From a7365711bc3be3dda22a92988a6bb57fff1da9dc Mon Sep 17 00:00:00 2001 From: duang777 Date: Sat, 24 Oct 2026 11:15:31 +0800 Subject: [PATCH 1/2] fix(leases): canonicalize equivalent write scopes Prevent lexical path aliases from bypassing same-repository task-lease conflicts. Preserve retained lease bytes while using a conservative comparison view for legacy malformed scopes. Signed-off-by: duang777 --- .../coordination/task_lease_acquire.ts | 4 +- .../coordination/task_lease_state.ts | 7 +- .../work_items/task_lease_acquire.ts | 35 ++++-- .../work_items/task_lease_acquire_decision.ts | 75 +++++++++++-- tests/control_plane/test_task_lease.py | 1 + .../task_lease_acquire.test.ts | 23 ++++ .../task_lease_workspace.test.ts | 105 +++++++++++++++++- 7 files changed, 228 insertions(+), 22 deletions(-) diff --git a/loopx/control_plane/coordination/task_lease_acquire.ts b/loopx/control_plane/coordination/task_lease_acquire.ts index 33dbae51c5..8f587b2f7e 100644 --- a/loopx/control_plane/coordination/task_lease_acquire.ts +++ b/loopx/control_plane/coordination/task_lease_acquire.ts @@ -13,7 +13,7 @@ import {acceptanceWorkGuard} from "../goals/acceptance_contract.ts"; import {goalObjectiveWorkGuard} from "../goals/goal_objective.ts"; import {todoExecutionDependencyRejection} from "./todo_execution_dependency.ts"; import {normalizeGoalId, normalizeTodoId, normalizeOwner, normalizeIdempotencyKey, - normalizeWriteScopes, normalizeTtl, leaseEpoch, leaseVersion, leaseIsActive, + requireWriteScopes, normalizeTtl, leaseEpoch, leaseVersion, leaseIsActive, TaskLeaseAcquireError} from "../work_items/task_lease_acquire.ts"; export interface CanonicalTaskLeaseAcquireInput { @@ -33,7 +33,7 @@ export async function executeCanonicalTaskLeaseAcquire(store: AuthorityStore, ra input = {...raw, goal_id: normalizeGoalId(raw.goal_id), todo_id: normalizeTodoId(raw.todo_id), owner: normalizeOwner(raw.owner), idempotency_key: normalizeIdempotencyKey(raw.idempotency_key), write_workspace: leaseWorkspace(raw.write_workspace), - write_scopes: normalizeWriteScopes(raw.write_scopes), ttl_seconds: normalizeTtl(raw.ttl_seconds), + write_scopes: requireWriteScopes(raw.write_scopes), ttl_seconds: normalizeTtl(raw.ttl_seconds), registered_agents: raw.registered_agents.map(normalizeOwner)}; if (input.expected_version !== null && (!Number.isSafeInteger(input.expected_version) || input.expected_version < 0)) { return failed("invalid_expected_version", "expected lease version must be a non-negative safe integer"); diff --git a/loopx/control_plane/coordination/task_lease_state.ts b/loopx/control_plane/coordination/task_lease_state.ts index 29b9dd5034..0140166b98 100644 --- a/loopx/control_plane/coordination/task_lease_state.ts +++ b/loopx/control_plane/coordination/task_lease_state.ts @@ -7,7 +7,10 @@ import {normalizeTodoAgent} from "./todo_agents.ts"; import {leaseOwnerRejection} from "../work_items/task_lease_eligibility.ts"; import {leaseVersion, leaseEpoch, leaseInteger, leaseIsActive, normalizeOwner, normalizeIdempotencyKey, TASK_LEASE_SCHEMA_VERSION, type LeaseRecord, type TodoFact} from "../work_items/task_lease_acquire.ts"; -import type {AcquireDecisionInput} from "../work_items/task_lease_acquire_decision.ts"; +import { + comparisonWriteScopes, + type AcquireDecisionInput, +} from "../work_items/task_lease_acquire_decision.ts"; import {normalizeTodoRepository} from "../todos/work_requirements.ts"; import {leaseWriteRepository} from "../work_items/task_lease_repository.ts"; @@ -65,7 +68,7 @@ export function canonicalTaskLeaseAcquireFacts(index: ReturnType(); for (const item of raw) { - const scope = compact(item); + const scope = canonicalWriteScope(compact(item)); if ( - scope.length === 0 || scope.length > TODO_WRITE_SCOPE_MAX_CHARS || - scope.startsWith("/") || scope.startsWith("~") || - scope.split("/").includes("..") || /[\s<>]/u.test(scope) || - seen.has(scope) + scope === null || seen.has(scope) ) { continue; } @@ -259,6 +263,17 @@ export function normalizeWriteScopes(value: unknown): string[] { return scopes; } +export function requireWriteScopes(value: unknown): string[] { + const raw = writeScopeCandidates(value); + if (raw.some(item => canonicalWriteScope(compact(item)) === null)) { + throw new TaskLeaseAcquireError( + "write_scopes must contain valid repository-relative scope tokens", + "invalid_write_scopes", + ); + } + return normalizeWriteScopes(raw); +} + export function normalizeTtl(value: unknown): number { const ttl = value === null || value === undefined ? DEFAULT_TTL_SECONDS @@ -424,7 +439,7 @@ function decodeRequest(value: unknown): AcquireRequest { owner: normalizeOwner(request.owner), todo_id: normalizeTodoId(request.todo_id), idempotency_key: normalizeIdempotencyKey(request.idempotency_key), - write_scopes: normalizeWriteScopes(request.write_scopes), + write_scopes: requireWriteScopes(request.write_scopes), ttl_seconds: normalizeTtl(request.ttl_seconds), expected_version: optionalInteger(request.expected_version, "expected_version"), authority, diff --git a/loopx/control_plane/work_items/task_lease_acquire_decision.ts b/loopx/control_plane/work_items/task_lease_acquire_decision.ts index 3ae8dddaca..ecde5fae35 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -10,6 +10,8 @@ import type {TodoFact, LeaseRecord} from "./task_lease_acquire.ts"; import {normalizeTodoRepository} from "../todos/work_requirements.ts"; import {leaseWriteRepository, leaseRepositoryRejection, repositoryScopesMayOverlap} from "./task_lease_repository.ts"; +export const TODO_WRITE_SCOPE_MAX_CHARS = 160; + export interface AcquireDecisionLease { present: boolean; active: boolean; @@ -66,6 +68,63 @@ function stringValue(value: unknown, label: string): string { return value; } +/** + * Canonicalize one repository-relative scope without resolving it against a + * host filesystem. Glob character classes retain their own slash semantics. + */ +export function canonicalWriteScope(value: string): string | null { + const scope = value.trim(); + if ( + scope.length === 0 || scope.length > TODO_WRITE_SCOPE_MAX_CHARS || + scope.startsWith("/") || scope.startsWith("~") || scope.includes("\\") || + /[\s<>]/u.test(scope) + ) { + return null; + } + const parts: string[] = []; + let start = 0; + for (let index = 0; index < scope.length; index += 1) { + if (scope[index] === "[") { + const closing = scope.indexOf("]", index + 1); + if (closing >= 0) { + index = closing; + continue; + } + } + if (scope[index] !== "/") continue; + parts.push(scope.slice(start, index)); + start = index + 1; + } + parts.push(scope.slice(start)); + const trailingSlash = scope.endsWith("/"); + const canonical: string[] = []; + for (const part of parts) { + if (part.length === 0 || part === ".") continue; + if (part === "..") return null; + canonical.push(part); + } + if (canonical.length === 0) return trailingSlash ? "./" : null; + return `${canonical.join("/")}${trailingSlash ? "/" : ""}`; +} + +/** + * Build a conflict-comparison view for persisted scopes without rewriting the + * retained record. An unreadable legacy scope blocks new work conservatively. + */ +export function comparisonWriteScopes(scopes: readonly string[]): string[] { + const canonical: string[] = []; + const seen = new Set(); + for (const raw of scopes) { + const scope = canonicalWriteScope(raw); + if (scope === null) return ["**"]; + if (!seen.has(scope)) { + seen.add(scope); + canonical.push(scope); + } + } + return canonical; +} + function optionalInteger(value: unknown, label: string): number | null { if (value === null || value === undefined) return null; if (typeof value !== "number" || !Number.isInteger(value)) { @@ -182,8 +241,8 @@ export function evaluateTaskLeaseWriteScopesOverlap(value: unknown): JsonObject const input = requireJsonObject(value, "task lease write-scope overlap"); return { overlap: writeScopesOverlap( - decisionStringArray(input.left, "left"), - decisionStringArray(input.right, "right"), + comparisonWriteScopes(decisionStringArray(input.left, "left")), + comparisonWriteScopes(decisionStringArray(input.right, "right")), ), }; } @@ -239,7 +298,9 @@ function decodeDecisionLease(value: unknown): AcquireDecisionLease | null { ), version: decisionInteger(lease.version, "lease.version"), lease_epoch: decisionInteger(lease.lease_epoch, "lease.lease_epoch"), - write_scopes: decisionStringArray(lease.write_scopes, "lease.write_scopes"), + write_scopes: comparisonWriteScopes( + decisionStringArray(lease.write_scopes, "lease.write_scopes"), + ), write_repository: leaseWriteRepository(lease.write_repository), write_workspace: leaseWorkspace(lease.write_workspace), acquire_ttl_seconds: optionalInteger( @@ -264,10 +325,10 @@ function decodeAcquireDecisionInput(value: unknown): AcquireDecisionInput { effective: decisionBoolean(lease.effective, `other_leases[${index}].effective`), write_repository: leaseWriteRepository(lease.write_repository), write_workspace: leaseWorkspace(lease.write_workspace), - write_scopes: decisionStringArray( + write_scopes: comparisonWriteScopes(decisionStringArray( lease.write_scopes, `other_leases[${index}].write_scopes`, - ), + )), }; }); return { @@ -286,10 +347,10 @@ function decodeAcquireDecisionInput(value: unknown): AcquireDecisionInput { "command.idempotency_key", ), ttl_seconds: decisionInteger(command.ttl_seconds, "command.ttl_seconds"), - write_scopes: decisionStringArray( + write_scopes: comparisonWriteScopes(decisionStringArray( command.write_scopes, "command.write_scopes", - ), + )), write_workspace: leaseWorkspace(command.write_workspace), expected_version: optionalInteger( command.expected_version, diff --git a/tests/control_plane/test_task_lease.py b/tests/control_plane/test_task_lease.py index 06fbb99ff1..c68659c863 100644 --- a/tests/control_plane/test_task_lease.py +++ b/tests/control_plane/test_task_lease.py @@ -52,6 +52,7 @@ def test_local_lease_reader_preserves_bytes_and_types_corruption(tmp_path: Path, (["**"], ["loopx/cli.py"], True), (["docs/a.md"], ["docs/b.md"], False), ([], ["docs/a.md"], False), + (["src//main.ts"], ["src/./main.ts"], True), ], ) def test_write_scopes_overlap( diff --git a/tests/control_plane_ts/task_lease_acquire.test.ts b/tests/control_plane_ts/task_lease_acquire.test.ts index d35e9e7833..932f4cf258 100644 --- a/tests/control_plane_ts/task_lease_acquire.test.ts +++ b/tests/control_plane_ts/task_lease_acquire.test.ts @@ -94,3 +94,26 @@ test("canonical acquire checks the durable fence before opening a provider", asy assert.equal(result.legacy_fallback_used, false); assert.equal(opened, 0); }); + +test("canonical acquire rejects noncanonical write scopes before opening a provider", async (t) => { + const root = await mkdtemp(join(tmpdir(), "loopx-lease-acquire-scope-")); + t.after(() => rm(root, {recursive: true, force: true})); + const runtimeRoot = join(root, "runtime"); + let opened = 0; + + const result = await executeTaskLeaseAcquire( + {...request(runtimeRoot, TASK_LEASE_CANONICAL_ACQUIRE_REQUEST_SCHEMA), write_scopes: ["src\\main.ts"]}, + { + authorityProvider: { + createStore: () => { + opened += 1; + throw new Error("provider must remain closed"); + }, + }, + }, + ); + + assert.equal(result.ok, false); + assert.equal(result.error_code, "invalid_write_scopes"); + assert.equal(opened, 0); +}); diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index 62f033aaa8..195ac64cfb 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -4,7 +4,10 @@ import {execFileSync} from "node:child_process"; import {mkdtemp, rm, mkdir, writeFile, symlink, realpath} from "node:fs/promises"; import {platform, tmpdir} from "node:os"; import {join} from "node:path"; -import {evaluateTaskLeaseAcquireDecision, evaluateTaskLeaseWriteScopesOverlap} from "../../loopx/control_plane/work_items/task_lease_acquire_decision.ts"; +import {decideTaskLeaseAcquire, evaluateTaskLeaseAcquireDecision, evaluateTaskLeaseWriteScopesOverlap} from "../../loopx/control_plane/work_items/task_lease_acquire_decision.ts"; +import {normalizeWriteScopes} from "../../loopx/control_plane/work_items/task_lease_acquire.ts"; +import {canonicalTaskLeaseAcquireFacts} from "../../loopx/control_plane/coordination/task_lease_state.ts"; +import {indexCoordinationProjection} from "../../loopx/control_plane/coordination/coordination_projection.ts"; import {leaseWorkspace, sameLeaseCheckout, observeLeaseWorktree} from "../../loopx/control_plane/work_items/task_lease_workspace.ts"; const repo = "git:github.com/example/project"; @@ -165,6 +168,106 @@ test("ordinary exclusive grants retain overlap rejection without worktree mode", assert.equal(evaluateTaskLeaseAcquireDecision(exclusive).code, "write_scope_conflict"); } }); + +test("lexical scope aliases cannot bypass an ordinary exclusive lease", () => { + const now = new Date("2030-01-01T00:00:00Z"); + const projection = indexCoordinationProjection({ + goal_id: "goal-a", + todos: [ + {goal_id: "goal-a", todo_id: "todo_target", archive_state: "active", status: "open", claimed_by: "agent-a"}, + {goal_id: "goal-a", todo_id: "todo_holder", archive_state: "active", status: "open", claimed_by: "agent-b"}, + ], + leases: [{ + schema_version: "task_lease_v0", goal_id: "goal-a", todo_id: "todo_holder", + owner: "agent-b", idempotency_key: "holder", status: "active", version: 1, + lease_epoch: 1, write_scopes: ["src/./main.ts"], + expires_at: "2031-01-01T00:00:00Z", + }], + }, "goal-a"); + const facts = canonicalTaskLeaseAcquireFacts( + projection, + "goal-a", + "todo_target", + ["agent-a", "agent-b"], + now, + ); + const decision = decideTaskLeaseAcquire({ + handoff_mode: "hard_lease", + registered_agents: ["agent-a", "agent-b"], + ...facts, + command: { + owner: "agent-a", + idempotency_key: "target", + ttl_seconds: 60, + expected_version: 0, + write_scopes: normalizeWriteScopes(["src//main.ts"]), + }, + }); + + assert.equal(decision.code, "write_scope_conflict"); + assert.deepEqual(facts.other_leases[0].write_scopes, ["src/main.ts"]); + assert.deepEqual(projection.leases.get("todo_holder")?.write_scopes, ["src/./main.ts"]); +}); + +test("malformed retained scopes conservatively conflict without rewriting history", () => { + for (const holderScope of ["../legacy", "src\\legacy"]) { + const now = new Date("2030-01-01T00:00:00Z"); + const projection = indexCoordinationProjection({ + goal_id: "goal-a", + todos: [ + {goal_id: "goal-a", todo_id: "todo_target", archive_state: "active", status: "open", claimed_by: "agent-a"}, + {goal_id: "goal-a", todo_id: "todo_holder", archive_state: "active", status: "open", claimed_by: "agent-b"}, + ], + leases: [{ + schema_version: "task_lease_v0", goal_id: "goal-a", todo_id: "todo_holder", + owner: "agent-b", idempotency_key: "holder", status: "active", version: 1, + lease_epoch: 1, write_scopes: [holderScope], + expires_at: "2031-01-01T00:00:00Z", + }], + }, "goal-a"); + const facts = canonicalTaskLeaseAcquireFacts( + projection, + "goal-a", + "todo_target", + ["agent-a", "agent-b"], + now, + ); + const decision = decideTaskLeaseAcquire({ + handoff_mode: "hard_lease", + registered_agents: ["agent-a", "agent-b"], + ...facts, + command: { + owner: "agent-a", + idempotency_key: "target", + ttl_seconds: 60, + expected_version: 0, + write_scopes: ["docs/next.md"], + }, + }); + + assert.equal(decision.code, "write_scope_conflict"); + assert.deepEqual(facts.other_leases[0].write_scopes, ["**"]); + assert.deepEqual(projection.leases.get("todo_holder")?.write_scopes, [holderScope]); + } +}); + +test("scope normalization and pure overlap agree on lexical aliases", () => { + assert.deepEqual( + normalizeWriteScopes(["src//main.ts", "src/./main.ts", "./src/main.ts"]), + ["src/main.ts"], + ); + assert.deepEqual( + normalizeWriteScopes(["src/[./]/main.ts", "src/[//]/main.ts", "src/"]), + ["src/[./]/main.ts", "src/[//]/main.ts", "src/"], + ); + assert.equal( + evaluateTaskLeaseWriteScopesOverlap({ + left: ["src//main.ts"], + right: ["src/./main.ts"], + }).overlap, + true, + ); +}); test("a known same-checkout collision wins over changed repository metadata", () => { const other = {...workspace, repository: "git:github.com/example/other"}; const input = request(other); From b61bc6a7e0aadc16d3801560e79448e3ee09f27e Mon Sep 17 00:00:00 2001 From: duang777 Date: Sat, 24 Oct 2026 13:41:58 +0800 Subject: [PATCH 2/2] fix(leases): preserve scope identity and glob boundaries Keep original scope spellings in acquisition request identity while using canonical comparison and new-record views. Preserve accepted Todo alias declarations, share character-class parsing, and reject malformed scope evidence in historical receipts. Cover public File/SQLite acquisition, cross-version renewal replay, ordinary add-to-claim and exclusive glob conflicts. Signed-off-by: duang777 --- docs/reference/canonical-lease-renew.md | 12 ++ .../coordination/task_lease_acquire.ts | 10 +- .../work_items/task_lease_acquire.ts | 5 +- .../work_items/task_lease_acquire_decision.ts | 29 +++-- .../test_canonical_lease_scopes.py | 114 ++++++++++++++++++ .../canonical_task_lease_renew.test.ts | 22 ++++ .../task_lease_workspace.test.ts | 19 ++- 7 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 tests/control_plane/test_canonical_lease_scopes.py diff --git a/docs/reference/canonical-lease-renew.md b/docs/reference/canonical-lease-renew.md index e823d26941..a783d84311 100644 --- a/docs/reference/canonical-lease-renew.md +++ b/docs/reference/canonical-lease-renew.md @@ -43,6 +43,18 @@ code-edit mode below. Host/path case aliases remain overlapping. The existing complete-head scan, owner eligibility, TTL, generations, CAS and receipt identities are unchanged; an empty scope set still does not conflict. +Scope comparison folds lexical path aliases such as `src//main.ts` and +`src/./main.ts`, while preserving slash and dot members inside glob character +classes. Fresh lease records use this canonical scope set. The original request +spellings remain part of the immutable acquisition identity, so an exact retry +of a retained request recovers its original receipt, including after renewal. +Equivalent strings in an accepted Todo declaration do not require a declaration +revision before atomic claim/acquire. + +冲突比较会归并相对路径的词法别名,但保留 glob 字符类中的斜杠、点与字面量右括号。 +新租约保存规范范围;原请求的拼写仍参与不可变请求身份,旧请求在升级或续租后按原参数 +重试会恢复原回执与当前执行证明。已有 Todo 声明中的等价字符串不会要求用户先重录声明。 + Old grants without `write_repository` (or with null) remain unknown and conservatively overlap any repository for ordinary exclusive acquisition. Reading or renewing them does not diff --git a/loopx/control_plane/coordination/task_lease_acquire.ts b/loopx/control_plane/coordination/task_lease_acquire.ts index 8f587b2f7e..a30101e6ac 100644 --- a/loopx/control_plane/coordination/task_lease_acquire.ts +++ b/loopx/control_plane/coordination/task_lease_acquire.ts @@ -7,7 +7,7 @@ import {AuthorityStoreProtocolError, canonicalAuthorityObject, canonicalAuthorit import {CoordinationCommandReceipt, commandReceiptResult} from "./command_receipt.ts"; import {indexCoordinationProjection, prepareCoordinationProjectionCommit} from "./coordination_projection.ts"; import {canonicalTaskLease} from "./task_lease_state.ts"; -import {decideTaskLeaseAcquire, materializeTaskLeaseAcquire} from "../work_items/task_lease_acquire_decision.ts"; +import {canonicalWriteScope, comparisonWriteScopes, decideTaskLeaseAcquire, materializeTaskLeaseAcquire} from "../work_items/task_lease_acquire_decision.ts"; import {acquisitionFacts, currentLeaseAcquisitionProof} from "./lease_acquisition_proof.ts"; import {acceptanceWorkGuard} from "../goals/acceptance_contract.ts"; import {goalObjectiveWorkGuard} from "../goals/goal_objective.ts"; @@ -53,8 +53,12 @@ export async function executeCanonicalTaskLeaseAcquire(store: AuthorityStore, ra decode(original) { const payload = commandReceiptResult(original); const lease = canonicalTaskLease(canonicalAuthorityObject(payload.fields.lease, "acquire receipt lease"), input.goal_id, input.todo_id); - const scopes = [...(lease.write_scopes ?? []) as string[]].sort(); - if (!sameLeaseWorkspace(lease.write_workspace, input.write_workspace) || JSON.stringify(scopes) !== JSON.stringify([...input.write_scopes].sort()) || + const retainedScopes = (lease.write_scopes ?? []) as string[]; + if (retainedScopes.some(scope => canonicalWriteScope(scope) === null)) { + throw new AuthorityStoreProtocolError("acquire receipt contains an invalid write scope"); + } + const scopes = comparisonWriteScopes(retainedScopes).sort(); + if (!sameLeaseWorkspace(lease.write_workspace, input.write_workspace) || JSON.stringify(scopes) !== JSON.stringify(comparisonWriteScopes(input.write_scopes).sort()) || (lease.acquire_ttl_seconds != null && lease.acquire_ttl_seconds !== input.ttl_seconds) || (payload.changed && input.expected_version !== null && leaseVersion(lease) !== input.expected_version + 1)) { throw new AuthorityStoreProtocolError("acquire receipt does not match its original parameters"); diff --git a/loopx/control_plane/work_items/task_lease_acquire.ts b/loopx/control_plane/work_items/task_lease_acquire.ts index 79c5f229bd..8a4e027258 100644 --- a/loopx/control_plane/work_items/task_lease_acquire.ts +++ b/loopx/control_plane/work_items/task_lease_acquire.ts @@ -246,14 +246,15 @@ function writeScopeCandidates(value: unknown): unknown[] { return raw; } +/** Keep request spellings stable for immutable receipts and declared work. */ export function normalizeWriteScopes(value: unknown): string[] { const raw = writeScopeCandidates(value); const scopes: string[] = []; const seen = new Set(); for (const item of raw) { - const scope = canonicalWriteScope(compact(item)); + const scope = compact(item); if ( - scope === null || seen.has(scope) + canonicalWriteScope(scope) === null || seen.has(scope) ) { continue; } diff --git a/loopx/control_plane/work_items/task_lease_acquire_decision.ts b/loopx/control_plane/work_items/task_lease_acquire_decision.ts index ecde5fae35..d116285b90 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -68,6 +68,13 @@ function stringValue(value: unknown, label: string): string { return value; } +function characterClassEnd(pattern: string, start: number): number { + let end = start + 1; + if (pattern[end] === "!") end += 1; + if (pattern[end] === "]") end += 1; + return pattern.indexOf("]", end); +} + /** * Canonicalize one repository-relative scope without resolving it against a * host filesystem. Glob character classes retain their own slash semantics. @@ -85,7 +92,7 @@ export function canonicalWriteScope(value: string): string | null { let start = 0; for (let index = 0; index < scope.length; index += 1) { if (scope[index] === "[") { - const closing = scope.indexOf("]", index + 1); + const closing = characterClassEnd(scope, index); if (closing >= 0) { index = closing; continue; @@ -138,10 +145,7 @@ function classMatch( start: number, value: string, ): { end: number; matches: boolean } | null { - let end = start + 1; - if (pattern[end] === "!") end += 1; - if (pattern[end] === "]") end += 1; - end = pattern.indexOf("]", end); + const end = characterClassEnd(pattern, start); if (end < 0) return null; let body = pattern.slice(start + 1, end); const negated = body.startsWith("!"); @@ -227,7 +231,8 @@ function scopePairOverlaps(left: string, right: string): boolean { function writeScopesOverlap(left: readonly string[], right: readonly string[]): boolean { if (left.length === 0 || right.length === 0) return false; - return left.some((a) => right.some((b) => scopePairOverlaps(a, b))); + const a = comparisonWriteScopes(left), b = comparisonWriteScopes(right); + return a.some((first) => b.some((second) => scopePairOverlaps(first, second))); } function decisionStringArray(value: unknown, label: string): string[] { @@ -241,8 +246,8 @@ export function evaluateTaskLeaseWriteScopesOverlap(value: unknown): JsonObject const input = requireJsonObject(value, "task lease write-scope overlap"); return { overlap: writeScopesOverlap( - comparisonWriteScopes(decisionStringArray(input.left, "left")), - comparisonWriteScopes(decisionStringArray(input.right, "right")), + decisionStringArray(input.left, "left"), + decisionStringArray(input.right, "right"), ), }; } @@ -475,7 +480,7 @@ export function decideTaskLeaseAcquire(input: AcquireDecisionInput): AcquireDeci idempotency_key: command.idempotency_key, version: actualVersion + 1, lease_epoch: (lease?.lease_epoch ?? 0) + 1, - write_scopes: [...command.write_scopes], + write_scopes: comparisonWriteScopes(command.write_scopes), ...(workspace ? {write_workspace: workspace} : {}), ...(repository === null ? {} : {write_repository: repository}), acquire_ttl_seconds: command.ttl_seconds, @@ -484,8 +489,8 @@ export function decideTaskLeaseAcquire(input: AcquireDecisionInput): AcquireDeci } function equalScopeSets(left: readonly string[], right: readonly string[]): boolean { - const a = [...new Set(left)].sort((first, second) => first.localeCompare(second)); - const b = [...new Set(right)].sort((first, second) => first.localeCompare(second)); + const a = comparisonWriteScopes(left).sort((first, second) => first.localeCompare(second)); + const b = comparisonWriteScopes(right).sort((first, second) => first.localeCompare(second)); return a.length === b.length && a.every((value, index) => value === b[index]); } @@ -499,7 +504,7 @@ export function materializeTaskLeaseAcquire(identity: {goal_id: string; todo_id: return {schema_version: "task_lease_v0", goal_id: identity.goal_id, todo_id: identity.todo_id, owner: command.owner, idempotency_key: command.idempotency_key, ...(decision.next_lease.write_workspace ? {write_workspace: leaseWorkspace(decision.next_lease.write_workspace)} : {}), - write_scopes: [...command.write_scopes], acquire_ttl_seconds: command.ttl_seconds, + write_scopes: comparisonWriteScopes(command.write_scopes), acquire_ttl_seconds: command.ttl_seconds, ...(decision.next_lease.write_repository == null ? {} : {write_repository: leaseWriteRepository(decision.next_lease.write_repository)}), version: decisionInteger(decision.next_lease.version, "next_lease.version"), diff --git a/tests/control_plane/test_canonical_lease_scopes.py b/tests/control_plane/test_canonical_lease_scopes.py new file mode 100644 index 0000000000..e32eb40a88 --- /dev/null +++ b/tests/control_plane/test_canonical_lease_scopes.py @@ -0,0 +1,114 @@ +"""Scope comparison must preserve request identity and accepted declarations.""" +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + +REPO = Path(__file__).resolve().parents[2] +ALIASES = ["src//main.ts", "src/./main.ts"] + + +@pytest.fixture(params=["file", "sqlite"]) +def scope_goal(tmp_path, monkeypatch, request): + isolate_sqlite_runtime(tmp_path, monkeypatch) + runtime, state, registry = tmp_path / "runtime", tmp_path / "state.md", tmp_path / "registry.json" + goal = "scope-contract" + state.write_text("# Synthetic scope contract\n") + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ + "id": goal, "repo": str(tmp_path), "state_file": state.name, + "coordination": {"registered_agents": ["agent-a", "agent-b"]}, + }]})) + todos = [{"schema_version": "todo_item_v0", "todo_id": "todo_scope_" + suffix, + "role": "agent", "task_class": "advancement_task", "status": "open", "done": False, + "text": "Synthetic scope work " + suffix, "archive_state": "active", + "source_section": "Agent Todo", "index": index, + "task_repository": "git:github.com/example/project", + "required_write_scopes": ALIASES if suffix == "a" else [], + } for index, suffix in enumerate(["a", "b"], 1)] + projection = build_todo_runtime_shadow_projection(goal_id=goal, handoff_mode="hard_lease", todos=todos) + initialize_canonical_authority(runtime, goal, projection, state_path=state, provider=request.param) + + def cli(*args, cwd=None, expected_exit=0): + selected_repo = Path(os.environ.get("LOOPX_LEASE_SCOPE_CONSUMER", REPO)) if cwd is None else cwd + child = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", *args, "--goal-id", goal], + cwd=selected_repo, capture_output=True, text=True, timeout=60, check=False) + assert child.returncode == expected_exit, child.stdout + child.stderr + return json.loads(child.stdout) + + def current(): + return read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=goal, include_leases=True) + + return cli, current + + +def test_alias_acquire_keeps_original_request_identity_after_renewal(scope_goal): + cli, current = scope_goal + acquire = ("task-lease", "acquire", "--todo-id", "todo_scope_a", "--owner", "agent-a", + "--idempotency-key", "alias-a", "--expected-version", "0", "--ttl-seconds", "600", + "--write-scope", "src//main.ts") + producer = Path(os.environ.get("LOOPX_LEASE_SCOPE_PRODUCER", REPO)) + first = cli(*acquire, cwd=producer) + original = first["original_receipt"] + before = current() + replay = cli(*acquire) + assert replay["idempotent"] and replay["original_receipt"] == original + assert current() == before + renewed = cli("task-lease", "renew", "--todo-id", "todo_scope_a", "--owner", "agent-a", + "--idempotency-key", "alias-a", "--expected-version", "1", "--ttl-seconds", "600") + before = current() + replay = cli(*acquire) + assert replay["lease"] == renewed["lease"] + assert replay["lease"]["version"] == 2 and replay["lease"]["lease_epoch"] == 1 + assert replay["original_receipt"] == original and current() == before + changed = (*acquire[:-1], "src/main.ts") + rejected = cli(*changed, expected_exit=1) + assert rejected["error_code"] == "coordination_operation_identity_mismatch" + assert current() == before + + +def test_retained_alias_declaration_can_be_claimed(scope_goal): + cli, _ = scope_goal + claimed = cli("todo", "claim", "--todo-id", "todo_scope_a", "--claimed-by", "agent-a", + "--agent-id", "agent-a", "--claim-operation-id", "alias-claim", + "--task-lease-idempotency-key", "alias-claim", "--task-lease-expected-version", "0") + assert claimed["ok"] + assert cli("task-lease", "inspect", "--todo-id", "todo_scope_a")["lease"]["write_scopes"] == ["src/main.ts"] + assert cli("todo", "list", "--todo-id", "todo_scope_a")["todo"]["required_write_scopes"] == ALIASES + + +def test_authored_alias_declaration_can_be_claimed_without_revision(scope_goal): + cli, _ = scope_goal + added = cli("todo", "add", "--role", "agent", "--priority", "P1", "--text", "Acquire alias work", + "--task-repository", "git:github.com/example/project", "--required-write-scope", ALIASES[0], + "--required-write-scope", ALIASES[1], "--operation-id", "scope-add") + todo_id = added["todo_id"] + claimed = cli("todo", "claim", "--todo-id", todo_id, "--claimed-by", "agent-a", "--agent-id", "agent-a", + "--claim-operation-id", "authored-claim", "--task-lease-idempotency-key", "authored-claim", + "--task-lease-expected-version", "0") + assert claimed["ok"] + assert cli("task-lease", "inspect", "--todo-id", todo_id)["lease"]["write_scopes"] == ["src/main.ts"] + assert cli("todo", "list", "--todo-id", todo_id)["todo"]["required_write_scopes"] == ALIASES + + +@pytest.mark.parametrize("scope,target", [ + ("src/[]/./]x", "src/.x"), + ("src/[]/./]x", "src/]x"), + ("src/[!]/./]x", "src/ax"), +]) +def test_glob_class_members_keep_exclusive_scope_coverage(scope_goal, scope, target): + cli, current = scope_goal + first = cli("task-lease", "acquire", "--todo-id", "todo_scope_a", "--owner", "agent-a", + "--idempotency-key", "class-a", "--write-scope", scope, "--expected-version", "0") + assert first["lease"]["write_scopes"] == [scope] + before = current() + rejected = cli("task-lease", "acquire", "--todo-id", "todo_scope_b", "--owner", "agent-b", + "--idempotency-key", "class-b", "--write-scope", target, "--expected-version", "0", expected_exit=1) + assert rejected["error_code"] == "write_scope_conflict" and current() == before diff --git a/tests/control_plane_ts/canonical_task_lease_renew.test.ts b/tests/control_plane_ts/canonical_task_lease_renew.test.ts index eb144bbaa2..53b27dd7ce 100644 --- a/tests/control_plane_ts/canonical_task_lease_renew.test.ts +++ b/tests/control_plane_ts/canonical_task_lease_renew.test.ts @@ -108,6 +108,28 @@ for (const provider of ["file", "sqlite"] as const) { expected_version: 1, ttl_seconds: 600, write_scopes: ["src/**"], authority: request.authority}; } const acquireNow = new Date("2026-09-13T10:11:00Z"); + providerTest(`${provider} malformed receipt scopes cannot become a root wildcard`, async t => { + const {store, request, lease, goal} = await fixture(t, provider); + const command = {...acquireRequest(request), write_scopes: ["**"]}; + const identityFields = {goal_id: goal, todo_id: command.todo_id, owner: command.owner, + idempotency_key: command.idempotency_key}; + const operation_id = `lease-acquire:${canonicalAuthoritySha256(identityFields)}`; + const retained = {...lease, idempotency_key: command.idempotency_key, version: 2, lease_epoch: 8, + write_scopes: ["../bad"], acquired_at: "2026-09-13T10:11:00Z", updated_at: "2026-09-13T10:11:00Z", + expires_at: "2026-09-13T10:21:00Z"}; + const loaded = await store.loadAuthority(); + assert.equal(loaded.status, "loaded"); + if (loaded.status !== "loaded") throw new Error("fixture head missing"); + const identity = {schema_version: "loopx_canonical_task_lease_acquire_receipt_v0", operation_id, goal_id: goal, + request_sha256: canonicalAuthoritySha256({...identityFields, expected_version: 1, ttl_seconds: 600, write_scopes: ["**"]})}; + assert.equal((await store.commitAuthority({operation_id, expected_provider_revision: loaded.provider_revision, + next_projection: authorityProjectionFixture(goal, request.authority.todos, [retained], "native", {handoff_mode: "hard_lease"}), + events: [], receipts: [{...identity, result: {changed: true, lease: retained, handoff_mode: "hard_lease"}}]})).status, "applied"); + const before = await store.loadAuthority(); + const result = await executeTaskLeaseAcquire(command, {now: () => acquireNow}); + assert.equal(result.error_code, "invalid_coordination_command_receipt"); + assert.deepEqual(await store.loadAuthority(), before); + }); providerTest(`${provider} legacy acquire wire requires canonical authority`, async t => { const {store, request, state} = await fixture(t, provider); const command = acquireRequest(request); diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index 195ac64cfb..741c7dccbc 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -4,7 +4,7 @@ import {execFileSync} from "node:child_process"; import {mkdtemp, rm, mkdir, writeFile, symlink, realpath} from "node:fs/promises"; import {platform, tmpdir} from "node:os"; import {join} from "node:path"; -import {decideTaskLeaseAcquire, evaluateTaskLeaseAcquireDecision, evaluateTaskLeaseWriteScopesOverlap} from "../../loopx/control_plane/work_items/task_lease_acquire_decision.ts"; +import {canonicalWriteScope, comparisonWriteScopes, decideTaskLeaseAcquire, evaluateTaskLeaseAcquireDecision, evaluateTaskLeaseWriteScopesOverlap} from "../../loopx/control_plane/work_items/task_lease_acquire_decision.ts"; import {normalizeWriteScopes} from "../../loopx/control_plane/work_items/task_lease_acquire.ts"; import {canonicalTaskLeaseAcquireFacts} from "../../loopx/control_plane/coordination/task_lease_state.ts"; import {indexCoordinationProjection} from "../../loopx/control_plane/coordination/coordination_projection.ts"; @@ -251,9 +251,11 @@ test("malformed retained scopes conservatively conflict without rewriting histor } }); -test("scope normalization and pure overlap agree on lexical aliases", () => { +test("scope comparison canonicalizes aliases while request identity keeps its spelling", () => { + const aliases = ["src//main.ts", "src/./main.ts", "./src/main.ts"]; + assert.deepEqual(normalizeWriteScopes(aliases), aliases); assert.deepEqual( - normalizeWriteScopes(["src//main.ts", "src/./main.ts", "./src/main.ts"]), + comparisonWriteScopes(aliases), ["src/main.ts"], ); assert.deepEqual( @@ -268,6 +270,17 @@ test("scope normalization and pure overlap agree on lexical aliases", () => { true, ); }); +test("scope comparison and matching share literal right-bracket character classes", () => { + for (const [scope, target] of [ + ["src/[]/./]x", "src/.x"], + ["src/[]/./]x", "src/]x"], + ["src/[!]/./]x", "src/ax"], + ]) { + assert.equal(canonicalWriteScope(scope), scope); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [scope], right: [target]}).overlap, true); + } + assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: ["src/[!]/./]x"], right: ["src/.x"]}).overlap, false); +}); test("a known same-checkout collision wins over changed repository metadata", () => { const other = {...workspace, repository: "git:github.com/example/other"}; const input = request(other);