diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts index d8273d8498..d81533cbf6 100644 --- a/server/src/__tests__/environment-run-orchestrator.test.ts +++ b/server/src/__tests__/environment-run-orchestrator.test.ts @@ -303,6 +303,23 @@ describe("environmentRunOrchestrator — realizeForRun", () => { expect(mockResolveEnvironmentExecutionTarget).toHaveBeenCalledOnce(); }); + it("resolves run-scoped checkout credentials without persisting them in workspace metadata", async () => { + const runtime = makeMockRuntime(); + const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime }); + const request = mockBuildWorkspaceRealizationRequest.getMockImplementation()!(); + request.source.repoUrl = "https://github.com/company/private-repo.git"; + mockBuildWorkspaceRealizationRequest.mockReturnValue(request); + const gitAuth = { configArgs: ["-c", "credential.helper="], env: { PAPERCLIP_GIT_TOKEN: "scoped-fixture-token" }, source: "managed_connection" as const, secretName: null }; + const resolveGitAuth = vi.fn(async () => gitAuth); + await orchestrator.realizeForRun({ ...makeRealizeInput({ environment: makeEnvironment("computer") }), resolveGitAuth }); + expect(resolveGitAuth).toHaveBeenCalledExactlyOnceWith(request.source.repoUrl); + expect(runtime.realizeWorkspace).toHaveBeenCalledWith(expect.objectContaining({ gitAuth })); + const dispatched = vi.mocked(runtime.realizeWorkspace).mock.calls[0]![0]; + expect(JSON.stringify(dispatched.workspace)).not.toContain("scoped-fixture-token"); + expect(JSON.stringify(mockUpdateLeaseMetadata.mock.calls)).not.toContain("scoped-fixture-token"); + expect(JSON.stringify(mockUpdateExecutionWorkspace.mock.calls)).not.toContain("scoped-fixture-token"); + }); + it("uses an in-place authoritative root on the adapter execution target", async () => { mockResolveEnvironmentExecutionTarget.mockResolvedValue({ kind: "remote", diff --git a/server/src/modules/computers/adapters/boat.test.ts b/server/src/modules/computers/adapters/boat.test.ts index d6f96f78bd..7e2240c195 100644 --- a/server/src/modules/computers/adapters/boat.test.ts +++ b/server/src/modules/computers/adapters/boat.test.ts @@ -1,5 +1,6 @@ import { mkdtempSync, + readFileSync, realpathSync, rmSync, symlinkSync, @@ -12,6 +13,7 @@ import { spawnSync } from "node:child_process"; import { remoteProgram } from "./remote-program.js"; import { afterEach, describe, it, expect, vi } from "vitest"; import { boatBackend } from "./boat.js"; +import { buildGitAuthInvocation } from "../../../services/git-credentials.js"; import type { ComputerRecord } from "../domain/ledger.js"; const record: ComputerRecord = { id: "computer", @@ -112,11 +114,11 @@ function fixture() { const temp = realpathSync(mkdtempSync(join(tmpdir(), "computer-files-"))); roots.push(temp); const root = join(temp, "home"); - const call = (input: Record) => { + const call = (input: Record, env = process.env) => { const result = spawnSync( "python3", ["-c", remoteProgram.replaceAll("/home/user/paperclip/", `${temp}/`)], - { input: JSON.stringify({ ...input, root }), encoding: "utf8" }, + { input: JSON.stringify({ ...input, root }), encoding: "utf8", env }, ); if (result.status !== 0) throw new Error(result.stderr); return JSON.parse(result.stdout); @@ -218,6 +220,40 @@ describe("confined computer files", () => { expect(f.call({ action: "list", limit: 2 }).entries).toHaveLength(2); expect(f.call({ action: "list", limit: 1001 })).toEqual({ error: "invalid" }); }); + it("clones a private checkout with operation-scoped auth and leaves no credential material behind", () => { + const f = fixture(); + const source = join(f.temp, "source"); + mkdirSync(source); + const realGit = spawnSync("which", ["git"], { encoding: "utf8" }).stdout.trim(); + for (const args of [["init"], ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "base"]]) { + expect(spawnSync(realGit, args, { cwd: source, encoding: "utf8" }).status).toBe(0); + } + const bin = join(f.temp, "bin"); + mkdirSync(bin); + // Stand in for an authenticated GitHub transport, using real Git and the + // production URL-scoped credential helper, without any external network. + writeFileSync(join(bin, "git"), `#!${process.execPath} +const {spawnSync}=require("node:child_process"); +const args=process.argv.slice(2), clone=args.indexOf("clone"); +if(clone<0) process.exit(9); +if(args.some(arg=>arg.includes("scoped-fixture-token"))) process.exit(10); +const credential=spawnSync(process.env.FIXTURE_REAL_GIT,[...args.slice(0,clone),"credential","fill"],{input:"protocol=https\\nhost=github.com\\n\\n",encoding:"utf8"}); +if(credential.status!==0||!credential.stdout.includes("password=scoped-fixture-token")) process.exit(11); +args[clone+2]=process.env.FIXTURE_SOURCE; +const result=spawnSync(process.env.FIXTURE_REAL_GIT,args,{encoding:"utf8"}); +process.stdout.write(result.stdout||"");process.stderr.write(result.stderr||"");process.exit(result.status??1); +`, { mode: 0o700 }); + const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, FIXTURE_REAL_GIT: realGit, FIXTURE_SOURCE: source }; + const request = { action: "workspace", mode: "shared", repositoryUrl: "https://github.com/company/private.git" }; + expect(f.call(request, env)).toEqual({ error: "command_failed" }); + const gitAuth = buildGitAuthInvocation({ token: "scoped-fixture-token", source: "managed_connection", secretName: null }); + const result = f.call({ ...request, gitAuth }, env); + expect(result.remoteCwd).toBe(join(f.root, "checkout")); + const config = readFileSync(join(result.remoteCwd, ".git", "config"), "utf8"); + expect(config).not.toContain("scoped-fixture-token"); + expect(config).not.toContain("credential"); + expect(spawnSync(realGit, ["rev-parse", "HEAD"], { cwd: result.remoteCwd }).status).toBe(0); + }); it.each([false, true])("uses the requested worktree branch separately from its start ref (explicit base: %s)", explicitBase => { const f = fixture(); const checkout = join(f.root, "checkout"); diff --git a/server/src/modules/computers/adapters/remote-program.ts b/server/src/modules/computers/adapters/remote-program.ts index 2f658d45fb..fa937dbf40 100644 --- a/server/src/modules/computers/adapters/remote-program.ts +++ b/server/src/modules/computers/adapters/remote-program.ts @@ -5,8 +5,8 @@ p=json.load(sys.stdin) def fail(code): print(json.dumps({'error':code}));sys.exit(0) def digest(data):return hashlib.sha256(data).hexdigest() -def run(args,cwd=None): - r=subprocess.run(args,cwd=cwd,capture_output=True,text=True) +def run(args,cwd=None,env=None): + r=subprocess.run(args,cwd=cwd,env=env,capture_output=True,text=True) if r.returncode:fail('command_failed') return r.stdout.strip() root=p['root'] @@ -65,7 +65,13 @@ if act=='workspace': checkout=os.path.join(root,'checkout') if repo: if not (repo.startswith('https://') or repo.startswith('ssh://') or repo.startswith('git@')) or '\n' in repo:fail('invalid') - if not os.path.exists(checkout):run(['git','clone','--',repo,checkout]) + if not os.path.exists(checkout): + # Resolve credentials on the controller for this operation. Only the clone + # child receives the helper environment; no credential file or Git config persists. + auth=p.get('gitAuth') or {};git_env=dict(os.environ) + git_env.update({'GIT_TERMINAL_PROMPT':'0','GIT_CONFIG_GLOBAL':'/dev/null','GIT_CONFIG_NOSYSTEM':'1'}) + git_env.update(auth.get('env',{})) + run(['git']+auth.get('configArgs',[])+['clone','--',repo,checkout],env=git_env) elif run(['git','remote','get-url','origin'],checkout)!=repo:fail('conflict') else: os.makedirs(checkout,exist_ok=True) diff --git a/server/src/modules/computers/application/service.test.ts b/server/src/modules/computers/application/service.test.ts index fa962a6158..80be803a88 100644 --- a/server/src/modules/computers/application/service.test.ts +++ b/server/src/modules/computers/application/service.test.ts @@ -113,6 +113,16 @@ function fixture() { }; } describe("computer ownership", () => { + it("passes checkout credentials transiently without retaining them in computer state", async () => { + const f = fixture(); + await f.attach(); + const binding = await f.admit(); + const gitAuth = { configArgs: ["-c", "credential.helper="], env: { PAPERCLIP_GIT_TOKEN: "scoped-fixture-token" } }; + await f.service.realizeWorkspace({ ...f.scope, owner: binding.owner, projectId: "project", mode: "shared", + repositoryUrl: "https://github.com/company/private.git", gitAuth }); + expect(f.backend.remote).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "workspace", gitAuth })); + expect(JSON.stringify(await f.repository.get(f.scope))).not.toContain("scoped-fixture-token"); + }); it("explains an unavailable dev server without masking provider errors", async () => { const f = fixture(); await f.attach(); diff --git a/server/src/modules/computers/application/service.ts b/server/src/modules/computers/application/service.ts index 44e4d7602d..64e1e8c37c 100644 --- a/server/src/modules/computers/application/service.ts +++ b/server/src/modules/computers/application/service.ts @@ -767,6 +767,8 @@ finally: projectId?: string; taskId?: string; repositoryUrl?: string; + /** Transient Git helper configuration; sent over stdin, never retained in the ledger. */ + gitAuth?: { configArgs: string[]; env: Record }; branch?: string; baseRef?: string; mode: "shared" | "worktree"; diff --git a/server/src/services/computer-environment-driver.ts b/server/src/services/computer-environment-driver.ts index b89e70dffc..9818dc8fc7 100644 --- a/server/src/services/computer-environment-driver.ts +++ b/server/src/services/computer-environment-driver.ts @@ -77,11 +77,12 @@ export function createComputerEnvironmentDriver(db: Db): EnvironmentRuntimeDrive if (!result.retired) throw new Error("computer_owner_retirement_superseded"); return { providerLeaseId: lease.providerLeaseId, state: "stopped" }; }, - async realizeWorkspace({ lease, workspace }) { + async realizeWorkspace({ lease, workspace, gitAuth }) { const request = workspace.metadata?.workspaceRealizationRequest as { source?: { projectId?: string; repoUrl?: string; repoRef?: string; branchName?: string; strategy?: string }; issueId?: string } | undefined; const result = await computers.realizeWorkspace({ ...scope(lease), owner: computerOwnerFromLease(lease), projectId: request?.source?.projectId ?? undefined, taskId: request?.issueId ?? undefined, repositoryUrl: request?.source?.repoUrl ?? undefined, + gitAuth, branch: request?.source?.branchName ?? request?.source?.repoRef ?? undefined, baseRef: request?.source?.repoRef ?? undefined, mode: request?.source?.strategy === "git_worktree" ? "worktree" : "shared" }); diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 82d7997efc..c5cbb0ab95 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -51,6 +51,7 @@ import { buildWorkspaceRealizationRequest } from "./workspace-realization.js"; import { executionWorkspaceService } from "./execution-workspaces.js"; import { logActivity } from "./activity-log.js"; import { logger } from "../middleware/logger.js"; +import type { GitRemoteAuthProvider } from "./git-credentials.js"; import { parseObject } from "../adapters/utils.js"; import type { RealizedExecutionWorkspace } from "./workspace-runtime.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; @@ -393,6 +394,8 @@ export function environmentRunOrchestrator( * target. Absent keeps the safe no-op default in the bridge. */ duplexObservabilityRecorder?: DuplexObservabilityRecorder | null; + /** Run-scoped auth for initial persistent checkouts; never persisted as workspace metadata. */ + resolveGitAuth?: GitRemoteAuthProvider; }): Promise { const { environment, @@ -430,6 +433,9 @@ export function environmentRunOrchestrator( const workspaceRealizationResult = await environmentRuntime.realizeWorkspace({ environment, lease, + gitAuth: environment.driver === "computer" && workspaceRealizationRequest.source.repoUrl + ? await input.resolveGitAuth?.(workspaceRealizationRequest.source.repoUrl) ?? undefined + : undefined, workspace: { localPath: executionWorkspace.cwd, remotePath: remoteCwd, diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 94558bb4dd..b3a93ca67d 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -526,6 +526,8 @@ export interface EnvironmentDriverLeaseInput { } export interface EnvironmentDriverRealizeWorkspaceInput extends EnvironmentDriverLeaseInput { + /** Ephemeral authentication for the initial checkout, excluded from durable metadata. */ + gitAuth?: { configArgs: string[]; env: Record }; workspace: { localPath?: string; remotePath?: string; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index ba705de655..c6c7cbe50c 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4529,6 +4529,7 @@ export function heartbeatService( effectiveExecutionWorkspaceMode, persistedExecutionWorkspace, duplexObservabilityRecorder, + resolveGitAuth: workspaceGitAuthProvider, }); nativeRunnerPreparationSpans.push({ name: "environment.workspace.realize",