diff --git a/packages/adapter-utils/src/git-workspace-sync.test.ts b/packages/adapter-utils/src/git-workspace-sync.test.ts index 596cd70981..3f5b70ca8c 100644 --- a/packages/adapter-utils/src/git-workspace-sync.test.ts +++ b/packages/adapter-utils/src/git-workspace-sync.test.ts @@ -14,6 +14,7 @@ import { isMissingGitPrerequisiteError, readGitWorkspaceSnapshot, runLocalGit, + sanitizeGitRemoteUrl, withShallowGitWorkspaceClone, } from "./git-workspace-sync.js"; @@ -73,6 +74,101 @@ describe("git workspace sync", () => { }); }); + it("copies the workspace origin remote into the shallow clone", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-origin-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + await git(repo, ["remote", "add", "origin", "https://github.com/example/repo.git"]); + + const snapshot = await readGitWorkspaceSnapshot(repo); + await withShallowGitWorkspaceClone({ + localDir: repo, + snapshot: snapshot!, + }, async (cloneDir) => { + expect(await git(cloneDir, ["remote", "get-url", "origin"])).toBe("https://github.com/example/repo.git"); + }); + }); + + it("scrubs credentials from the origin remote before copying it", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-origin-scrub-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + await git(repo, ["remote", "add", "origin", "https://x-access-token:sekret@github.com/example/repo.git"]); + + const snapshot = await readGitWorkspaceSnapshot(repo); + await withShallowGitWorkspaceClone({ + localDir: repo, + snapshot: snapshot!, + }, async (cloneDir) => { + expect(await git(cloneDir, ["remote", "get-url", "origin"])).toBe("https://github.com/example/repo.git"); + }); + }); + + it("leaves the shallow clone remote-less when the workspace has no origin", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-no-origin-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + + const snapshot = await readGitWorkspaceSnapshot(repo); + await withShallowGitWorkspaceClone({ + localDir: repo, + snapshot: snapshot!, + }, async (cloneDir) => { + await expect(git(cloneDir, ["remote", "get-url", "origin"])).rejects.toThrow(); + }); + }); + + it("drops a filesystem-path origin instead of copying it into the shallow clone", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-path-origin-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + await git(repo, ["remote", "add", "origin", path.join(rootDir, "elsewhere.git")]); + + const snapshot = await readGitWorkspaceSnapshot(repo); + await withShallowGitWorkspaceClone({ + localDir: repo, + snapshot: snapshot!, + }, async (cloneDir) => { + await expect(git(cloneDir, ["remote", "get-url", "origin"])).rejects.toThrow(); + }); + }); + + it("pushes new commits from the shallow clone to an origin that holds the base commit", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-shallow-push-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + const upstream = path.join(rootDir, "upstream.git"); + await mkdir(upstream, { recursive: true }); + await git(upstream, ["init", "--bare"]); + await git(repo, ["remote", "add", "origin", upstream]); + await git(repo, ["push", "origin", "main"]); + const baseHead = await git(repo, ["rev-parse", "HEAD"]); + + const snapshot = await readGitWorkspaceSnapshot(repo); + await withShallowGitWorkspaceClone({ + localDir: repo, + snapshot: snapshot!, + }, async (cloneDir) => { + await git(cloneDir, ["config", "user.name", "Paperclip Sandbox"]); + await git(cloneDir, ["config", "user.email", "sandbox@paperclip.dev"]); + await writeFile(path.join(cloneDir, "change.txt"), "sandbox change\n", "utf8"); + await git(cloneDir, ["add", "change.txt"]); + await git(cloneDir, ["commit", "-m", "sandbox change"]); + const cloneHead = await git(cloneDir, ["rev-parse", "HEAD"]); + + // A filesystem-path origin is dropped by the allowlist, so configure the + // remote explicitly — the property under test is the push itself: the + // clone is shallow (single grafted commit), but the boundary commit + // already exists on the origin, so the push pack closes without full + // ancestry. That is what makes transported branches publishable. + await git(cloneDir, ["remote", "add", "origin", upstream]); + await git(cloneDir, ["push", "origin", "HEAD:refs/heads/sandbox-change"]); + + expect(await git(upstream, ["rev-parse", "refs/heads/sandbox-change"])).toBe(cloneHead); + expect(await git(upstream, ["merge-base", "refs/heads/main", "refs/heads/sandbox-change"])).toBe(baseHead); + }); + }); + it("builds thin git delta bundles relative to the imported base", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-delta-")); cleanupDirs.push(rootDir); @@ -300,3 +396,47 @@ describe("git workspace sync", () => { } }); }); + +describe("sanitizeGitRemoteUrl", () => { + it("strips userinfo, query, and fragment from http(s) URLs", () => { + expect(sanitizeGitRemoteUrl("https://x-access-token:sekret@github.com/example/repo.git")) + .toBe("https://github.com/example/repo.git"); + expect(sanitizeGitRemoteUrl("https://sekret-token@github.com/example/repo.git")) + .toBe("https://github.com/example/repo.git"); + expect(sanitizeGitRemoteUrl("http://user:pass@git.internal/example/repo.git")) + .toBe("http://git.internal/example/repo.git"); + expect(sanitizeGitRemoteUrl("https://github.com/example/repo.git?private_token=sekret#fragment")) + .toBe("https://github.com/example/repo.git"); + }); + + it("strips password and query from ssh-scheme URLs but keeps the username", () => { + expect(sanitizeGitRemoteUrl("ssh://git@github.com/example/repo.git")) + .toBe("ssh://git@github.com/example/repo.git"); + expect(sanitizeGitRemoteUrl("ssh://git:sekret@github.com/example/repo.git")) + .toBe("ssh://git@github.com/example/repo.git"); + expect(sanitizeGitRemoteUrl("git+ssh://git@github.com/example/repo.git?key=sekret")) + .toBe("git+ssh://git@github.com/example/repo.git"); + }); + + it("keeps credential-free scp-like remotes unchanged", () => { + expect(sanitizeGitRemoteUrl("git@github.com:example/repo.git")) + .toBe("git@github.com:example/repo.git"); + expect(sanitizeGitRemoteUrl("https://github.com/example/repo.git")) + .toBe("https://github.com/example/repo.git"); + }); + + it("drops every shape whose credential surface is unknown", () => { + // Filesystem paths are useless on the execution host and could leak + // host-layout details; unknown schemes and malformed userinfo could carry + // embedded secrets the sanitizer cannot recognize. All fail closed. + expect(sanitizeGitRemoteUrl("/tmp/local/upstream.git")).toBeNull(); + expect(sanitizeGitRemoteUrl("ftp://user:pass@host/repo.git")).toBeNull(); + expect(sanitizeGitRemoteUrl("user:pass@host:path/repo.git")).toBeNull(); + expect(sanitizeGitRemoteUrl("host.example:path/repo.git")).toBeNull(); + }); + + it("returns null for empty input", () => { + expect(sanitizeGitRemoteUrl("")).toBeNull(); + expect(sanitizeGitRemoteUrl(" ")).toBeNull(); + }); +}); diff --git a/packages/adapter-utils/src/git-workspace-sync.ts b/packages/adapter-utils/src/git-workspace-sync.ts index 0e36dee982..dd0517ec41 100644 --- a/packages/adapter-utils/src/git-workspace-sync.ts +++ b/packages/adapter-utils/src/git-workspace-sync.ts @@ -110,6 +110,67 @@ export async function readGitWorkspaceSnapshot(localDir: string): Promise { + try { + const result = await runLocalGit(localDir, ["remote", "get-url", "origin"], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }); + return sanitizeGitRemoteUrl(result.stdout.trim()); + } catch { + return null; + } +} + export async function withShallowGitWorkspaceClone( input: { localDir: string; @@ -120,6 +181,7 @@ export async function withShallowGitWorkspaceClone( const cloneDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-git-workspace-")); const tempRef = `refs/paperclip/git-sync/import/${randomUUID()}`; try { + const originUrl = await readSanitizedOriginRemoteUrl(input.localDir); await runLocalGit(input.localDir, ["update-ref", tempRef, input.snapshot.headCommit], { timeout: 10_000, maxBuffer: 16 * 1024, @@ -128,6 +190,19 @@ export async function withShallowGitWorkspaceClone( timeout: 10_000, maxBuffer: 64 * 1024, }); + if (originUrl) { + // The clone is what lands in the sandbox. Without `origin`, the branch + // there reads as an unpublishable root snapshot even though its head is a + // commit the upstream remote already holds — so fetch (to reconnect + // ancestry) and push (to publish the branch; the shallow boundary commit + // is already on the remote, so the pack closes) are both mechanically + // possible once the remote is carried over. Best-effort: a failure to + // record the remote must not fail the transport. + await runLocalGit(cloneDir, ["remote", "add", "origin", originUrl], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }).catch(() => undefined); + } await runLocalGit(cloneDir, ["fetch", "--depth=1", input.localDir, tempRef], { timeout: 60_000, maxBuffer: 1024 * 1024, diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index b2aa909c9a..de86cdb678 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { Transform } from "node:stream"; import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js"; +import { readSanitizedOriginRemoteUrl } from "./git-workspace-sync.js"; import type { RunProcessResult } from "./server-utils.js"; import type { DirectorySnapshot } from "./workspace-restore-merge.js"; import { mergeDirectoryWithBaseline } from "./workspace-restore-merge.js"; @@ -776,6 +777,7 @@ async function importGitWorkspaceToSsh(input: { timeout: 60_000, maxBuffer: 1024 * 1024, }); + const originUrl = await readSanitizedOriginRemoteUrl(input.localDir); const remoteSetupScript = [ "set -e", @@ -784,6 +786,15 @@ async function importGitWorkspaceToSsh(input: { 'trap \'rm -f "$tmp_bundle"\' EXIT', 'cat > "$tmp_bundle"', `if [ ! -d ${shellQuote(path.posix.join(input.remoteDir, ".git"))} ]; then git init ${shellQuote(input.remoteDir)} >/dev/null; fi`, + // Carry the workspace's (credential-scrubbed) origin into the transported + // repo so branches there keep a publishable remote instead of reading as + // remote-less snapshots. set-url covers a reused workspace whose origin + // changed; add covers the fresh-init case. Best-effort under `set -e`. + ...(originUrl + ? [ + `{ git -C ${shellQuote(input.remoteDir)} remote set-url origin ${shellQuote(originUrl)} >/dev/null 2>&1 || git -C ${shellQuote(input.remoteDir)} remote add origin ${shellQuote(originUrl)} >/dev/null 2>&1; } || true`, + ] + : []), `git -C ${shellQuote(input.remoteDir)} fetch --force "$tmp_bundle" '${tempRef}:${tempRef}' >/dev/null`, input.snapshot.branchName ? `git -C ${shellQuote(input.remoteDir)} checkout --force -B ${shellQuote(input.snapshot.branchName)} ${shellQuote(input.snapshot.headCommit)} >/dev/null` diff --git a/packages/adapters/AUTHORING.md b/packages/adapters/AUTHORING.md index 3448e994b5..0b32365653 100644 --- a/packages/adapters/AUTHORING.md +++ b/packages/adapters/AUTHORING.md @@ -38,6 +38,18 @@ How to apply: `workspace_finalize=failed` on the execution workspace, which gates dependent issue wakes until the next successful finalize. Do not swallow restore errors. +- A transported workspace copy *may* carry the local workspace's `origin` + remote URL so that branches in the copy stay publishable by the agent or an + operator who holds credentials — the transport helpers copy the URL as + metadata only. The copy is allowlist-based and fails closed + (`sanitizeGitRemoteUrl`): http(s) URLs are stripped of userinfo, query, and + fragment; `ssh:`/`git:` scheme URLs are stripped of password and query; + scp-like `user@host:path` passes through (the syntax has no password slot); + every other shape — filesystem paths, unknown schemes — is dropped rather + than risk persisting an embedded secret. This does not weaken the contract: + sync-back through the local cwd remains the only cross-run persistence + path, the helpers never fetch from or push to that remote, and a workspace + without an `origin` transports exactly as before. The invariant is pinned by the `no-remote-git contract` case in [`packages/adapter-utils/src/ssh-fixture.test.ts`](../adapter-utils/src/ssh-fixture.test.ts),