mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip runs across the CLI, server, adapters, plugins, CI, and container images. > - These surfaces declared different Node.js versions from 20 through 24. > - A newer `@types/node` major can expose APIs that the supported runtime does not provide. > - Node.js 20 is no longer a suitable project baseline, and Node.js 24 is the current LTS line. > - This pull request sets Node.js 24.11.0 as one repository-wide baseline, adds a drift check, and gives users actionable startup guidance when their runtime is too old. > - The benefit is one clear runtime contract for development, release, installation, and published packages. ## Linked Issues or Issue Description Refs #2734 Refs #11727 Refs #739 ## What Changed - Require Node.js 24.11.0 or newer in all 42 package manifests and runtime checks. - Use Node.js 24 in GitHub Actions, Docker images, smoke images, sandbox setup, portable installs, and esbuild targets. - Align every direct `@types/node` declaration on `^24.0.0`. - Prevent Dependabot from opening major `@types/node` upgrades without a matching runtime decision. - Add `.nvmrc` and a CI policy check for Node version drift. - Update ACP version gates, tests, and user documentation for the new minimum. - Print a non-blocking warning on CLI and server startup when Node is unsupported, with remediation through a version manager or the documented downloaded `install.sh` workflow. - Deduplicate that warning when `paperclipai run` boots the CLI and server in the same process. ## Verification - `node scripts/check-node-version-policy.mjs` - `node --check scripts/check-node-version-policy.mjs` - `node --check cli/esbuild.config.mjs` - `node --check scripts/generate-npm-package-json.mjs` - `bash -n scripts/install.sh scripts/test-install-sh-docker.sh scripts/e2e-install-lifecycle.sh` - Parsed all 42 package manifests and confirmed `engines.node` is `>=24.11.0`. - `git diff --check` - `vitest run packages/adapter-utils/src/sandbox-install-command.test.ts` passed with 3 tests. - `vitest run cli/src/node-version.test.ts` passed with 4 tests. - Directly exercised the shared warning helper for unsupported-version messaging and same-process deduplication. - The focused exe.dev suite could not resolve the locally unbuilt plugin SDK from this isolated worktree. A full offline workspace install was also blocked because the package-manager signature verifier requires registry access. The full suite was not run locally; draft CI performs a clean install and evaluates the wider impact. ## Risks - This is a breaking runtime change for users, plugins, and deployments that still use Node.js 20 or 22. - Published workspace packages will now produce an engine warning or failure in strict package managers on older Node.js releases. - Node.js 24 can reveal dependency, native module, Playwright, or agent CLI compatibility issues in CI. - The bootstrap installer now installs Node.js 24 when the current runtime is older than 24.11.0. - The portable sandbox fallback is pinned to Node.js 24.11.0 and depends on that upstream tarball remaining available. - Unsupported runtimes continue booting after a warning, so a later incompatibility can still fail at its point of use. - The CLI and server share the warning policy through the published `@paperclipai/shared` package; packaging checks must keep that subpath export available. - This PR does not commit `pnpm-lock.yaml` because repository policy assigns lockfile generation to CI. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex based on GPT-5. The exact deployment ID and context window are not exposed in this session. Reasoning, repository tools, shell execution, and GitHub tools were enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <[email protected]>
730 lines
25 KiB
TypeScript
730 lines
25 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import type { AdapterExecutionContext, AdapterInvocationMeta } from "@paperclipai/adapter-utils";
|
|
import { runChildProcess } from "@paperclipai/adapter-utils/server-utils";
|
|
import {
|
|
buildGeminiAcpConfig,
|
|
createGeminiAcpExecutor,
|
|
nodeVersionMeetsGeminiAcpMinimum,
|
|
resolveGeminiExecutionEngine,
|
|
resolveGeminiExecutionEngineForRun,
|
|
testGeminiAcpEnvironment,
|
|
} from "./acp.js";
|
|
|
|
// A local stand-in for a sandbox runner: runs the managed-runtime staging
|
|
// scripts (mkdir/tar/find) as real child processes so the remote ACP lane can
|
|
// be exercised end-to-end against the host filesystem.
|
|
function createLocalSandboxRunner() {
|
|
let counter = 0;
|
|
return {
|
|
execute: async (input: {
|
|
command: string;
|
|
args?: string[];
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
stdin?: string;
|
|
timeoutMs?: number;
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
}) => {
|
|
counter += 1;
|
|
const command = input.command === "bash" ? "/bin/bash" : input.command;
|
|
return await runChildProcess(`gemini-acp-sandbox-run-${counter}`, command, input.args ?? [], {
|
|
cwd: input.cwd ?? process.cwd(),
|
|
env: input.env ?? {},
|
|
stdin: input.stdin,
|
|
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
|
|
graceSec: 5,
|
|
onLog: input.onLog ?? (async () => {}),
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
type FakeRuntimeOptions = Record<string, unknown>;
|
|
type FakeRuntimeEvent = { type: string; text?: string; stream?: string; tag?: string };
|
|
type FakeRuntimeHandle = {
|
|
sessionKey: string;
|
|
backend: string;
|
|
runtimeSessionName: string;
|
|
cwd?: string;
|
|
acpxRecordId: string;
|
|
backendSessionId: string;
|
|
agentSessionId: string;
|
|
};
|
|
type FakeRuntimeTurnResult = { status: "completed" | "failed" | "cancelled"; stopReason?: string };
|
|
type FakeRuntimeTurn = {
|
|
requestId: string;
|
|
events: AsyncIterable<FakeRuntimeEvent>;
|
|
result: Promise<FakeRuntimeTurnResult>;
|
|
cancel: () => Promise<void>;
|
|
closeStream: () => Promise<void>;
|
|
};
|
|
|
|
const tempRoots: string[] = [];
|
|
const originalNodeVersion = process.version;
|
|
const originalPath = process.env.PATH;
|
|
const originalHome = process.env.HOME;
|
|
const originalGeminiApiKey = process.env.GEMINI_API_KEY;
|
|
|
|
function setNodeVersion(version: string): void {
|
|
Object.defineProperty(process, "version", {
|
|
configurable: true,
|
|
enumerable: true,
|
|
value: version,
|
|
});
|
|
}
|
|
|
|
afterEach(async () => {
|
|
setNodeVersion(originalNodeVersion);
|
|
if (originalPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = originalPath;
|
|
if (originalHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = originalHome;
|
|
if (originalGeminiApiKey === undefined) delete process.env.GEMINI_API_KEY;
|
|
else process.env.GEMINI_API_KEY = originalGeminiApiKey;
|
|
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
|
|
});
|
|
|
|
class FakeRuntime {
|
|
ensureInputs: Array<{
|
|
sessionKey: string;
|
|
agent: string;
|
|
mode: "persistent" | "oneshot";
|
|
cwd?: string;
|
|
resumeSessionId?: string;
|
|
}> = [];
|
|
startInputs: Array<{ handle: FakeRuntimeHandle; text: string; requestId: string; timeoutMs?: number }> = [];
|
|
closeInputs: Array<{ handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }> = [];
|
|
setConfigInputs: Array<{ handle: FakeRuntimeHandle; key: string; value: string }> = [];
|
|
ensureCount = 0;
|
|
|
|
constructor(
|
|
readonly options: FakeRuntimeOptions,
|
|
readonly events: FakeRuntimeEvent[] = [
|
|
{ type: "text_delta", text: "hello", stream: "output", tag: "agent_message_chunk" },
|
|
],
|
|
readonly terminal: FakeRuntimeTurnResult = { status: "completed", stopReason: "end_turn" },
|
|
) {}
|
|
|
|
async ensureSession(input: {
|
|
sessionKey: string;
|
|
agent: string;
|
|
mode: "persistent" | "oneshot";
|
|
cwd?: string;
|
|
resumeSessionId?: string;
|
|
}): Promise<FakeRuntimeHandle> {
|
|
this.ensureInputs.push(input);
|
|
this.ensureCount += 1;
|
|
return {
|
|
sessionKey: input.sessionKey,
|
|
backend: "acpx",
|
|
runtimeSessionName: `runtime-${this.ensureCount}`,
|
|
cwd: input.cwd,
|
|
acpxRecordId: `record-${this.ensureCount}`,
|
|
backendSessionId: `acp-${this.ensureCount}`,
|
|
agentSessionId: `agent-${this.ensureCount}`,
|
|
};
|
|
}
|
|
|
|
startTurn(input: {
|
|
handle: FakeRuntimeHandle;
|
|
text: string;
|
|
requestId: string;
|
|
timeoutMs?: number;
|
|
}): FakeRuntimeTurn {
|
|
this.startInputs.push(input);
|
|
const events = this.events;
|
|
const terminal = this.terminal;
|
|
return {
|
|
requestId: input.requestId,
|
|
events: {
|
|
[Symbol.asyncIterator]: async function* () {
|
|
for (const event of events) yield event;
|
|
},
|
|
},
|
|
result: Promise.resolve(terminal),
|
|
cancel: async () => {},
|
|
closeStream: async () => {},
|
|
};
|
|
}
|
|
|
|
runTurn(): AsyncIterable<FakeRuntimeEvent> {
|
|
throw new Error("not used");
|
|
}
|
|
|
|
getCapabilities() {
|
|
return { controls: [] };
|
|
}
|
|
|
|
getStatus() {
|
|
return Promise.resolve({});
|
|
}
|
|
|
|
async setConfigOption(input: { handle: FakeRuntimeHandle; key: string; value: string }) {
|
|
this.setConfigInputs.push(input);
|
|
}
|
|
|
|
async setMode() {}
|
|
|
|
async cancel() {}
|
|
|
|
async close(input: { handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }) {
|
|
this.closeInputs.push(input);
|
|
}
|
|
}
|
|
|
|
async function makeTempRoot(prefix: string) {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
tempRoots.push(root);
|
|
return root;
|
|
}
|
|
|
|
function buildContext(root: string, overrides: Partial<AdapterExecutionContext> = {}): AdapterExecutionContext {
|
|
return {
|
|
runId: "run-1",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Gemini ACP",
|
|
adapterType: "gemini_local",
|
|
adapterConfig: {},
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: "PAP-1",
|
|
},
|
|
config: {
|
|
engine: "acp",
|
|
cwd: root,
|
|
stateDir: path.join(root, "state"),
|
|
command: "fake-gemini",
|
|
promptTemplate: "Do the assigned work.",
|
|
},
|
|
context: {
|
|
issueId: "issue-1",
|
|
paperclipTaskMarkdown: "Task context",
|
|
paperclipWorkspace: {
|
|
cwd: root,
|
|
source: "project_workspace",
|
|
workspaceId: "workspace-1",
|
|
},
|
|
},
|
|
onLog: async () => {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("gemini_local ACP lane", () => {
|
|
it("maps Gemini config to the ACPX Gemini target", () => {
|
|
expect(buildGeminiAcpConfig({
|
|
engine: "acp",
|
|
cwd: "/repo",
|
|
model: "gemini-2.5-pro",
|
|
command: "/opt/gemini",
|
|
warmHandleIdleMs: 25,
|
|
})).toMatchObject({
|
|
agent: "gemini",
|
|
cwd: "/repo",
|
|
model: "gemini-2.5-pro",
|
|
agentCommand: "/opt/gemini --acp",
|
|
mode: "persistent",
|
|
permissionMode: "approve-all",
|
|
nonInteractivePermissions: "deny",
|
|
warmHandleIdleMs: 25,
|
|
});
|
|
|
|
expect(buildGeminiAcpConfig({ engine: "acp", model: "auto" })).not.toHaveProperty("model");
|
|
expect(buildGeminiAcpConfig({ engine: "acp", agentCommand: "custom-gemini-acp" })).toMatchObject({
|
|
agentCommand: "custom-gemini-acp",
|
|
});
|
|
});
|
|
|
|
it("checks the Node version required by the Gemini ACP runtime", () => {
|
|
setNodeVersion("v24.10.0");
|
|
expect(nodeVersionMeetsGeminiAcpMinimum()).toBe(false);
|
|
setNodeVersion("v24.11.0");
|
|
expect(nodeVersionMeetsGeminiAcpMinimum()).toBe(true);
|
|
});
|
|
|
|
it("defaults to ACP when prerequisites pass and falls back to CLI only for auto resolution", async () => {
|
|
const root = await makeTempRoot("paperclip-gemini-acp-default-");
|
|
const commandPath = path.join(root, "bin", "gemini");
|
|
await fs.mkdir(path.dirname(commandPath), { recursive: true });
|
|
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
|
|
setNodeVersion("v24.11.0");
|
|
|
|
expect(resolveGeminiExecutionEngine({})).toEqual({ engine: "acp", explicit: false });
|
|
await expect(
|
|
resolveGeminiExecutionEngineForRun({
|
|
config: { command: commandPath },
|
|
executionTarget: null,
|
|
}),
|
|
).resolves.toEqual({ engine: "acp", explicit: false });
|
|
await expect(
|
|
resolveGeminiExecutionEngineForRun({
|
|
config: { engine: "cli", command: commandPath },
|
|
executionTarget: null,
|
|
}),
|
|
).resolves.toEqual({ engine: "cli", explicit: true });
|
|
expect(resolveGeminiExecutionEngine({ engine: "acp" })).toEqual({
|
|
engine: "acp",
|
|
explicit: true,
|
|
});
|
|
|
|
setNodeVersion("v24.10.0");
|
|
await expect(
|
|
resolveGeminiExecutionEngineForRun({
|
|
config: { command: commandPath },
|
|
executionTarget: null,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
engine: "cli",
|
|
explicit: false,
|
|
fallbackReason: expect.stringContaining("Node"),
|
|
});
|
|
await expect(
|
|
resolveGeminiExecutionEngineForRun({
|
|
config: { engine: "acp", command: "/missing/gemini" },
|
|
executionTarget: null,
|
|
}),
|
|
).resolves.toEqual({ engine: "acp", explicit: true });
|
|
});
|
|
|
|
it("falls back to the CLI lane for non-sandbox remote auto runs", async () => {
|
|
setNodeVersion("v24.11.0");
|
|
await expect(
|
|
resolveGeminiExecutionEngineForRun({
|
|
config: { agentCommand: "gemini --acp" },
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "ssh",
|
|
remoteCwd: "/work",
|
|
spec: {
|
|
host: "127.0.0.1",
|
|
port: 22,
|
|
username: "fixture",
|
|
remoteCwd: "/work",
|
|
remoteWorkspacePath: "/work",
|
|
privateKey: null,
|
|
knownHosts: null,
|
|
strictHostKeyChecking: true,
|
|
},
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({
|
|
engine: "cli",
|
|
explicit: false,
|
|
fallbackReason: expect.stringContaining("sandbox remote targets only"),
|
|
});
|
|
});
|
|
|
|
it("falls back to the CLI lane for one-shot sandbox auto runs", async () => {
|
|
setNodeVersion("v24.11.0");
|
|
await expect(
|
|
resolveGeminiExecutionEngineForRun({
|
|
config: {},
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd: "/work",
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({
|
|
engine: "cli",
|
|
explicit: false,
|
|
fallbackReason: expect.stringContaining("bidirectional remote process"),
|
|
});
|
|
});
|
|
|
|
it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => {
|
|
setNodeVersion("v24.11.0");
|
|
await expect(
|
|
resolveGeminiExecutionEngineForRun({
|
|
config: { agentCommand: "gemini --acp" },
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd: "/work",
|
|
runner: {
|
|
execute: async () => ({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "",
|
|
stderr: "",
|
|
pid: null,
|
|
startedAt: new Date().toISOString(),
|
|
}),
|
|
},
|
|
},
|
|
}),
|
|
).resolves.toEqual({
|
|
engine: "acp",
|
|
explicit: false,
|
|
});
|
|
});
|
|
|
|
it("executes Gemini through the shared ACP runtime", async () => {
|
|
const root = await makeTempRoot("paperclip-gemini-acp-run-");
|
|
process.env.HOME = path.join(root, "home");
|
|
const runtime = new FakeRuntime({});
|
|
const metas: AdapterInvocationMeta[] = [];
|
|
const logs: Array<{ stream: string; text: string }> = [];
|
|
const execute = createGeminiAcpExecutor({
|
|
createRuntime: (options) => {
|
|
Object.assign(runtime.options, options);
|
|
return runtime as never;
|
|
},
|
|
});
|
|
|
|
const result = await execute(buildContext(root, {
|
|
onMeta: async (meta) => {
|
|
metas.push(meta);
|
|
},
|
|
onLog: async (stream, text) => {
|
|
logs.push({ stream, text });
|
|
},
|
|
}));
|
|
|
|
expect(runtime.ensureInputs[0]).toMatchObject({
|
|
agent: "gemini",
|
|
mode: "persistent",
|
|
cwd: root,
|
|
});
|
|
expect(runtime.startInputs[0]?.text).toContain("Do the assigned work.");
|
|
expect(result).toMatchObject({
|
|
exitCode: 0,
|
|
provider: "acpx",
|
|
sessionId: "acp-1",
|
|
sessionDisplayId: "agent-1",
|
|
summary: "hello",
|
|
});
|
|
expect(result.sessionParams).toMatchObject({
|
|
agent: "gemini",
|
|
acpSessionId: "acp-1",
|
|
cwd: root,
|
|
});
|
|
expect(metas[0]).toMatchObject({
|
|
adapterType: "gemini_local",
|
|
command: "fake-gemini --acp",
|
|
});
|
|
expect(logs.some((entry) => entry.text.includes("\"type\":\"acpx.session\""))).toBe(true);
|
|
});
|
|
|
|
it("creates the ACP session on the in-sandbox workspace cwd for runner-backed remote runs", async () => {
|
|
const root = await makeTempRoot("paperclip-gemini-acp-remote-cwd-");
|
|
process.env.HOME = path.join(root, "home");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
|
|
|
const runtime = new FakeRuntime({});
|
|
const execute = createGeminiAcpExecutor({
|
|
createRuntime: (options) => {
|
|
Object.assign(runtime.options, options);
|
|
return runtime as never;
|
|
},
|
|
});
|
|
|
|
const result = await execute(
|
|
buildContext(localCwd, {
|
|
config: {
|
|
engine: "acp",
|
|
cwd: localCwd,
|
|
// Throwaway ACP command so the process-session bridge does not require
|
|
// a real gemini binary in the local sandbox stand-in.
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir: path.join(root, "state"),
|
|
promptTemplate: "Do the assigned work.",
|
|
},
|
|
context: {
|
|
issueId: "issue-1",
|
|
paperclipTaskMarkdown: "Task context",
|
|
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
},
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
} as never,
|
|
authToken: "real-run-jwt",
|
|
}),
|
|
);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
await expect(fs.readFile(path.join(remoteCwd, "hello.txt"), "utf8")).resolves.toBe("hi");
|
|
expect(runtime.ensureInputs[0]?.cwd).toBe(remoteCwd);
|
|
expect(runtime.ensureInputs[0]?.cwd).not.toBe(localCwd);
|
|
});
|
|
|
|
it("seeds the managed Gemini home into the sandbox, repoints HOME, and keeps the key file-only", async () => {
|
|
const root = await makeTempRoot("paperclip-gemini-acp-home-seed-");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
const hostHome = path.join(root, "home");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
// A selected skill so the shipped skills asset has content to seed.
|
|
const skillSource = path.join(root, "skills", "review");
|
|
await fs.mkdir(skillSource, { recursive: true });
|
|
await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\n---\nUse the review skill.\n", "utf8");
|
|
// The credential is delivered through the adapter-config env — the run env the
|
|
// seam forwards into the sandbox — so pre-selecting api-key auth is backed by a
|
|
// credential that is actually available in-sandbox. A stray host-only key must
|
|
// NOT be relied on, so we clear it to prove the selection comes from the run env.
|
|
const SECRET_KEY = "AIza-secret-key-value-SENTINEL";
|
|
process.env.HOME = hostHome;
|
|
delete process.env.GEMINI_API_KEY;
|
|
|
|
const meta: AdapterInvocationMeta[] = [];
|
|
const runtime = new FakeRuntime({});
|
|
const execute = createGeminiAcpExecutor({
|
|
createRuntime: (options) => {
|
|
Object.assign(runtime.options, options);
|
|
return runtime as never;
|
|
},
|
|
});
|
|
|
|
const result = await execute(
|
|
buildContext(localCwd, {
|
|
config: {
|
|
engine: "acp",
|
|
cwd: localCwd,
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir: path.join(root, "state"),
|
|
// Drive resolveGeminiSkillsHome to a temp home so the engine prepares
|
|
// skills off the real ~/.gemini, and deliver the key via config env.
|
|
env: { HOME: hostHome, GEMINI_API_KEY: SECRET_KEY },
|
|
promptTemplate: "Do the assigned work.",
|
|
paperclipRuntimeSkills: [{ key: "company/review", runtimeName: "review", source: skillSource }],
|
|
paperclipSkillSync: { desiredSkills: ["company/review"] },
|
|
},
|
|
context: {
|
|
issueId: "issue-1",
|
|
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
},
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
} as never,
|
|
authToken: "real-run-jwt",
|
|
onMeta: async (payload) => {
|
|
meta.push(payload);
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
const remappedHome = String(meta[0]?.env?.HOME ?? "");
|
|
// C2 — HOME repointed onto the in-sandbox managed runtime root, distinct from
|
|
// the host home.
|
|
expect(remappedHome).not.toBe(hostHome);
|
|
expect(remappedHome).toContain(".paperclip-runtime");
|
|
// Seeded: skills copied into $HOME/.gemini/skills (local runner = host FS).
|
|
await expect(
|
|
fs.readFile(path.join(remappedHome, ".gemini", "skills", "review", "SKILL.md"), "utf8"),
|
|
).resolves.toContain("review skill");
|
|
// settings.json pre-selects api-key auth but carries no key bytes.
|
|
const settingsRaw = await fs.readFile(path.join(remappedHome, ".gemini", "settings.json"), "utf8");
|
|
expect(settingsRaw).toContain("gemini-api-key");
|
|
expect(settingsRaw).not.toContain(SECRET_KEY);
|
|
// The selector is backed by a credential the sandbox actually receives: the key
|
|
// rides in the forwarded run env (that is how it reaches the sandbox). The
|
|
// invocation meta redacts the value for logging, so it is present but never the
|
|
// raw bytes; the settings.json selector above proves the seam saw it in-env.
|
|
expect(meta[0]?.env?.GEMINI_API_KEY).toBeDefined();
|
|
expect(meta[0]?.env?.GEMINI_API_KEY).not.toBe(SECRET_KEY);
|
|
// C4 — no XDG_* variable introduced for credential discovery.
|
|
expect(Object.keys(meta[0]?.env ?? {}).filter((key) => key.startsWith("XDG_"))).toEqual([]);
|
|
});
|
|
|
|
it("test_gemini_acp_seam_registers_workspace_sync_back", async () => {
|
|
const root = await makeTempRoot("paperclip-gemini-acp-syncback-");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
|
|
|
// The runtime writes a NEW file into the in-sandbox workspace during the turn.
|
|
// The seam must register a workspace sync-back teardown, so the file lands in
|
|
// the host worktree after the run.
|
|
const runtime = new FakeRuntime({});
|
|
const startTurn = runtime.startTurn.bind(runtime);
|
|
runtime.startTurn = (input) => {
|
|
const turn = startTurn(input);
|
|
const remoteWorkspaceCwd = input.handle.cwd ?? remoteCwd;
|
|
return {
|
|
...turn,
|
|
result: (async () => {
|
|
await fs.writeFile(path.join(remoteWorkspaceCwd, "from-sandbox.txt"), "synced", "utf8");
|
|
return await turn.result;
|
|
})(),
|
|
};
|
|
};
|
|
|
|
const execute = createGeminiAcpExecutor({
|
|
createRuntime: (options) => {
|
|
Object.assign(runtime.options, options);
|
|
return runtime as never;
|
|
},
|
|
});
|
|
|
|
const result = await execute(
|
|
buildContext(localCwd, {
|
|
config: {
|
|
engine: "acp",
|
|
cwd: localCwd,
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir: path.join(root, "state"),
|
|
promptTemplate: "Do the assigned work.",
|
|
},
|
|
context: {
|
|
issueId: "issue-1",
|
|
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
},
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
} as never,
|
|
authToken: "real-run-jwt",
|
|
}),
|
|
);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
// The teardown fired `restoreWorkspace`, so the sandbox-authored file is now
|
|
// in the host worktree.
|
|
await expect(fs.readFile(path.join(localCwd, "from-sandbox.txt"), "utf8")).resolves.toBe("synced");
|
|
});
|
|
|
|
it("does not persist an api-key auth selector from a host-only credential", async () => {
|
|
const root = await makeTempRoot("paperclip-gemini-acp-hostkey-");
|
|
const localCwd = path.join(root, "worktree");
|
|
const remoteCwd = path.join(root, "remote-workspace");
|
|
const hostHome = path.join(root, "home");
|
|
await fs.mkdir(localCwd, { recursive: true });
|
|
await fs.mkdir(remoteCwd, { recursive: true });
|
|
// The key exists ONLY in the host process env — never in the adapter-config env
|
|
// — so the remote sandbox (which does not inherit the host environment) will not
|
|
// receive it. Selecting api-key auth off this host-only signal would start
|
|
// headless Gemini with a credential it cannot see and fail authentication, so
|
|
// the seam must NOT persist a selector here.
|
|
const SECRET_KEY = "AIza-host-only-key-SENTINEL";
|
|
process.env.HOME = hostHome;
|
|
process.env.GEMINI_API_KEY = SECRET_KEY;
|
|
|
|
const meta: AdapterInvocationMeta[] = [];
|
|
const runtime = new FakeRuntime({});
|
|
const execute = createGeminiAcpExecutor({
|
|
createRuntime: (options) => {
|
|
Object.assign(runtime.options, options);
|
|
return runtime as never;
|
|
},
|
|
});
|
|
|
|
const result = await execute(
|
|
buildContext(localCwd, {
|
|
config: {
|
|
engine: "acp",
|
|
cwd: localCwd,
|
|
agentCommand: "node ./fake-acp.js",
|
|
stateDir: path.join(root, "state"),
|
|
env: { HOME: hostHome },
|
|
promptTemplate: "Do the assigned work.",
|
|
},
|
|
context: {
|
|
issueId: "issue-1",
|
|
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
},
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd,
|
|
runner: createLocalSandboxRunner(),
|
|
} as never,
|
|
authToken: "real-run-jwt",
|
|
onMeta: async (payload) => {
|
|
meta.push(payload);
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
const remappedHome = String(meta[0]?.env?.HOME ?? "");
|
|
expect(remappedHome).toContain(".paperclip-runtime");
|
|
// No settings.json auth selector is written, because a host-only key is not a
|
|
// reliable in-sandbox credential signal.
|
|
await expect(
|
|
fs.readFile(path.join(remappedHome, ".gemini", "settings.json"), "utf8"),
|
|
).rejects.toThrow();
|
|
// And the host-only key never leaks into the forwarded run env.
|
|
for (const value of Object.values(meta[0]?.env ?? {})) {
|
|
expect(String(value)).not.toContain(SECRET_KEY);
|
|
}
|
|
});
|
|
|
|
it("falls back to the CLI lane for a runner-less sandbox even when the ACP command is set", async () => {
|
|
setNodeVersion("v24.11.0");
|
|
await expect(
|
|
resolveGeminiExecutionEngineForRun({
|
|
config: { agentCommand: "gemini --acp" },
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "sandbox",
|
|
providerKey: "fake-plugin",
|
|
remoteCwd: "/work",
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({
|
|
engine: "cli",
|
|
explicit: false,
|
|
fallbackReason: expect.stringContaining("bidirectional remote process"),
|
|
});
|
|
});
|
|
|
|
it("reports Gemini ACP environment readiness", async () => {
|
|
const root = await makeTempRoot("paperclip-gemini-acp-env-");
|
|
const bin = path.join(root, "bin");
|
|
await fs.mkdir(bin, { recursive: true });
|
|
await fs.writeFile(path.join(bin, "gemini"), "#!/usr/bin/env sh\n", "utf8");
|
|
process.env.PATH = `${bin}${path.delimiter}${process.env.PATH ?? ""}`;
|
|
process.env.GEMINI_API_KEY = "test-key";
|
|
setNodeVersion("v24.11.0");
|
|
|
|
const result = await testGeminiAcpEnvironment({
|
|
adapterType: "gemini_local",
|
|
companyId: "company-1",
|
|
config: {
|
|
engine: "acp",
|
|
cwd: root,
|
|
},
|
|
});
|
|
|
|
expect(result.status).toBe("pass");
|
|
expect(result.checks).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ code: "gemini_engine_selected" }),
|
|
expect.objectContaining({ code: "gemini_acp_command_resolvable" }),
|
|
expect.objectContaining({ code: "gemini_acp_credentials_detected" }),
|
|
]),
|
|
);
|
|
});
|
|
});
|