@tickernelz/paperclip-pro-adapter-gemini-local 2026.925.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/dist/cli/format-event.d.ts +2 -0
- package/dist/cli/format-event.d.ts.map +1 -0
- package/dist/cli/format-event.js +223 -0
- package/dist/cli/format-event.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/server/acp.d.ts +19 -0
- package/dist/server/acp.d.ts.map +1 -0
- package/dist/server/acp.js +404 -0
- package/dist/server/acp.js.map +1 -0
- package/dist/server/acp.test.d.ts +2 -0
- package/dist/server/acp.test.d.ts.map +1 -0
- package/dist/server/acp.test.js +662 -0
- package/dist/server/acp.test.js.map +1 -0
- package/dist/server/config-schema.d.ts +3 -0
- package/dist/server/config-schema.d.ts.map +1 -0
- package/dist/server/config-schema.js +67 -0
- package/dist/server/config-schema.js.map +1 -0
- package/dist/server/engine-availability.test.d.ts +2 -0
- package/dist/server/engine-availability.test.d.ts.map +1 -0
- package/dist/server/engine-availability.test.js +41 -0
- package/dist/server/engine-availability.test.js.map +1 -0
- package/dist/server/execute.acp-fallback.test.d.ts +2 -0
- package/dist/server/execute.acp-fallback.test.d.ts.map +1 -0
- package/dist/server/execute.acp-fallback.test.js +130 -0
- package/dist/server/execute.acp-fallback.test.js.map +1 -0
- package/dist/server/execute.d.ts +3 -0
- package/dist/server/execute.d.ts.map +1 -0
- package/dist/server/execute.js +625 -0
- package/dist/server/execute.js.map +1 -0
- package/dist/server/execute.remote.test.d.ts +2 -0
- package/dist/server/execute.remote.test.d.ts.map +1 -0
- package/dist/server/execute.remote.test.js +377 -0
- package/dist/server/execute.remote.test.js.map +1 -0
- package/dist/server/index.d.ts +9 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +67 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/parse.d.ts +39 -0
- package/dist/server/parse.d.ts.map +1 -0
- package/dist/server/parse.js +268 -0
- package/dist/server/parse.js.map +1 -0
- package/dist/server/parse.test.d.ts +2 -0
- package/dist/server/parse.test.d.ts.map +1 -0
- package/dist/server/parse.test.js +166 -0
- package/dist/server/parse.test.js.map +1 -0
- package/dist/server/skills.d.ts +8 -0
- package/dist/server/skills.d.ts.map +1 -0
- package/dist/server/skills.js +69 -0
- package/dist/server/skills.js.map +1 -0
- package/dist/server/test.d.ts +3 -0
- package/dist/server/test.d.ts.map +1 -0
- package/dist/server/test.js +270 -0
- package/dist/server/test.js.map +1 -0
- package/dist/server/utils.d.ts +2 -0
- package/dist/server/utils.d.ts.map +1 -0
- package/dist/server/utils.js +7 -0
- package/dist/server/utils.js.map +1 -0
- package/dist/ui/build-config.d.ts +3 -0
- package/dist/ui/build-config.d.ts.map +1 -0
- package/dist/ui/build-config.js +39 -0
- package/dist/ui/build-config.js.map +1 -0
- package/dist/ui/build-config.test.d.ts +2 -0
- package/dist/ui/build-config.test.d.ts.map +1 -0
- package/dist/ui/build-config.test.js +63 -0
- package/dist/ui/build-config.test.js.map +1 -0
- package/dist/ui/index.d.ts +3 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +3 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/parse-stdout.d.ts +3 -0
- package/dist/ui/parse-stdout.d.ts.map +1 -0
- package/dist/ui/parse-stdout.js +265 -0
- package/dist/ui/parse-stdout.js.map +1 -0
- package/dist/ui/parse-stdout.test.d.ts +2 -0
- package/dist/ui/parse-stdout.test.d.ts.map +1 -0
- package/dist/ui/parse-stdout.test.js +73 -0
- package/dist/ui/parse-stdout.test.js.map +1 -0
- package/package.json +58 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { runChildProcess } from "@tickernelz/paperclip-pro-adapter-utils/server-utils";
|
|
6
|
+
// Every test in this file needs a real teardown, so the mock below delegates
|
|
7
|
+
// to the actual factory by default. Only the wiring test further down reads
|
|
8
|
+
// the call arguments; it does not change this behavior.
|
|
9
|
+
const mockCreateWorkspaceRestoreTeardown = vi.hoisted(() => vi.fn());
|
|
10
|
+
vi.mock("@tickernelz/paperclip-pro-adapter-utils/workspace-restore-teardown", async (importOriginal) => {
|
|
11
|
+
const actual = await importOriginal();
|
|
12
|
+
mockCreateWorkspaceRestoreTeardown.mockImplementation(actual.createWorkspaceRestoreTeardown);
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
createWorkspaceRestoreTeardown: mockCreateWorkspaceRestoreTeardown,
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
import { buildGeminiAcpConfig, createGeminiAcpExecutor, nodeVersionMeetsGeminiAcpMinimum, resolveGeminiExecutionEngine, resolveGeminiExecutionEngineForRun, testGeminiAcpEnvironment, } from "./acp.js";
|
|
19
|
+
// A local stand-in for a sandbox runner: runs the managed-runtime staging
|
|
20
|
+
// scripts (mkdir/tar/find) as real child processes so the remote ACP lane can
|
|
21
|
+
// be exercised end-to-end against the host filesystem.
|
|
22
|
+
function createLocalSandboxRunner() {
|
|
23
|
+
let counter = 0;
|
|
24
|
+
return {
|
|
25
|
+
execute: async (input) => {
|
|
26
|
+
counter += 1;
|
|
27
|
+
const command = input.command === "bash" ? "/bin/bash" : input.command;
|
|
28
|
+
return await runChildProcess(`gemini-acp-sandbox-run-${counter}`, command, input.args ?? [], {
|
|
29
|
+
cwd: input.cwd ?? process.cwd(),
|
|
30
|
+
env: input.env ?? {},
|
|
31
|
+
stdin: input.stdin,
|
|
32
|
+
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
|
|
33
|
+
graceSec: 5,
|
|
34
|
+
onLog: input.onLog ?? (async () => { }),
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const tempRoots = [];
|
|
40
|
+
const originalNodeVersion = process.version;
|
|
41
|
+
const originalPath = process.env.PATH;
|
|
42
|
+
const originalHome = process.env.HOME;
|
|
43
|
+
const originalGeminiApiKey = process.env.GEMINI_API_KEY;
|
|
44
|
+
function setNodeVersion(version) {
|
|
45
|
+
Object.defineProperty(process, "version", {
|
|
46
|
+
configurable: true,
|
|
47
|
+
enumerable: true,
|
|
48
|
+
value: version,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
setNodeVersion(originalNodeVersion);
|
|
53
|
+
if (originalPath === undefined)
|
|
54
|
+
delete process.env.PATH;
|
|
55
|
+
else
|
|
56
|
+
process.env.PATH = originalPath;
|
|
57
|
+
if (originalHome === undefined)
|
|
58
|
+
delete process.env.HOME;
|
|
59
|
+
else
|
|
60
|
+
process.env.HOME = originalHome;
|
|
61
|
+
if (originalGeminiApiKey === undefined)
|
|
62
|
+
delete process.env.GEMINI_API_KEY;
|
|
63
|
+
else
|
|
64
|
+
process.env.GEMINI_API_KEY = originalGeminiApiKey;
|
|
65
|
+
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
|
|
66
|
+
});
|
|
67
|
+
class FakeRuntime {
|
|
68
|
+
options;
|
|
69
|
+
events;
|
|
70
|
+
terminal;
|
|
71
|
+
ensureInputs = [];
|
|
72
|
+
startInputs = [];
|
|
73
|
+
closeInputs = [];
|
|
74
|
+
setConfigInputs = [];
|
|
75
|
+
ensureCount = 0;
|
|
76
|
+
constructor(options, events = [
|
|
77
|
+
{ type: "text_delta", text: "hello", stream: "output", tag: "agent_message_chunk" },
|
|
78
|
+
], terminal = { status: "completed", stopReason: "end_turn" }) {
|
|
79
|
+
this.options = options;
|
|
80
|
+
this.events = events;
|
|
81
|
+
this.terminal = terminal;
|
|
82
|
+
}
|
|
83
|
+
async ensureSession(input) {
|
|
84
|
+
this.ensureInputs.push(input);
|
|
85
|
+
this.ensureCount += 1;
|
|
86
|
+
return {
|
|
87
|
+
sessionKey: input.sessionKey,
|
|
88
|
+
backend: "acpx",
|
|
89
|
+
runtimeSessionName: `runtime-${this.ensureCount}`,
|
|
90
|
+
cwd: input.cwd,
|
|
91
|
+
acpxRecordId: `record-${this.ensureCount}`,
|
|
92
|
+
backendSessionId: `acp-${this.ensureCount}`,
|
|
93
|
+
agentSessionId: `agent-${this.ensureCount}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
startTurn(input) {
|
|
97
|
+
this.startInputs.push(input);
|
|
98
|
+
const events = this.events;
|
|
99
|
+
const terminal = this.terminal;
|
|
100
|
+
return {
|
|
101
|
+
requestId: input.requestId,
|
|
102
|
+
events: {
|
|
103
|
+
[Symbol.asyncIterator]: async function* () {
|
|
104
|
+
for (const event of events)
|
|
105
|
+
yield event;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
result: Promise.resolve(terminal),
|
|
109
|
+
cancel: async () => { },
|
|
110
|
+
closeStream: async () => { },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
runTurn() {
|
|
114
|
+
throw new Error("not used");
|
|
115
|
+
}
|
|
116
|
+
getCapabilities() {
|
|
117
|
+
return { controls: [] };
|
|
118
|
+
}
|
|
119
|
+
getStatus() {
|
|
120
|
+
return Promise.resolve({});
|
|
121
|
+
}
|
|
122
|
+
async setConfigOption(input) {
|
|
123
|
+
this.setConfigInputs.push(input);
|
|
124
|
+
}
|
|
125
|
+
async setMode() { }
|
|
126
|
+
async cancel() { }
|
|
127
|
+
async close(input) {
|
|
128
|
+
this.closeInputs.push(input);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function makeTempRoot(prefix) {
|
|
132
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
133
|
+
tempRoots.push(root);
|
|
134
|
+
return root;
|
|
135
|
+
}
|
|
136
|
+
function buildContext(root, overrides = {}) {
|
|
137
|
+
return {
|
|
138
|
+
runId: "run-1",
|
|
139
|
+
agent: {
|
|
140
|
+
id: "agent-1",
|
|
141
|
+
companyId: "company-1",
|
|
142
|
+
name: "Gemini ACP",
|
|
143
|
+
adapterType: "gemini_local",
|
|
144
|
+
adapterConfig: {},
|
|
145
|
+
},
|
|
146
|
+
runtime: {
|
|
147
|
+
sessionId: null,
|
|
148
|
+
sessionParams: null,
|
|
149
|
+
sessionDisplayId: null,
|
|
150
|
+
taskKey: "PAP-1",
|
|
151
|
+
},
|
|
152
|
+
config: {
|
|
153
|
+
engine: "acp",
|
|
154
|
+
cwd: root,
|
|
155
|
+
stateDir: path.join(root, "state"),
|
|
156
|
+
command: "fake-gemini",
|
|
157
|
+
promptTemplate: "Do the assigned work.",
|
|
158
|
+
},
|
|
159
|
+
context: {
|
|
160
|
+
issueId: "issue-1",
|
|
161
|
+
paperclipTaskMarkdown: "Task context",
|
|
162
|
+
paperclipWorkspace: {
|
|
163
|
+
cwd: root,
|
|
164
|
+
source: "project_workspace",
|
|
165
|
+
workspaceId: "workspace-1",
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
onLog: async () => { },
|
|
169
|
+
...overrides,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
describe("gemini_local ACP lane", () => {
|
|
173
|
+
it("maps Gemini config to the ACPX Gemini target", () => {
|
|
174
|
+
expect(buildGeminiAcpConfig({
|
|
175
|
+
engine: "acp",
|
|
176
|
+
cwd: "/repo",
|
|
177
|
+
model: "gemini-2.5-pro",
|
|
178
|
+
command: "/opt/gemini",
|
|
179
|
+
warmHandleIdleMs: 25,
|
|
180
|
+
})).toMatchObject({
|
|
181
|
+
agent: "gemini",
|
|
182
|
+
cwd: "/repo",
|
|
183
|
+
model: "gemini-2.5-pro",
|
|
184
|
+
agentCommand: "/opt/gemini --acp",
|
|
185
|
+
mode: "persistent",
|
|
186
|
+
permissionMode: "approve-all",
|
|
187
|
+
nonInteractivePermissions: "deny",
|
|
188
|
+
warmHandleIdleMs: 25,
|
|
189
|
+
});
|
|
190
|
+
expect(buildGeminiAcpConfig({ engine: "acp", model: "auto" })).not.toHaveProperty("model");
|
|
191
|
+
expect(buildGeminiAcpConfig({ engine: "acp", agentCommand: "custom-gemini-acp" })).toMatchObject({
|
|
192
|
+
agentCommand: "custom-gemini-acp",
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
it("checks the Node version required by the Gemini ACP runtime", () => {
|
|
196
|
+
setNodeVersion("v24.10.0");
|
|
197
|
+
expect(nodeVersionMeetsGeminiAcpMinimum()).toBe(false);
|
|
198
|
+
setNodeVersion("v24.11.0");
|
|
199
|
+
expect(nodeVersionMeetsGeminiAcpMinimum()).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
it("keeps ACP selected and reports unavailable prerequisites for default and explicit engines", async () => {
|
|
202
|
+
const root = await makeTempRoot("paperclip-gemini-acp-default-");
|
|
203
|
+
const commandPath = path.join(root, "bin", "gemini");
|
|
204
|
+
await fs.mkdir(path.dirname(commandPath), { recursive: true });
|
|
205
|
+
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
|
|
206
|
+
setNodeVersion("v24.11.0");
|
|
207
|
+
expect(resolveGeminiExecutionEngine({})).toEqual({ engine: "acp", explicit: false });
|
|
208
|
+
await expect(resolveGeminiExecutionEngineForRun({
|
|
209
|
+
config: { command: commandPath },
|
|
210
|
+
executionTarget: null,
|
|
211
|
+
})).resolves.toEqual({ engine: "acp", explicit: false });
|
|
212
|
+
await expect(resolveGeminiExecutionEngineForRun({
|
|
213
|
+
config: { engine: "cli", command: commandPath },
|
|
214
|
+
executionTarget: null,
|
|
215
|
+
})).resolves.toEqual({ engine: "cli", explicit: true });
|
|
216
|
+
expect(resolveGeminiExecutionEngine({ engine: "acp" })).toEqual({
|
|
217
|
+
engine: "acp",
|
|
218
|
+
explicit: true,
|
|
219
|
+
});
|
|
220
|
+
setNodeVersion("v24.10.0");
|
|
221
|
+
await expect(resolveGeminiExecutionEngineForRun({
|
|
222
|
+
config: { command: commandPath },
|
|
223
|
+
executionTarget: null,
|
|
224
|
+
})).resolves.toMatchObject({
|
|
225
|
+
engine: "acp",
|
|
226
|
+
explicit: false,
|
|
227
|
+
unavailableReason: expect.stringContaining("Node"),
|
|
228
|
+
});
|
|
229
|
+
await expect(resolveGeminiExecutionEngineForRun({
|
|
230
|
+
config: { engine: "acp", command: "/missing/gemini" },
|
|
231
|
+
executionTarget: null,
|
|
232
|
+
})).resolves.toMatchObject({ engine: "acp", explicit: true, unavailableReason: expect.stringContaining("Node") });
|
|
233
|
+
});
|
|
234
|
+
it("reports unavailable ACP for non-sandbox remote auto runs", async () => {
|
|
235
|
+
setNodeVersion("v24.11.0");
|
|
236
|
+
await expect(resolveGeminiExecutionEngineForRun({
|
|
237
|
+
config: { agentCommand: "gemini --acp" },
|
|
238
|
+
executionTarget: {
|
|
239
|
+
kind: "remote",
|
|
240
|
+
transport: "ssh",
|
|
241
|
+
remoteCwd: "/work",
|
|
242
|
+
spec: {
|
|
243
|
+
host: "127.0.0.1",
|
|
244
|
+
port: 22,
|
|
245
|
+
username: "fixture",
|
|
246
|
+
remoteCwd: "/work",
|
|
247
|
+
remoteWorkspacePath: "/work",
|
|
248
|
+
privateKey: null,
|
|
249
|
+
knownHosts: null,
|
|
250
|
+
strictHostKeyChecking: true,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
})).resolves.toMatchObject({
|
|
254
|
+
engine: "acp",
|
|
255
|
+
explicit: false,
|
|
256
|
+
unavailableReason: expect.stringContaining("sandbox remote targets only"),
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
it("reports unavailable ACP for one-shot sandbox auto runs", async () => {
|
|
260
|
+
setNodeVersion("v24.11.0");
|
|
261
|
+
await expect(resolveGeminiExecutionEngineForRun({
|
|
262
|
+
config: {},
|
|
263
|
+
executionTarget: {
|
|
264
|
+
kind: "remote",
|
|
265
|
+
transport: "sandbox",
|
|
266
|
+
providerKey: "fake-plugin",
|
|
267
|
+
remoteCwd: "/work",
|
|
268
|
+
},
|
|
269
|
+
})).resolves.toMatchObject({
|
|
270
|
+
engine: "acp",
|
|
271
|
+
explicit: false,
|
|
272
|
+
unavailableReason: expect.stringContaining("bidirectional remote process"),
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => {
|
|
276
|
+
setNodeVersion("v24.11.0");
|
|
277
|
+
await expect(resolveGeminiExecutionEngineForRun({
|
|
278
|
+
config: { agentCommand: "gemini --acp" },
|
|
279
|
+
executionTarget: {
|
|
280
|
+
kind: "remote",
|
|
281
|
+
transport: "sandbox",
|
|
282
|
+
providerKey: "fake-plugin",
|
|
283
|
+
remoteCwd: "/work",
|
|
284
|
+
runner: {
|
|
285
|
+
execute: async () => ({
|
|
286
|
+
exitCode: 0,
|
|
287
|
+
signal: null,
|
|
288
|
+
timedOut: false,
|
|
289
|
+
stdout: "",
|
|
290
|
+
stderr: "",
|
|
291
|
+
pid: null,
|
|
292
|
+
startedAt: new Date().toISOString(),
|
|
293
|
+
}),
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
})).resolves.toEqual({
|
|
297
|
+
engine: "acp",
|
|
298
|
+
explicit: false,
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
it("executes Gemini through the shared ACP runtime", async () => {
|
|
302
|
+
const root = await makeTempRoot("paperclip-gemini-acp-run-");
|
|
303
|
+
process.env.HOME = path.join(root, "home");
|
|
304
|
+
const runtime = new FakeRuntime({});
|
|
305
|
+
const metas = [];
|
|
306
|
+
const logs = [];
|
|
307
|
+
const execute = createGeminiAcpExecutor({
|
|
308
|
+
createRuntime: (options) => {
|
|
309
|
+
Object.assign(runtime.options, options);
|
|
310
|
+
return runtime;
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
const result = await execute(buildContext(root, {
|
|
314
|
+
onMeta: async (meta) => {
|
|
315
|
+
metas.push(meta);
|
|
316
|
+
},
|
|
317
|
+
onLog: async (stream, text) => {
|
|
318
|
+
logs.push({ stream, text });
|
|
319
|
+
},
|
|
320
|
+
}));
|
|
321
|
+
expect(runtime.ensureInputs[0]).toMatchObject({
|
|
322
|
+
agent: "gemini",
|
|
323
|
+
mode: "persistent",
|
|
324
|
+
cwd: root,
|
|
325
|
+
});
|
|
326
|
+
expect(runtime.startInputs[0]?.text).toContain("Do the assigned work.");
|
|
327
|
+
expect(result).toMatchObject({
|
|
328
|
+
exitCode: 0,
|
|
329
|
+
provider: "acpx",
|
|
330
|
+
sessionId: "acp-1",
|
|
331
|
+
sessionDisplayId: "agent-1",
|
|
332
|
+
summary: "hello",
|
|
333
|
+
});
|
|
334
|
+
expect(result.sessionParams).toMatchObject({
|
|
335
|
+
agent: "gemini",
|
|
336
|
+
acpSessionId: "acp-1",
|
|
337
|
+
cwd: root,
|
|
338
|
+
});
|
|
339
|
+
expect(metas[0]).toMatchObject({
|
|
340
|
+
adapterType: "gemini_local",
|
|
341
|
+
command: "fake-gemini --acp",
|
|
342
|
+
});
|
|
343
|
+
expect(logs.some((entry) => entry.text.includes("\"type\":\"acpx.session\""))).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
it("creates the ACP session on the in-sandbox workspace cwd for runner-backed remote runs", async () => {
|
|
346
|
+
const root = await makeTempRoot("paperclip-gemini-acp-remote-cwd-");
|
|
347
|
+
process.env.HOME = path.join(root, "home");
|
|
348
|
+
const localCwd = path.join(root, "worktree");
|
|
349
|
+
const remoteCwd = path.join(root, "remote-workspace");
|
|
350
|
+
await fs.mkdir(localCwd, { recursive: true });
|
|
351
|
+
await fs.mkdir(remoteCwd, { recursive: true });
|
|
352
|
+
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
|
353
|
+
const runtime = new FakeRuntime({});
|
|
354
|
+
const execute = createGeminiAcpExecutor({
|
|
355
|
+
createRuntime: (options) => {
|
|
356
|
+
Object.assign(runtime.options, options);
|
|
357
|
+
return runtime;
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
const result = await execute(buildContext(localCwd, {
|
|
361
|
+
config: {
|
|
362
|
+
engine: "acp",
|
|
363
|
+
cwd: localCwd,
|
|
364
|
+
// Throwaway ACP command so the process-session bridge does not require
|
|
365
|
+
// a real gemini binary in the local sandbox stand-in.
|
|
366
|
+
agentCommand: "node ./fake-acp.js",
|
|
367
|
+
stateDir: path.join(root, "state"),
|
|
368
|
+
promptTemplate: "Do the assigned work.",
|
|
369
|
+
},
|
|
370
|
+
context: {
|
|
371
|
+
issueId: "issue-1",
|
|
372
|
+
paperclipTaskMarkdown: "Task context",
|
|
373
|
+
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
374
|
+
},
|
|
375
|
+
executionTarget: {
|
|
376
|
+
kind: "remote",
|
|
377
|
+
transport: "sandbox",
|
|
378
|
+
providerKey: "fake-plugin",
|
|
379
|
+
remoteCwd,
|
|
380
|
+
runner: createLocalSandboxRunner(),
|
|
381
|
+
},
|
|
382
|
+
authToken: "real-run-jwt",
|
|
383
|
+
}));
|
|
384
|
+
expect(result.exitCode).toBe(0);
|
|
385
|
+
await expect(fs.readFile(path.join(remoteCwd, "hello.txt"), "utf8")).resolves.toBe("hi");
|
|
386
|
+
expect(runtime.ensureInputs[0]?.cwd).toBe(remoteCwd);
|
|
387
|
+
expect(runtime.ensureInputs[0]?.cwd).not.toBe(localCwd);
|
|
388
|
+
});
|
|
389
|
+
it("seeds the managed Gemini home into the sandbox, repoints HOME, and keeps the key file-only", async () => {
|
|
390
|
+
const root = await makeTempRoot("paperclip-gemini-acp-home-seed-");
|
|
391
|
+
const localCwd = path.join(root, "worktree");
|
|
392
|
+
const remoteCwd = path.join(root, "remote-workspace");
|
|
393
|
+
const hostHome = path.join(root, "home");
|
|
394
|
+
await fs.mkdir(localCwd, { recursive: true });
|
|
395
|
+
await fs.mkdir(remoteCwd, { recursive: true });
|
|
396
|
+
// A selected skill so the shipped skills asset has content to seed.
|
|
397
|
+
const skillSource = path.join(root, "skills", "review");
|
|
398
|
+
await fs.mkdir(skillSource, { recursive: true });
|
|
399
|
+
await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\n---\nUse the review skill.\n", "utf8");
|
|
400
|
+
// The credential is delivered through the adapter-config env — the run env the
|
|
401
|
+
// seam forwards into the sandbox — so pre-selecting api-key auth is backed by a
|
|
402
|
+
// credential that is actually available in-sandbox. A stray host-only key must
|
|
403
|
+
// NOT be relied on, so we clear it to prove the selection comes from the run env.
|
|
404
|
+
const SECRET_KEY = "AIza-secret-key-value-SENTINEL";
|
|
405
|
+
process.env.HOME = hostHome;
|
|
406
|
+
delete process.env.GEMINI_API_KEY;
|
|
407
|
+
const meta = [];
|
|
408
|
+
const runtime = new FakeRuntime({});
|
|
409
|
+
const execute = createGeminiAcpExecutor({
|
|
410
|
+
createRuntime: (options) => {
|
|
411
|
+
Object.assign(runtime.options, options);
|
|
412
|
+
return runtime;
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
const result = await execute(buildContext(localCwd, {
|
|
416
|
+
config: {
|
|
417
|
+
engine: "acp",
|
|
418
|
+
cwd: localCwd,
|
|
419
|
+
agentCommand: "node ./fake-acp.js",
|
|
420
|
+
stateDir: path.join(root, "state"),
|
|
421
|
+
// Drive resolveGeminiSkillsHome to a temp home so the engine prepares
|
|
422
|
+
// skills off the real ~/.gemini, and deliver the key via config env.
|
|
423
|
+
env: { HOME: hostHome, GEMINI_API_KEY: SECRET_KEY },
|
|
424
|
+
promptTemplate: "Do the assigned work.",
|
|
425
|
+
paperclipRuntimeSkills: [{ key: "company/review", runtimeName: "review", source: skillSource }],
|
|
426
|
+
paperclipSkillSync: { desiredSkills: ["company/review"] },
|
|
427
|
+
},
|
|
428
|
+
context: {
|
|
429
|
+
issueId: "issue-1",
|
|
430
|
+
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
431
|
+
},
|
|
432
|
+
executionTarget: {
|
|
433
|
+
kind: "remote",
|
|
434
|
+
transport: "sandbox",
|
|
435
|
+
providerKey: "fake-plugin",
|
|
436
|
+
remoteCwd,
|
|
437
|
+
runner: createLocalSandboxRunner(),
|
|
438
|
+
},
|
|
439
|
+
authToken: "real-run-jwt",
|
|
440
|
+
onMeta: async (payload) => {
|
|
441
|
+
meta.push(payload);
|
|
442
|
+
},
|
|
443
|
+
}));
|
|
444
|
+
expect(result.exitCode).toBe(0);
|
|
445
|
+
const remappedHome = String(meta[0]?.env?.HOME ?? "");
|
|
446
|
+
// C2 — HOME repointed onto the in-sandbox managed runtime root, distinct from
|
|
447
|
+
// the host home.
|
|
448
|
+
expect(remappedHome).not.toBe(hostHome);
|
|
449
|
+
expect(remappedHome).toContain(".paperclip-runtime");
|
|
450
|
+
// Seeded: skills copied into $HOME/.gemini/skills (local runner = host FS).
|
|
451
|
+
await expect(fs.readFile(path.join(remappedHome, ".gemini", "skills", "review", "SKILL.md"), "utf8")).resolves.toContain("review skill");
|
|
452
|
+
// settings.json pre-selects api-key auth but carries no key bytes.
|
|
453
|
+
const settingsRaw = await fs.readFile(path.join(remappedHome, ".gemini", "settings.json"), "utf8");
|
|
454
|
+
expect(settingsRaw).toContain("gemini-api-key");
|
|
455
|
+
expect(settingsRaw).not.toContain(SECRET_KEY);
|
|
456
|
+
// The selector is backed by a credential the sandbox actually receives: the key
|
|
457
|
+
// rides in the forwarded run env (that is how it reaches the sandbox). The
|
|
458
|
+
// invocation meta redacts the value for logging, so it is present but never the
|
|
459
|
+
// raw bytes; the settings.json selector above proves the seam saw it in-env.
|
|
460
|
+
expect(meta[0]?.env?.GEMINI_API_KEY).toBeDefined();
|
|
461
|
+
expect(meta[0]?.env?.GEMINI_API_KEY).not.toBe(SECRET_KEY);
|
|
462
|
+
// C4 — no XDG_* variable introduced for credential discovery.
|
|
463
|
+
expect(Object.keys(meta[0]?.env ?? {}).filter((key) => key.startsWith("XDG_"))).toEqual([]);
|
|
464
|
+
});
|
|
465
|
+
it("test_gemini_acp_seam_registers_workspace_sync_back", async () => {
|
|
466
|
+
const root = await makeTempRoot("paperclip-gemini-acp-syncback-");
|
|
467
|
+
const localCwd = path.join(root, "worktree");
|
|
468
|
+
const remoteCwd = path.join(root, "remote-workspace");
|
|
469
|
+
await fs.mkdir(localCwd, { recursive: true });
|
|
470
|
+
await fs.mkdir(remoteCwd, { recursive: true });
|
|
471
|
+
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
|
472
|
+
// The runtime writes a NEW file into the in-sandbox workspace during the turn.
|
|
473
|
+
// The seam must register a workspace sync-back teardown, so the file lands in
|
|
474
|
+
// the host worktree after the run.
|
|
475
|
+
const runtime = new FakeRuntime({});
|
|
476
|
+
const startTurn = runtime.startTurn.bind(runtime);
|
|
477
|
+
runtime.startTurn = (input) => {
|
|
478
|
+
const turn = startTurn(input);
|
|
479
|
+
const remoteWorkspaceCwd = input.handle.cwd ?? remoteCwd;
|
|
480
|
+
return {
|
|
481
|
+
...turn,
|
|
482
|
+
result: (async () => {
|
|
483
|
+
await fs.writeFile(path.join(remoteWorkspaceCwd, "from-sandbox.txt"), "synced", "utf8");
|
|
484
|
+
return await turn.result;
|
|
485
|
+
})(),
|
|
486
|
+
};
|
|
487
|
+
};
|
|
488
|
+
const execute = createGeminiAcpExecutor({
|
|
489
|
+
createRuntime: (options) => {
|
|
490
|
+
Object.assign(runtime.options, options);
|
|
491
|
+
return runtime;
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
const result = await execute(buildContext(localCwd, {
|
|
495
|
+
config: {
|
|
496
|
+
engine: "acp",
|
|
497
|
+
cwd: localCwd,
|
|
498
|
+
agentCommand: "node ./fake-acp.js",
|
|
499
|
+
stateDir: path.join(root, "state"),
|
|
500
|
+
promptTemplate: "Do the assigned work.",
|
|
501
|
+
},
|
|
502
|
+
context: {
|
|
503
|
+
issueId: "issue-1",
|
|
504
|
+
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
505
|
+
},
|
|
506
|
+
executionTarget: {
|
|
507
|
+
kind: "remote",
|
|
508
|
+
transport: "sandbox",
|
|
509
|
+
providerKey: "fake-plugin",
|
|
510
|
+
remoteCwd,
|
|
511
|
+
runner: createLocalSandboxRunner(),
|
|
512
|
+
},
|
|
513
|
+
authToken: "real-run-jwt",
|
|
514
|
+
}));
|
|
515
|
+
expect(result.exitCode).toBe(0);
|
|
516
|
+
// The teardown fired `restoreWorkspace`, so the sandbox-authored file is now
|
|
517
|
+
// in the host worktree.
|
|
518
|
+
await expect(fs.readFile(path.join(localCwd, "from-sandbox.txt"), "utf8")).resolves.toBe("synced");
|
|
519
|
+
});
|
|
520
|
+
it("passes the Gemini-specific teardown messages to the shared workspace-restore-teardown factory", async () => {
|
|
521
|
+
// Wiring test only: `createWorkspaceRestoreTeardown` owns the
|
|
522
|
+
// classify-and-redact contract, proven once for all three adapters by its
|
|
523
|
+
// own table-driven test in `packages/adapter-utils`. This test proves only
|
|
524
|
+
// that the Gemini adapter passes its own two message strings to it.
|
|
525
|
+
// Clear the shared, hoisted mock first: earlier tests in this file also
|
|
526
|
+
// call through it, and a leftover call could hide a real wiring bug.
|
|
527
|
+
mockCreateWorkspaceRestoreTeardown.mockClear();
|
|
528
|
+
const root = await makeTempRoot("paperclip-gemini-acp-teardown-wiring-");
|
|
529
|
+
const localCwd = path.join(root, "worktree");
|
|
530
|
+
const remoteCwd = path.join(root, "remote-workspace");
|
|
531
|
+
await fs.mkdir(localCwd, { recursive: true });
|
|
532
|
+
await fs.mkdir(remoteCwd, { recursive: true });
|
|
533
|
+
const execute = createGeminiAcpExecutor({
|
|
534
|
+
createRuntime: (options) => new FakeRuntime(options),
|
|
535
|
+
});
|
|
536
|
+
const result = await execute(buildContext(localCwd, {
|
|
537
|
+
config: {
|
|
538
|
+
engine: "acp",
|
|
539
|
+
cwd: localCwd,
|
|
540
|
+
agentCommand: "node ./fake-acp.js",
|
|
541
|
+
stateDir: path.join(root, "state"),
|
|
542
|
+
promptTemplate: "Do the assigned work.",
|
|
543
|
+
},
|
|
544
|
+
context: {
|
|
545
|
+
issueId: "issue-1",
|
|
546
|
+
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
547
|
+
},
|
|
548
|
+
executionTarget: {
|
|
549
|
+
kind: "remote",
|
|
550
|
+
transport: "sandbox",
|
|
551
|
+
providerKey: "fake-plugin",
|
|
552
|
+
remoteCwd,
|
|
553
|
+
runner: createLocalSandboxRunner(),
|
|
554
|
+
},
|
|
555
|
+
authToken: "real-run-jwt",
|
|
556
|
+
}));
|
|
557
|
+
expect(result.exitCode).toBe(0);
|
|
558
|
+
expect(mockCreateWorkspaceRestoreTeardown).toHaveBeenCalledWith(expect.objectContaining({
|
|
559
|
+
startMessage: "[paperclip] Restoring workspace changes from the sandbox.\n",
|
|
560
|
+
failurePrefix: "[paperclip] Gemini ACP teardown workspace restore failed",
|
|
561
|
+
}));
|
|
562
|
+
});
|
|
563
|
+
it("does not persist an api-key auth selector from a host-only credential", async () => {
|
|
564
|
+
const root = await makeTempRoot("paperclip-gemini-acp-hostkey-");
|
|
565
|
+
const localCwd = path.join(root, "worktree");
|
|
566
|
+
const remoteCwd = path.join(root, "remote-workspace");
|
|
567
|
+
const hostHome = path.join(root, "home");
|
|
568
|
+
await fs.mkdir(localCwd, { recursive: true });
|
|
569
|
+
await fs.mkdir(remoteCwd, { recursive: true });
|
|
570
|
+
// The key exists ONLY in the host process env — never in the adapter-config env
|
|
571
|
+
// — so the remote sandbox (which does not inherit the host environment) will not
|
|
572
|
+
// receive it. Selecting api-key auth off this host-only signal would start
|
|
573
|
+
// headless Gemini with a credential it cannot see and fail authentication, so
|
|
574
|
+
// the seam must NOT persist a selector here.
|
|
575
|
+
const SECRET_KEY = "AIza-host-only-key-SENTINEL";
|
|
576
|
+
process.env.HOME = hostHome;
|
|
577
|
+
process.env.GEMINI_API_KEY = SECRET_KEY;
|
|
578
|
+
const meta = [];
|
|
579
|
+
const runtime = new FakeRuntime({});
|
|
580
|
+
const execute = createGeminiAcpExecutor({
|
|
581
|
+
createRuntime: (options) => {
|
|
582
|
+
Object.assign(runtime.options, options);
|
|
583
|
+
return runtime;
|
|
584
|
+
},
|
|
585
|
+
});
|
|
586
|
+
const result = await execute(buildContext(localCwd, {
|
|
587
|
+
config: {
|
|
588
|
+
engine: "acp",
|
|
589
|
+
cwd: localCwd,
|
|
590
|
+
agentCommand: "node ./fake-acp.js",
|
|
591
|
+
stateDir: path.join(root, "state"),
|
|
592
|
+
env: { HOME: hostHome },
|
|
593
|
+
promptTemplate: "Do the assigned work.",
|
|
594
|
+
},
|
|
595
|
+
context: {
|
|
596
|
+
issueId: "issue-1",
|
|
597
|
+
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
598
|
+
},
|
|
599
|
+
executionTarget: {
|
|
600
|
+
kind: "remote",
|
|
601
|
+
transport: "sandbox",
|
|
602
|
+
providerKey: "fake-plugin",
|
|
603
|
+
remoteCwd,
|
|
604
|
+
runner: createLocalSandboxRunner(),
|
|
605
|
+
},
|
|
606
|
+
authToken: "real-run-jwt",
|
|
607
|
+
onMeta: async (payload) => {
|
|
608
|
+
meta.push(payload);
|
|
609
|
+
},
|
|
610
|
+
}));
|
|
611
|
+
expect(result.exitCode).toBe(0);
|
|
612
|
+
const remappedHome = String(meta[0]?.env?.HOME ?? "");
|
|
613
|
+
expect(remappedHome).toContain(".paperclip-runtime");
|
|
614
|
+
// No settings.json auth selector is written, because a host-only key is not a
|
|
615
|
+
// reliable in-sandbox credential signal.
|
|
616
|
+
await expect(fs.readFile(path.join(remappedHome, ".gemini", "settings.json"), "utf8")).rejects.toThrow();
|
|
617
|
+
// And the host-only key never leaks into the forwarded run env.
|
|
618
|
+
for (const value of Object.values(meta[0]?.env ?? {})) {
|
|
619
|
+
expect(String(value)).not.toContain(SECRET_KEY);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
it("reports unavailable ACP for a runner-less sandbox even when the ACP command is set", async () => {
|
|
623
|
+
setNodeVersion("v24.11.0");
|
|
624
|
+
await expect(resolveGeminiExecutionEngineForRun({
|
|
625
|
+
config: { agentCommand: "gemini --acp" },
|
|
626
|
+
executionTarget: {
|
|
627
|
+
kind: "remote",
|
|
628
|
+
transport: "sandbox",
|
|
629
|
+
providerKey: "fake-plugin",
|
|
630
|
+
remoteCwd: "/work",
|
|
631
|
+
},
|
|
632
|
+
})).resolves.toMatchObject({
|
|
633
|
+
engine: "acp",
|
|
634
|
+
explicit: false,
|
|
635
|
+
unavailableReason: expect.stringContaining("bidirectional remote process"),
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
it("reports Gemini ACP environment readiness", async () => {
|
|
639
|
+
const root = await makeTempRoot("paperclip-gemini-acp-env-");
|
|
640
|
+
const bin = path.join(root, "bin");
|
|
641
|
+
await fs.mkdir(bin, { recursive: true });
|
|
642
|
+
await fs.writeFile(path.join(bin, "gemini"), "#!/usr/bin/env sh\n", "utf8");
|
|
643
|
+
process.env.PATH = `${bin}${path.delimiter}${process.env.PATH ?? ""}`;
|
|
644
|
+
process.env.GEMINI_API_KEY = "test-key";
|
|
645
|
+
setNodeVersion("v24.11.0");
|
|
646
|
+
const result = await testGeminiAcpEnvironment({
|
|
647
|
+
adapterType: "gemini_local",
|
|
648
|
+
companyId: "company-1",
|
|
649
|
+
config: {
|
|
650
|
+
engine: "acp",
|
|
651
|
+
cwd: root,
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
expect(result.status).toBe("pass");
|
|
655
|
+
expect(result.checks).toEqual(expect.arrayContaining([
|
|
656
|
+
expect.objectContaining({ code: "gemini_engine_selected" }),
|
|
657
|
+
expect.objectContaining({ code: "gemini_acp_command_resolvable" }),
|
|
658
|
+
expect.objectContaining({ code: "gemini_acp_credentials_detected" }),
|
|
659
|
+
]));
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
//# sourceMappingURL=acp.test.js.map
|