mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Integrate reviewed Boat credentials and workspace fixes
Co-authored-by: Codex <noreply@openai.com> * codex/boat-environment-core: Carry explicit existing-branch intent in workspace realization Preserve Boat credentials and explicit workspace strategies Carry explicit existing-branch intent in workspace realization Fix runtime prerequisite home, workspace, and integrity boundaries
This commit is contained in:
commit
4479d24130
23 files changed
+234
-37
No files matched your search
@@ -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");
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
executionTargetIsRemote,
|
||||
executionCwd: effectiveExecutionCwd,
|
||||
});
|
||||
if (!executionTargetIsRemote) {
|
||||
if (executionTarget?.workspaceRealization?.mode !== "in_place") {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ afterEach(async () => {
|
||||
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<FileHandle["stat"]>) {
|
||||
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<FileHandle["read"]>) {
|
||||
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(); }
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}`,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
/**
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<typeof import("../modules/computers/index.js")>(),
|
||||
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);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<Parameters<Db["transaction"]>[0]>[0];
|
||||
export function computerRepository(db: ComputerDatabase): ComputerRepository {
|
||||
const decode = (row: typeof computers.$inferSelect): ComputerRecord => ({
|
||||
...row,
|
||||
ledger: row.ledger as unknown as Ledger,
|
||||
|
||||
@@ -901,6 +901,7 @@ finally:
|
||||
gitAuth?: { configArgs: string[]; env: Record<string, string> };
|
||||
branch?: string;
|
||||
baseRef?: string;
|
||||
existingBranch?: string;
|
||||
mode: "shared" | "worktree";
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -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<typeof createComputerService>;
|
||||
export type ComputerBinding = Awaited<ReturnType<ComputerService["admit"]>>;
|
||||
const services = new WeakMap<Db, ComputerService>();
|
||||
export function computerService(db: Db): ComputerService {
|
||||
const services = new WeakMap<ComputerDatabase, ComputerService>();
|
||||
export function computerService(db: ComputerDatabase): ComputerService {
|
||||
const existing = services.get(db);
|
||||
if (existing) return existing;
|
||||
const secrets = secretService(db);
|
||||
|
||||
@@ -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<typeof customImages.reconcileActiveTemplateForConfigChange>
|
||||
> = { action: "none" };
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user