diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts index 58118a838e6..3a80ff225ff 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -95,7 +95,7 @@ describe("claude remote execution", () => { } }); - it("prepares the workspace, syncs Claude runtime assets, and restores workspace changes for remote SSH execution", async () => { + it.each([true, false])("prepares and restores a copied SSH workspace (already exists: %s)", async (exists) => { vi.stubEnv("CLAUDE_CODE_USE_BEDROCK", "1"); vi.stubEnv("ANTHROPIC_MODEL", "host-only-model"); const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-")); @@ -104,7 +104,11 @@ describe("claude remote execution", () => { const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); const instructionsPath = path.join(rootDir, "instructions.md"); const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace"; - await mkdir(workspaceDir, { recursive: true }); + if (exists) await mkdir(workspaceDir, { recursive: true }); + prepareWorkspaceForSshExecution.mockImplementationOnce(async () => { + expect((await stat(workspaceDir)).isDirectory()).toBe(true); + return { gitBacked: false }; + }); await mkdir(alternateWorkspaceDir, { recursive: true }); await writeFile(instructionsPath, "Use the remote workspace.\n", "utf8"); diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 40a7318b094..68c94789b5a 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -219,7 +219,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { describe("ACPX installation integrity", () => { describe.each(["runtime", "command"] as const)("%s executable hydration", (kind) => { const verify = kind === "runtime" ? openVerifiedRuntimeExecutable : inspectCommand; - it.each(["once", "every_read", "wrong_digest", "mode", "mtime", "replacement", "symlink", "second_content"] as const)("requires stable full re-verification after a ctime-only transition (%s)", async (transition) => { + it.each(["once", "every_read", "wrong_digest", "mode", "mtime", "replacement", "symlink", "second_content", "uid", "gid", "nlink"] as const)("requires stable full re-verification after a ctime-only transition (%s)", async (transition) => { const directory = await mkdtemp(join(tmpdir(), "paperclip-runtime-hydration-")); temporaryDirectories.push(directory); const executable = join(directory, "runtime"); @@ -74,8 +74,15 @@ describe("ACPX installation integrity", () => { const probe = await open(executable, "r"); const prototype = Object.getPrototypeOf(probe) as FileHandle; const originalRead = probe.read; + const originalStat = probe.stat; await probe.close(); let reads = 0; + // Model ownership changes without requiring privileged chown in the test. + const statSpy = vi.spyOn(prototype, "stat").mockImplementation(async function (this: FileHandle, ...args: Parameters) { + const metadata = await Reflect.apply(originalStat, this, args); + if (reads > 0 && (transition === "uid" || transition === "gid")) metadata[transition] += 1n; + return metadata; + }); const readSpy = vi.spyOn(prototype, "read").mockImplementation(async function (this: FileHandle, ...args: Parameters) { if (reads === 1 && transition === "second_content") { await writeFile(executable, Buffer.alloc(bytes.length, 120)); @@ -93,6 +100,7 @@ describe("ACPX installation integrity", () => { const after = await stat(executable, { bigint: true }); expect(after.ctimeNs).not.toBe(before.ctimeNs); expect([after.dev, after.ino, after.size, after.mode, after.mtimeNs]).toEqual([before.dev, before.ino, before.size, before.mode, before.mtimeNs]); + if (transition === "nlink") await link(executable, `${executable}.link`); if (transition === "mode") await chmod(executable, 0o700); if (transition === "mtime") await utimes(executable, new Date(0), new Date(0)); if (transition === "replacement" || transition === "symlink") { @@ -117,7 +125,7 @@ describe("ACPX installation integrity", () => { await expect(verify(executable, digest, "fixture")).rejects.toThrow(transition === "wrong_digest" ? "digest mismatch" : "changed while it was verified"); } expect(reads).toBe(["once", "every_read", "second_content"].includes(transition) ? 2 : 1); - } finally { readSpy.mockRestore(); } + } finally { readSpy.mockRestore(); statSpy.mockRestore(); } }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index b51da6efe5b..b1a3a2f2285 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -1188,7 +1188,10 @@ async function openVerifiedExecutable( const beforeIdentity = fileIdentity(before); if ( !sameIdentity(fileIdentity(lexicalBaseline), beforeIdentity) || - lexicalBaseline.mode !== before.mode + lexicalBaseline.mode !== before.mode || + lexicalBaseline.uid !== before.uid || + lexicalBaseline.gid !== before.gid || + lexicalBaseline.nlink !== before.nlink ) { throw new Error( `${label} changed while it was verified`, @@ -1226,7 +1229,13 @@ async function openVerifiedExecutable( !lexicalAfter.isFile() || !sameIdentity(fileIdentity(lexicalAfter), afterIdentity) || lexicalAfter.mode !== after.mode || + lexicalAfter.uid !== after.uid || + lexicalAfter.gid !== after.gid || + lexicalAfter.nlink !== after.nlink || before.mode !== after.mode || + before.uid !== after.uid || + before.gid !== after.gid || + before.nlink !== after.nlink || !sameIdentity( { ...beforeIdentity, changedNanoseconds: afterIdentity.changedNanoseconds }, afterIdentity, diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts index d02e0cfb4b0..aa16e4ace3d 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts @@ -250,6 +250,8 @@ export function createIsolatedCodexAppServerArgs( readOnlyRoots: string[] = [], /** Server-registered run copy, never an environment/config-supplied root. */ instructionWorkingCopyRoot?: string, + /** Separate controller-registered personal directory, never an ambient env grant. */ + persistentAgentHome?: string, ): string[] { const explicitSource = source; source ??= process.env; @@ -268,7 +270,9 @@ export function createIsolatedCodexAppServerArgs( const commandEnvironment = codexCommandEnvironment(source); // Selected task values are inherited, never serialized into configuration argv. for (const key of configuredEnvironmentKeys(explicitSource)) delete commandEnvironment[key]; - if (instructionWorkingCopyRoot && source.AGENT_HOME === instructionWorkingCopyRoot) commandEnvironment.AGENT_HOME = instructionWorkingCopyRoot; + const registeredAgentHome = persistentAgentHome ?? instructionWorkingCopyRoot; + if (registeredAgentHome && source.AGENT_HOME === registeredAgentHome) commandEnvironment.AGENT_HOME = registeredAgentHome; + const registeredWritableRoots = [...new Set([instructionWorkingCopyRoot, persistentAgentHome].filter((root): root is string => Boolean(root)))]; const shellEnvironmentKeys = [...new Set([...inheritedGitHubKeys, ...Object.keys(commandEnvironment)])].sort(); if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) readOnlyRoots = [...readOnlyRoots, source.PAPERCLIP_GITHUB_LAUNCHER_DIR]; const deniedHostRoots = [ @@ -289,7 +293,7 @@ export function createIsolatedCodexAppServerArgs( ...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`), ...gitRoots.read.map((path) => `${tomlString(path)}="read"`), ...gitRoots.write.map((path) => `${tomlString(path)}="write"`), - ...(instructionWorkingCopyRoot ? [`${tomlString(resolve(instructionWorkingCopyRoot))}="write"`] : []), + ...registeredWritableRoots.map((root) => `${tomlString(resolve(root))}="write"`), ...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR ? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []), `":workspace_roots"={"."="write"}`, @@ -301,7 +305,7 @@ export function createIsolatedCodexAppServerArgs( ...deniedHostRoots.map((path) => `${tomlString(path)}="none"`), ...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`), ...[...gitRoots.read, ...gitRoots.write].map((path) => `${tomlString(path)}="read"`), - ...(instructionWorkingCopyRoot ? [`${tomlString(resolve(instructionWorkingCopyRoot))}="read"`] : []), + ...registeredWritableRoots.map((root) => `${tomlString(resolve(root))}="read"`), ...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR ? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []), `":workspace_roots"={"."="read"}`, diff --git a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts index 4e68be2944b..79fcd583bff 100644 --- a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts +++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts @@ -527,3 +527,16 @@ it("exposes a trusted persistent personal directory without instruction files", expect(supplied).not.toContain('AGENT_HOME="/untrusted"'); expect(createRunnerdCodexAppServerArgs(options).join("\n")).not.toContain("AGENT_HOME"); }); + +it("grants separate registered personal and instruction directories to Codex shells", () => { + const args = createRunnerdCodexAppServerArgs({ + environment: { AGENT_HOME: "/untrusted" }, codexHome: "/private-provider", + persistentAgentHome: "/personal/agent", instructionWorkingCopyRoot: "/instructions/run", + }).join("\n"); + expect(args).toContain('AGENT_HOME="/personal/agent"'); + for (const root of ["/personal/agent", "/instructions/run"]) { + expect(args).toContain(`"${root}"="write"`); + expect(args).toContain(`"${root}"="read"`); + } + expect(args).not.toContain("/untrusted"); +}); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index c127a1955db..a9a82213524 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3493,7 +3493,8 @@ export function createRunnerdCodexAppServerArgs(input: { ...(input.persistentAgentHome ? { AGENT_HOME: input.persistentAgentHome } : {}), }, [...(input.readOnlyRoots ?? []), ...codexExecutableReadOnlyRoots(input.environment ?? {}, input.codexCommand)], - input.instructionWorkingCopyRoot ?? input.persistentAgentHome, + input.instructionWorkingCopyRoot, + input.persistentAgentHome, ); } diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 25a8dbf6549..479260c30c3 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -351,6 +351,8 @@ export interface WorkspaceRealizationRequest { repoRef: string | null; strategy: "project_primary" | "git_worktree"; branchName: string | null; + /** Attach this exact pre-existing branch without creating or resetting it. */ + existingBranch?: string; worktreePath: string | null; }; /** diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 05ff46ada71..43c4ede5acf 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -332,7 +332,7 @@ describe("server adapter registry", () => { expect(config).not.toHaveProperty("engine"); }); - it("verifies the Claude harness on a computer after checking its platform", async () => { + it.each([true, false])("retains the unverified ACPX warning after a computer Claude CLI probe (succeeds: %s)", async (succeeds) => { const calls: string[] = []; const result = await requireServerAdapter("paperclip_runner").testEnvironment({ companyId: "company-1", adapterType: "paperclip_runner", @@ -342,13 +342,15 @@ describe("server adapter registry", () => { calls.push(input.command); const stdout = input.command === "claude" ? JSON.stringify({ type: "result", result: "hello", usage: { input_tokens: 1, output_tokens: 1 } }) : input.args?.some(arg => arg.includes("uname -s")) ? "Linux\nx86_64\n" : ""; - return { exitCode: 0, timedOut: false, stdout, stderr: "", signal: null, pid: null, startedAt: new Date().toISOString() }; + return { exitCode: input.command === "claude" && !succeeds ? 1 : 0, timedOut: false, stdout, stderr: "", signal: null, pid: null, startedAt: new Date().toISOString() }; } }, } as never, }); expect(calls).toContain("claude"); - expect(result.checks).toContainEqual(expect.objectContaining({ code: "claude_hello_probe_passed", level: "info" })); - expect(result.checks.some(check => check.level === "error")).toBe(false); + expect(result.status).toBe(succeeds ? "warn" : "fail"); + expect(result.checks).toContainEqual(expect.objectContaining({ code: "acpx_remote_runtime_unverified", level: "warn" })); + if (succeeds) expect(result.checks).toContainEqual(expect.objectContaining({ code: "claude_hello_probe_passed", level: "info" })); + expect(result.checks.some(check => check.level === "error")).toBe(!succeeds); }); it.each([true, false])("checks actual local ACPX installation readiness (%s)", async (ready) => { diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index c2aa6f0316d..42f5863d1fd 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -13,9 +13,10 @@ const mockAccessService = vi.hoisted(() => ({ })); const mockComputerAttach = vi.hoisted(() => vi.fn()); +const mockComputerServiceDb = vi.hoisted(() => vi.fn()); vi.mock("../modules/computers/index.js", async (importOriginal) => ({ ...await importOriginal(), - computerService: () => ({ attach: mockComputerAttach }), + computerService: (db: unknown) => { mockComputerServiceDb(db); return { attach: mockComputerAttach }; }, })); const mockAgentService = vi.hoisted(() => ({ @@ -2708,6 +2709,50 @@ describe("environment routes", () => { if (!succeeds) expect(mockLogActivity).not.toHaveBeenCalled(); }); + it.each([false, true])("commits Boat key rotation only after attachment succeeds (%s)", async succeeds => { + const priorSecret = "11111111-1111-1111-1111-111111111111"; + const nextSecret = "22222222-2222-2222-2222-222222222222"; + let environment = { ...createEnvironment(), driver: "computer", status: "active", + config: { provider: "boat", sandboxId: "bx_retained", apiKeySecretRef: { type: "secret_ref", secretId: priorSecret } }, + metadata: { computerCompanyId: "company-1" } }; + let boundSecret = priorSecret; + let transactionOpen = false; + const transaction = vi.spyOn(routeDb, "transaction").mockImplementation(async fn => { + const before = environment, bindingBefore = boundSecret; + transactionOpen = true; + try { return await fn(routeDbTx); } + catch (error) { environment = before; boundSecret = bindingBefore; throw error; } + finally { transactionOpen = false; } + }); + mockEnvironmentService.getById.mockImplementation(async () => environment); + mockEnvironmentService.update.mockImplementation(async (_id, patch) => environment = { ...environment, ...patch }); + mockSecretService.replaceSecretRefsForInstanceTarget.mockImplementation(async (_target, _refs, options) => { + expect(options.db).toBe(routeDbTx); + boundSecret = environment.config.apiKeySecretRef.secretId; + return []; + }); + mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBoatEnvironments: true }); + mockComputerAttach.mockImplementation(async input => { + expect(transactionOpen).toBe(true); + expect(mockComputerServiceDb).toHaveBeenLastCalledWith(routeDbTx); + expect(boundSecret).toBe(nextSecret); + expect(input.apiKeySecretRef.secretId).toBe(nextSecret); + if (!succeeds) throw conflict("Boat rejected the replacement key"); + return { computerId: "computer-1" }; + }); + try { + const app = createApp({ type: "board", userId: "user-1", source: "local_implicit" }); + const res = await request(app).patch(`/api/environments/${environment.id}`).send({ + config: { apiKeySecretRef: { type: "secret_ref", secretId: nextSecret } }, + }); + expect(res.status, JSON.stringify(res.body)).toBe(succeeds ? 200 : 409); + expect(environment.config.apiKeySecretRef.secretId).toBe(succeeds ? nextSecret : priorSecret); + expect(boundSecret).toBe(succeeds ? nextSecret : priorSecret); + expect(mockComputerAttach).toHaveBeenCalledOnce(); + if (!succeeds) expect(mockLogActivity).not.toHaveBeenCalled(); + } finally { transaction.mockRestore(); } + }); + it("logs a redacted update summary instead of raw config or metadata", async () => { const environment = createEnvironment(); mockEnvironmentService.getById.mockResolvedValue(environment); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 021523b997b..7290a474b55 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -9444,6 +9444,19 @@ describe("workspace realization request additionalSources", () => { }; } + it.each([undefined, "operator/retained"])("round-trips exact-branch intent independently of branch ownership (%s)", existingBranch => { + const workspace = buildRealizedWorkspace({ strategy: "git_worktree", branchName: existingBranch ?? "new-task-branch", + existingBranch, branchCreatedByRuntime: false }); + const request = buildWorkspaceRealizationRequest({ adapterType: "codex", companyId: "company-1", + environmentId: "environment-1", executionWorkspaceId: null, issueId: "issue-1", heartbeatRunId: "run-1", + requestedMode: "isolated_workspace", workspace, workspaceConfig: null }); + const parsed = readWorkspaceRealizationRequest(JSON.parse(JSON.stringify(request))); + expect(parsed?.source.existingBranch).toBe(existingBranch); + expect(parsed?.source.branchName).toBe(workspace.branchName); + expect(workspace.branchCreatedByRuntime).toBe(false); + if (!existingBranch) expect(request.source).not.toHaveProperty("existingBranch"); + }); + it("round-trips additionalSources and runtime provisioning through build/read realization request", () => { const workspace = buildRealizedWorkspace({ additionalWorkspaces: [ diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index f35d9b4b233..c9c13c144f2 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -432,16 +432,19 @@ const paperclipRunnerAdapter: ServerAdapterModule = { if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && (arch === "arm64" || (profile.acpxAgent !== "grok" && arch === "x86_64"))))) { throw new Error(`ACPX ${profile.acpxAgent} requires a qualified Linux x64 or macOS architecture.`); } + const runtimeUnverified = { code: "acpx_remote_runtime_unverified", level: "warn" as const, + message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }; if (target.transport === "computer" && profile.acpxAgent === "claude") { const hello = await claudeTestEnvironment({ ...context, adapterType: "claude_local", config: { ...context.config, engine: "cli", model: profile.model, dangerouslySkipPermissions: context.config.acpxPermissionMode === "approve-all" } }); - return { ...hello, adapterType: "paperclip_runner" }; + return { ...hello, adapterType: "paperclip_runner", + status: hello.status === "fail" ? "fail" as const : "warn" as const, + checks: [...hello.checks, runtimeUnverified] }; } return { adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(), - checks: [{ code: "acpx_remote_runtime_unverified", level: "warn" as const, - message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }], + checks: [runtimeUnverified], }; } const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxCursorInstallation, probeAcpxPiInstallation } = await import("../vendor/paperclip-runner/live/index.js"); diff --git a/server/src/modules/computers/adapters/boat.test.ts b/server/src/modules/computers/adapters/boat.test.ts index f2cd70e153d..e7f7c088fa1 100644 --- a/server/src/modules/computers/adapters/boat.test.ts +++ b/server/src/modules/computers/adapters/boat.test.ts @@ -771,6 +771,33 @@ process.stdout.write(result.stdout||"");process.stderr.write(result.stderr||""); expect(f.call({ ...input, branch: "another-branch" })).toEqual({ error: "conflict" }); expect(git("-C", result.remoteCwd, "status", "--short")).toContain("preserved"); }); + it.each([false, true])("attaches only the exact existing worktree branch (present: %s)", present => { + const f = fixture(), checkout = join(f.root, "checkout"); + mkdirSync(checkout, { recursive: true }); + const git = (...args: string[]) => { + const result = spawnSync("git", args, { cwd: checkout, encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr); + return result.stdout.trim(); + }; + git("init"); + git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "base"); + const expectedHead = git("rev-parse", "HEAD"), branch = "operator/retained"; + if (present) git("branch", branch); + git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "newer base"); + const input = { action: "workspace", mode: "worktree", taskId: "existing-task", branch, existingBranch: branch, baseRef: "HEAD" }; + const result = f.call(input); + if (present) { + expect(result.remoteCwd).toBe(join(f.root, "tasks", "existing-task")); + expect(git("-C", result.remoteCwd, "rev-parse", "HEAD")).toBe(expectedHead); + writeFileSync(join(result.remoteCwd, "retained-work"), "keep"); + expect(f.call(input).remoteCwd).toBe(result.remoteCwd); + expect(git("-C", result.remoteCwd, "status", "--short")).toContain("retained-work"); + } else { + expect(result).toEqual({ error: "command_failed" }); + expect(spawnSync("git", ["show-ref", "--verify", `refs/heads/${branch}`], { cwd: checkout }).status).not.toBe(0); + expect(existsSync(join(f.root, "tasks", "existing-task"))).toBe(false); + } + }); it("roundtrips binary bytes without interpreting text", () => { const f = fixture(); const bytes = Buffer.from([0, 255, 254, 13, 10, 128]); diff --git a/server/src/modules/computers/adapters/remote-program.ts b/server/src/modules/computers/adapters/remote-program.ts index 1d555baab02..638a267a152 100644 --- a/server/src/modules/computers/adapters/remote-program.ts +++ b/server/src/modules/computers/adapters/remote-program.ts @@ -71,7 +71,8 @@ if act=='workspace': info=os.fstat(workspace_lock.fileno()) if not stat.S_ISREG(info.st_mode) or info.st_nlink!=1:fail('invalid') fcntl.flock(workspace_lock,fcntl.LOCK_EX) - repo=p.get('repositoryUrl');branch=p.get('branch');task=p.get('taskId');base_ref=p.get('baseRef') + repo=p.get('repositoryUrl');branch=p.get('branch');task=p.get('taskId');base_ref=p.get('baseRef');existing_branch=p.get('existingBranch') + if existing_branch and (p.get('mode')!='worktree' or existing_branch!=branch):fail('invalid') if base_ref and (base_ref.startswith('-') or '\n' in base_ref):fail('invalid') checkout=os.path.join(root,'checkout') if repo: @@ -94,7 +95,10 @@ if act=='workspace': if not task:fail('invalid') target=os.path.join(root,'tasks',task);os.makedirs(os.path.dirname(target),exist_ok=True) task_branch=branch or 'paperclip/task-'+task - if not os.path.exists(target):run(['git','worktree','add','-b',task_branch,target,base_ref or 'HEAD'],checkout) + if existing_branch:run(['git','show-ref','--verify','refs/heads/'+existing_branch],checkout) + if not os.path.exists(target): + if existing_branch:run(['git','worktree','add',target,existing_branch],checkout) + else:run(['git','worktree','add','-b',task_branch,target,base_ref or 'HEAD'],checkout) run(['git','rev-parse','--show-toplevel'],target) if run(['git','symbolic-ref','--quiet','--short','HEAD'],target)!=task_branch:fail('conflict') else: diff --git a/server/src/modules/computers/adapters/repository.ts b/server/src/modules/computers/adapters/repository.ts index 7a827a260f6..6a886a4d4da 100644 --- a/server/src/modules/computers/adapters/repository.ts +++ b/server/src/modules/computers/adapters/repository.ts @@ -6,7 +6,8 @@ import { type Ledger, } from "../domain/ledger.js"; import type { ComputerRepository } from "../application/ports.js"; -export function computerRepository(db: Db): ComputerRepository { +export type ComputerDatabase = Db | Parameters[0]>[0]; +export function computerRepository(db: ComputerDatabase): ComputerRepository { const decode = (row: typeof computers.$inferSelect): ComputerRecord => ({ ...row, ledger: row.ledger as unknown as Ledger, diff --git a/server/src/modules/computers/application/service.ts b/server/src/modules/computers/application/service.ts index 53105fc78fe..8a2d1e85b95 100644 --- a/server/src/modules/computers/application/service.ts +++ b/server/src/modules/computers/application/service.ts @@ -901,6 +901,7 @@ finally: gitAuth?: { configArgs: string[]; env: Record }; branch?: string; baseRef?: string; + existingBranch?: string; mode: "shared" | "worktree"; }, ) { diff --git a/server/src/modules/computers/index.ts b/server/src/modules/computers/index.ts index 5ae73890c5a..a352404be51 100644 --- a/server/src/modules/computers/index.ts +++ b/server/src/modules/computers/index.ts @@ -1,6 +1,5 @@ -import type { Db } from "@paperclipai/db"; import { secretService } from "../../services/secrets.js"; -import { computerRepository } from "./adapters/repository.js"; +import { computerRepository, type ComputerDatabase } from "./adapters/repository.js"; import { boatBackend } from "./adapters/boat.js"; import { createComputerService } from "./application/service.js"; export { COMPUTER_RETIREMENT_GRACE_MS, ComputerError, ComputerStopPendingError } from "./domain/ledger.js"; @@ -10,8 +9,8 @@ export type { } from "./domain/ledger.js"; export type ComputerService = ReturnType; export type ComputerBinding = Awaited>; -const services = new WeakMap(); -export function computerService(db: Db): ComputerService { +const services = new WeakMap(); +export function computerService(db: ComputerDatabase): ComputerService { const existing = services.get(db); if (existing) return existing; const secrets = secretService(db); diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 5794ecc5270..12cacd1e4e3 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -1252,7 +1252,7 @@ export function environmentRoutes( // ref that fails validation (e.g. a deleted secret) must roll the whole // save back instead of leaving the config re-pointed with stale bindings. let environment = await db.transaction(async (tx) => { - const updated = await svc.update(existing.id, patch, { db: tx }); + let updated = await svc.update(existing.id, patch, { db: tx }); if (!updated) return null; if (patch.config !== undefined || patch.driver !== undefined) { await secrets.replaceSecretRefsForInstanceTarget( @@ -1269,20 +1269,22 @@ export function environmentRoutes( { db: tx }, ); } + // The candidate binding must be visible to provider verification without + // retiring the old committed credential when attachment fails. Use this + // same transaction for the computer ledger and activation as well. + if (updated.driver === "computer" && (req.body.config !== undefined || req.body.status === "active")) { + const parsed = parseEnvironmentDriverConfig(updated); + if (parsed.driver !== "computer") throw unprocessable("Invalid Boat configuration."); + await computerService(tx).attach({ companyId: String(updated.metadata?.computerCompanyId), + environmentId: updated.id, sandboxId: parsed.config.sandboxId, apiKeySecretRef: parsed.config.apiKeySecretRef }); + if (req.body.status !== "archived") updated = (await svc.update(updated.id, { status: "active" }, { db: tx }))!; + } return updated; }); if (!environment) { res.status(404).json({ error: "Environment not found" }); return; } - if (environment.driver === "computer" && (req.body.config !== undefined || req.body.status === "active")) { - const computerCompanyId = String(environment.metadata?.computerCompanyId); - const parsed = parseEnvironmentDriverConfig(environment); - if (parsed.driver !== "computer") throw unprocessable("Invalid Boat configuration."); - await computerService(db).attach({ companyId: computerCompanyId, environmentId: environment.id, - sandboxId: parsed.config.sandboxId, apiKeySecretRef: parsed.config.apiKeySecretRef }); - if (req.body.status !== "archived") environment = (await svc.update(environment.id, { status: "active" }))!; - } let customImageReconciliation: Awaited< ReturnType > = { action: "none" }; diff --git a/server/src/services/computer-environment-driver.ts b/server/src/services/computer-environment-driver.ts index 845a9f0cbd0..5fc754af6e7 100644 --- a/server/src/services/computer-environment-driver.ts +++ b/server/src/services/computer-environment-driver.ts @@ -78,13 +78,14 @@ export function createComputerEnvironmentDriver(db: Db): EnvironmentRuntimeDrive return { providerLeaseId: lease.providerLeaseId, state: "stopped" }; }, 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 request = workspace.metadata?.workspaceRealizationRequest as { source?: { projectId?: string; repoUrl?: string; repoRef?: string; branchName?: string; existingBranch?: 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, + existingBranch: request?.source?.existingBranch, mode: request?.source?.strategy === "git_worktree" ? "worktree" : "shared" }); await leases.updateLeaseMetadata(lease.id, { ...lease.metadata, remoteCwd: result.remoteCwd, fileAuthority: { kind: "remote-persistent", placementId: result.placementId, root: result.remoteCwd, agentHome: result.agentHome } }); diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index c251f4e02a9..78d05d30faa 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -8,6 +8,9 @@ import { eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; import { environmentRunOrchestrator } from "../environment-run-orchestrator.js"; +import { computerService } from "../../modules/computers/index.js"; +import { createComputerEnvironmentDriver } from "../computer-environment-driver.js"; +import { buildWorkspaceRealizationRequest, readWorkspaceRealizationRequest } from "../workspace-realization.js"; import { environmentService } from "../environments.js"; import { executionWorkspaceService } from "../execution-workspaces.js"; import { instanceSettingsService } from "../instance-settings.js"; @@ -219,6 +222,45 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary", expect(input.nativeRunnerPreparationSpans).toMatchObject([{ name: "environment.acquire", outcome: "failed" }]); }); + it("rejects an existing Boat branch with a shared workspace strategy before acquisition", async () => { + const { input, envOrchestrator } = await fixture(); + input.environment.selectedEnvironmentForConfig = { ...input.environment.selectedEnvironmentForConfig!, driver: "computer" }; + input.config.mergedConfig = { workspaceStrategy: { type: "project_primary", existingBranch: "operator/retained" } }; + const acquire = vi.spyOn(envOrchestrator, "acquireForRun"); + await expect(prepareHeartbeatWorkspace(db, input)).rejects.toMatchObject({ + resultJson: { workspaceValidation: { reason: "existing_branch_requires_git_worktree" } }, + }); + expect(acquire).not.toHaveBeenCalled(); + }); + + it.each([false, true])("preserves Boat workspace base and existing-branch selection (%s)", async existing => { + const { input, envOrchestrator } = await fixture(true); + input.environment.selectedEnvironmentForConfig = { ...input.environment.selectedEnvironmentForConfig!, driver: "computer" }; + input.config.mergedConfig = { workspaceStrategy: { type: "git_worktree", baseRef: "release-base", + ...(existing ? { existingBranch: "operator/retained" } : {}) } }; + const stopBeforeRemote = new Error("captured remote realization"); + const realize = vi.spyOn(envOrchestrator, "realizeForRun").mockRejectedValue(stopBeforeRemote); + await expect(prepareHeartbeatWorkspace(db, input)).rejects.toBe(stopBeforeRemote); + expect(realize).toHaveBeenCalledOnce(); + expect(realize.mock.calls[0]![0].executionWorkspace).toMatchObject({ + repoRef: "release-base", branchName: existing ? "operator/retained" : `paperclip/task-${input.task.issueId}`, + branchCreatedByRuntime: false, + }); + const args = realize.mock.calls[0]![0]; + const request = readWorkspaceRealizationRequest(buildWorkspaceRealizationRequest({ adapterType: input.agent.adapterType, + companyId: input.agent.companyId, environmentId: input.environment.selectedEnvironmentId, + executionWorkspaceId: null, issueId: input.task.issueId, heartbeatRunId: input.run.id, + requestedMode: "isolated_workspace", workspace: args.executionWorkspace, workspaceConfig: null }))!; + const remote = vi.spyOn(computerService(db), "realizeWorkspace").mockResolvedValue({ + remoteCwd: "/home/user/project/task", agentHome: "/home/user/agent", placementId: "placement" }); + await createComputerEnvironmentDriver(db).realizeWorkspace!({ environment: args.environment, + lease: { ...args.lease, providerLeaseId: "owner", metadata: { computerOwner: { computerId: "computer", ownerId: "owner", generation: 1 } } }, + workspace: { localPath: args.executionWorkspace.cwd, metadata: { workspaceRealizationRequest: request } } }); + expect(remote).toHaveBeenCalledWith(expect.objectContaining({ baseRef: "release-base", + branch: existing ? "operator/retained" : `paperclip/task-${input.task.issueId}`, + existingBranch: existing ? "operator/retained" : undefined })); + }); + it("binds a replacement workspace from realization and snapshots the restore lease", async () => { const { input, envOrchestrator, issue } = await fixture(); const realize = envOrchestrator.realizeForRun; diff --git a/server/src/services/heartbeat/workspace-preparation.ts b/server/src/services/heartbeat/workspace-preparation.ts index 39fccda9d68..4fd1ef03738 100644 --- a/server/src/services/heartbeat/workspace-preparation.ts +++ b/server/src/services/heartbeat/workspace-preparation.ts @@ -33,6 +33,7 @@ import { cleanupExecutionWorkspaceArtifacts, ensurePersistedExecutionWorkspaceAvailable, realizeExecutionWorkspace, + WorkspaceRuntimeValidationFailure, type ExecutionWorkspaceInput, type RealizedExecutionWorkspace, } from "../workspace-runtime.js"; @@ -468,6 +469,14 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac agentId: agent.id, }, ); + const computerExistingBranch = selectedEnvironmentForConfig?.driver === "computer" + ? readNonEmptyString(workspaceStrategyForFingerprint.existingBranch) : null; + if (computerExistingBranch && latestWorkspaceStrategyType !== "git_worktree") { + throw new WorkspaceRuntimeValidationFailure("An existing branch requires the git_worktree workspace strategy.", { + workspaceValidation: { reason: "existing_branch_requires_git_worktree", requestedExistingBranch: computerExistingBranch, + strategyType: latestWorkspaceStrategyType }, + }); + } const { executionWorkspace, reusedExecutionWorkspace, @@ -477,8 +486,11 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac ...executionWorkspaceBase, strategy: selectedEnvironmentForConfig?.driver === "computer" ? latestWorkspaceStrategyType : "project_primary", cwd: resolvedWorkspace.cwd, + repoRef: selectedEnvironmentForConfig?.driver === "computer" && latestWorkspaceStrategyType === "git_worktree" + ? readNonEmptyString(workspaceStrategyForFingerprint.baseRef) ?? executionWorkspaceBase.repoRef : executionWorkspaceBase.repoRef, branchName: selectedEnvironmentForConfig?.driver === "computer" && latestWorkspaceStrategyType === "git_worktree" && issueId - ? reusableExistingExecutionWorkspace?.branchName ?? `paperclip/task-${issueId}` : null, + ? computerExistingBranch ?? reusableExistingExecutionWorkspace?.branchName ?? `paperclip/task-${issueId}` : null, + ...(computerExistingBranch ? { existingBranch: computerExistingBranch } : {}), worktreePath: null, warnings: [], created: false, branchCreatedByRuntime: false, } as RealizedExecutionWorkspace, reusedExecutionWorkspace: false, diff --git a/server/src/services/workspace-realization.ts b/server/src/services/workspace-realization.ts index f69e393174e..3569611dc62 100644 --- a/server/src/services/workspace-realization.ts +++ b/server/src/services/workspace-realization.ts @@ -93,6 +93,7 @@ export function readWorkspaceRealizationRequest(value: unknown): WorkspaceRealiz repoRef: readString(source.repoRef), strategy: source.strategy === "git_worktree" ? "git_worktree" : "project_primary", branchName: readString(source.branchName), + ...(readString(source.existingBranch) ? { existingBranch: readString(source.existingBranch)! } : {}), worktreePath: readString(source.worktreePath), }, additionalSources: readAdditionalSources(parsed.additionalSources), @@ -137,6 +138,7 @@ export function buildWorkspaceRealizationRequest(input: { repoRef: input.workspace.repoRef, strategy: input.workspace.strategy, branchName: input.workspace.branchName, + ...(input.workspace.existingBranch ? { existingBranch: input.workspace.existingBranch } : {}), worktreePath: input.workspace.worktreePath, }, // The additional (referenced) sources carry the read-only referenced-project workspaces. Run diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 5c161536697..64989dfa452 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -170,6 +170,8 @@ export interface RealizedExecutionWorkspace extends ExecutionWorkspaceInput { strategy: "project_primary" | "git_worktree"; cwd: string; branchName: string | null; + /** Remote realization intent; distinct from ownership established after creation. */ + existingBranch?: string; worktreePath: string | null; warnings: string[]; created: boolean;