diff --git a/docs/reference/canonical-lease-renew.md b/docs/reference/canonical-lease-renew.md index 5a12813470..f00b3d8adf 100644 --- a/docs/reference/canonical-lease-renew.md +++ b/docs/reference/canonical-lease-renew.md @@ -73,6 +73,11 @@ cooperative code-edit coordination, not a filesystem access-control mechanism. It does not authorize changing shared runtime data, Git administration, remote branches, or merging. Use ordinary exclusive leases for those operations. +Accepted origin URLs use the same repository identity rules as Todo declarations: +explicit default transport ports (including SSH `:22` and Git `:9418`) do not +create another repository, while nondefault ports remain distinct. Origins with +passwords or unsafe path segments are rejected before lease acquisition. + Same-worktree aliases, the same Todo, other machines or clones, and grants without a verified workspace retain existing exclusion. Repository mismatch, redirected paths and a non-worktree root fail closed. Verified machine discovery @@ -103,6 +108,10 @@ JSON 与 Markdown 读回同一仓库字段或未知状态。这只是 Goal 内 不识别物理目录、软链接别名,也不是跨 Goal 锁;不新增配置、promotion 或自动 委派。CLI 与 native provider 检查已覆盖该边界,完整前端/Lark 协作旅程仍需单独交付。 +独立 worktree 的 origin URL 与 Todo 声明使用同一仓库身份规则:显式默认端口 +(包括 SSH `:22` 和 Git `:9418`)不产生另一个仓库身份,非默认端口仍须匹配。 +带密码或不安全路径段的 origin 在获取租约前被拒绝。 + ## Operate the current lease Read the current canonical lease and use its owner, execution key and version: diff --git a/loopx/control_plane/work_items/task_lease_workspace.ts b/loopx/control_plane/work_items/task_lease_workspace.ts index 3c6778dafe..0ea68f074f 100644 --- a/loopx/control_plane/work_items/task_lease_workspace.ts +++ b/loopx/control_plane/work_items/task_lease_workspace.ts @@ -9,6 +9,7 @@ import {BARE_SHA256_PATTERN} from "../content_digest.ts"; import type {JsonObject} from "../effect_program.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {requireJsonObject} from "../runtime_decode.ts"; +import {normalizeTodoRepository} from "../todos/work_requirements.ts"; import {leaseWriteRepository} from "./task_lease_repository.ts"; export interface LeaseWorkspace extends JsonObject { @@ -84,10 +85,11 @@ export async function observeLeaseWorktree(path: string, overlaps: (path: string const remote = await git("remote", "get-url", "origin"); const scp = /^[^@/:]+@([^/:]+):(.+)$/u.exec(remote); const url = scp ? new URL(`ssh://${scp[1]}/${scp[2]}`) : new URL(remote); - if (!["ssh:", "https:", "http:"].includes(url.protocol) || !url.hostname || url.search || url.hash) { + if (!["git:", "ssh:", "https:", "http:"].includes(url.protocol) || !url.hostname || url.search || url.hash) { throw new EffectRuntimeRequestError("worktree origin must identify a Git repository"); } - const repository = leaseWriteRepository(`git:${url.host.toLowerCase()}/${url.pathname.replace(/^\/+|\/+$/gu, "").replace(/\.git$/u, "")}`)!; + // Validate the transport above, but share the Todo's raw-remote identity codec. + const repository = leaseWriteRepository(normalizeTodoRepository(remote))!; // Private local paths are not persisted. Filesystem inode identity collapses // symlink/case aliases; a recreated directory gets a new identity. const key = async (p: string) => {const s = await stat(p); return digest(`${s.dev}:${s.ino}`);}; diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index 3961b3ee41..054d86a98b 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -1,5 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; +import {execFileSync} from "node:child_process"; +import {mkdtemp, rm} from "node:fs/promises"; +import {platform, tmpdir} from "node:os"; +import {join} from "node:path"; import {evaluateTaskLeaseAcquireDecision} from "../../loopx/control_plane/work_items/task_lease_acquire_decision.ts"; import {leaseWorkspace, independentLeaseWorktrees, observeLeaseWorktree} from "../../loopx/control_plane/work_items/task_lease_workspace.ts"; @@ -9,6 +13,51 @@ test("native observation never interprets a caller path relative to the worker", await assert.rejects(observeLeaseWorktree(".", () => false), /absolute/); }); +test("observed Git origins use the Todo repository identity contract", { + skip: !["darwin", "linux"].includes(platform()), +}, async t => { + const temporary = await mkdtemp(join(tmpdir(), "loopx-lease-origin-")); + t.after(() => rm(temporary, {recursive: true, force: true})); + const main = join(temporary, "main"), worktree = join(temporary, "worktree"); + const git = (...args: string[]) => execFileSync("git", args, {stdio: "pipe"}); + git("init", main); + git("-C", main, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", + "commit", "--allow-empty", "-m", "fixture"); + git("-C", main, "worktree", "add", "-b", "fixture", worktree); + for (const [origin, expected] of [ + ["https://GITHUB.com:443/example/project.git", repo], + ["ssh://git@github.com:22/example/project.git", repo], + ["git://github.com:9418/example/project.git", repo], + ["git@github.com:example/project.git", repo], + ["ssh://git@github.com/example//project.git", repo], + ["ssh://git@github.com:2222/example/project.git", "git:github.com:2222/example/project"], + ]) { + await t.test(origin, async () => { + git("-C", main, "config", "remote.origin.url", origin); + const observed = await observeLeaseWorktree(worktree, () => false); + assert.equal(observed.repository, expected); + const input = request(); + const decision = evaluateTaskLeaseAcquireDecision({...input, other_leases: [], + todo: {...input.todo, task_repository: expected}, command: {...input.command, write_workspace: observed}}); + assert.equal(decision.outcome, "apply"); + }); + } + for (const origin of [ + "https://fixture:fixture@github.com/example/project.git", + "https://github.com/example/../project.git", + "https://github.com/example\\project.git", + "https://github.com/example/project.git?query=fixture", + "https://github.com/example/project.git#fragment", + "file:///example/project.git", + "git:github.com/example/project.git", + ]) { + await t.test(`reject ${origin}`, async () => { + git("-C", main, "config", "remote.origin.url", origin); + await assert.rejects(observeLeaseWorktree(worktree, () => false)); + }); + } +}); + function request(other: unknown = {...workspace, worktree: "4".repeat(64)}) { return {handoff_mode: "hard_lease", registered_agents: ["agent-a", "agent-b"], todo: {todo_id: "todo_a", status: "open", claimed_by: "agent-a", excluded_agents: [], task_repository: repo}, lease: null, diff --git a/tests/test_task_lease_worktree.py b/tests/test_task_lease_worktree.py index 6810149a2d..b9e067b0a5 100644 --- a/tests/test_task_lease_worktree.py +++ b/tests/test_task_lease_worktree.py @@ -12,7 +12,13 @@ @pytest.mark.skipif(sys.platform not in {"darwin", "linux"}, reason="verified host identity currently supports macOS/Linux") @pytest.mark.parametrize("provider", ["file", "sqlite"]) -def test_worktree_scope_admission_and_replay(tmp_path, monkeypatch, capsys, provider): +@pytest.mark.parametrize("origin,repository", [ + ("https://github.com/example/project.git", "git:github.com/example/project"), + ("ssh://git@github.com:22/example/project.git", "git:github.com/example/project"), + ("git://github.com:9418/example/project.git", "git:github.com/example/project"), + ("ssh://git@github.com:2222/example/project.git", "git:github.com:2222/example/project"), +]) +def test_worktree_scope_admission_and_replay(tmp_path, monkeypatch, capsys, provider, origin, repository): isolate_sqlite_runtime(tmp_path, monkeypatch) project = tmp_path / "repo" project.mkdir() @@ -22,7 +28,7 @@ def git(*args): git("init") git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", "commit", "--allow-empty", "-m", "fixture") - git("remote", "add", "origin", "https://github.com/example/project.git") + git("remote", "add", "origin", origin) a, b = tmp_path / "a", tmp_path / "b" git("worktree", "add", "-b", "a", str(a)) git("worktree", "add", "-b", "b", str(b)) @@ -38,7 +44,7 @@ def git(*args): projection = build_todo_runtime_shadow_projection(goal_id=goal, handoff_mode="hard_lease", leases=[], todos=[{ "schema_version": "todo_item_v0", "todo_id": f"todo_worktree_{key}", "role": "agent", "status": "open", "done": False, "text": "Isolated code editing", "archive_state": "active", "source_section": "Agent Todo", "index": i, - "task_class": "advancement_task", "claimed_by": owner, "task_repository": "git:github.com/example/project", + "task_class": "advancement_task", "claimed_by": owner, "task_repository": repository, } for i, (key, owner) in enumerate([("a", "agent-a"), ("b", "agent-b"), ("c", "agent-b"), ("d", "agent-b")], 1)]) initialize_canonical_authority(runtime, goal, projection, state_path=state, provider=provider) state.unlink() @@ -76,9 +82,14 @@ def acquire(key, path, expected=0, scope="src/**"): # An unrelated ignored link must not prevent a narrow code-edit lease. (a / "outside").symlink_to(project, target_is_directory=True) monkeypatch.chdir(a) + git("remote", "set-url", "origin", "ssh://git@github.com:2223/example/project.git") + assert acquire("a", a, expected=1)["error_code"] == "lease_workspace_repository_mismatch" + git("remote", "set-url", "origin", origin) first = acquire("a", Path(".")) assert first["source_authority"] == provider + "_v0" assert "write_workspace" in first["lease"] + assert first["lease"]["write_repository"] == repository + assert first["lease"]["write_workspace"]["repository"] == repository assert str(tmp_path) not in json.dumps(first["lease"]) conflict = acquire("c", alias, expected=1) assert conflict["error_code"] == "write_scope_conflict"