@tickernelz/paperclip-pro-adapter-claude-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 +141 -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/cli/quota-probe.d.ts +3 -0
- package/dist/cli/quota-probe.d.ts.map +1 -0
- package/dist/cli/quota-probe.js +106 -0
- package/dist/cli/quota-probe.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +90 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +35 -0
- package/dist/index.test.js.map +1 -0
- package/dist/server/acp.auth.test.d.ts +2 -0
- package/dist/server/acp.auth.test.d.ts.map +1 -0
- package/dist/server/acp.auth.test.js +581 -0
- package/dist/server/acp.auth.test.js.map +1 -0
- package/dist/server/acp.d.ts +68 -0
- package/dist/server/acp.d.ts.map +1 -0
- package/dist/server/acp.js +779 -0
- package/dist/server/acp.js.map +1 -0
- package/dist/server/acp.quota.test.d.ts +2 -0
- package/dist/server/acp.quota.test.d.ts.map +1 -0
- package/dist/server/acp.quota.test.js +114 -0
- package/dist/server/acp.quota.test.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 +1153 -0
- package/dist/server/acp.test.js.map +1 -0
- package/dist/server/auth-check.d.ts +8 -0
- package/dist/server/auth-check.d.ts.map +1 -0
- package/dist/server/auth-check.js +8 -0
- package/dist/server/auth-check.js.map +1 -0
- package/dist/server/claude-config.d.ts +49 -0
- package/dist/server/claude-config.d.ts.map +1 -0
- package/dist/server/claude-config.js +279 -0
- package/dist/server/claude-config.js.map +1 -0
- package/dist/server/claude-config.test.d.ts +2 -0
- package/dist/server/claude-config.test.d.ts.map +1 -0
- package/dist/server/claude-config.test.js +197 -0
- package/dist/server/claude-config.test.js.map +1 -0
- package/dist/server/cli-capabilities.d.ts +26 -0
- package/dist/server/cli-capabilities.d.ts.map +1 -0
- package/dist/server/cli-capabilities.js +115 -0
- package/dist/server/cli-capabilities.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 +104 -0
- package/dist/server/execute.acp-fallback.test.js.map +1 -0
- package/dist/server/execute.d.ts +23 -0
- package/dist/server/execute.d.ts.map +1 -0
- package/dist/server/execute.js +1089 -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 +484 -0
- package/dist/server/execute.remote.test.js.map +1 -0
- package/dist/server/index.d.ts +16 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +84 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/models.d.ts +13 -0
- package/dist/server/models.d.ts.map +1 -0
- package/dist/server/models.js +153 -0
- package/dist/server/models.js.map +1 -0
- package/dist/server/parse.d.ts +58 -0
- package/dist/server/parse.d.ts.map +1 -0
- package/dist/server/parse.js +459 -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 +419 -0
- package/dist/server/parse.test.js.map +1 -0
- package/dist/server/permissions.d.ts +14 -0
- package/dist/server/permissions.d.ts.map +1 -0
- package/dist/server/permissions.js +13 -0
- package/dist/server/permissions.js.map +1 -0
- package/dist/server/permissions.test.d.ts +2 -0
- package/dist/server/permissions.test.d.ts.map +1 -0
- package/dist/server/permissions.test.js +22 -0
- package/dist/server/permissions.test.js.map +1 -0
- package/dist/server/probe-diagnostics.d.ts +102 -0
- package/dist/server/probe-diagnostics.d.ts.map +1 -0
- package/dist/server/probe-diagnostics.js +140 -0
- package/dist/server/probe-diagnostics.js.map +1 -0
- package/dist/server/probe-diagnostics.test.d.ts +2 -0
- package/dist/server/probe-diagnostics.test.d.ts.map +1 -0
- package/dist/server/probe-diagnostics.test.js +114 -0
- package/dist/server/probe-diagnostics.test.js.map +1 -0
- package/dist/server/probe-env.d.ts +42 -0
- package/dist/server/probe-env.d.ts.map +1 -0
- package/dist/server/probe-env.js +149 -0
- package/dist/server/probe-env.js.map +1 -0
- package/dist/server/probe-env.test.d.ts +2 -0
- package/dist/server/probe-env.test.d.ts.map +1 -0
- package/dist/server/probe-env.test.js +135 -0
- package/dist/server/probe-env.test.js.map +1 -0
- package/dist/server/probe-redaction.test.d.ts +2 -0
- package/dist/server/probe-redaction.test.d.ts.map +1 -0
- package/dist/server/probe-redaction.test.js +86 -0
- package/dist/server/probe-redaction.test.js.map +1 -0
- package/dist/server/prompt-cache.d.ts +17 -0
- package/dist/server/prompt-cache.d.ts.map +1 -0
- package/dist/server/prompt-cache.js +126 -0
- package/dist/server/prompt-cache.js.map +1 -0
- package/dist/server/quota-keychain.test.d.ts +2 -0
- package/dist/server/quota-keychain.test.d.ts.map +1 -0
- package/dist/server/quota-keychain.test.js +83 -0
- package/dist/server/quota-keychain.test.js.map +1 -0
- package/dist/server/quota.d.ts +30 -0
- package/dist/server/quota.d.ts.map +1 -0
- package/dist/server/quota.js +543 -0
- package/dist/server/quota.js.map +1 -0
- package/dist/server/setup-token-characterization.test.d.ts +2 -0
- package/dist/server/setup-token-characterization.test.d.ts.map +1 -0
- package/dist/server/setup-token-characterization.test.js +123 -0
- package/dist/server/setup-token-characterization.test.js.map +1 -0
- package/dist/server/setup-token-parse.d.ts +47 -0
- package/dist/server/setup-token-parse.d.ts.map +1 -0
- package/dist/server/setup-token-parse.js +506 -0
- package/dist/server/setup-token-parse.js.map +1 -0
- package/dist/server/setup-token-parse.test.d.ts +2 -0
- package/dist/server/setup-token-parse.test.d.ts.map +1 -0
- package/dist/server/setup-token-parse.test.js +587 -0
- package/dist/server/setup-token-parse.test.js.map +1 -0
- package/dist/server/setup-token-runner.d.ts +115 -0
- package/dist/server/setup-token-runner.d.ts.map +1 -0
- package/dist/server/setup-token-runner.js +317 -0
- package/dist/server/setup-token-runner.js.map +1 -0
- package/dist/server/setup-token-runner.test.d.ts +2 -0
- package/dist/server/setup-token-runner.test.d.ts.map +1 -0
- package/dist/server/setup-token-runner.test.js +336 -0
- package/dist/server/setup-token-runner.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 +42 -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 +434 -0
- package/dist/server/test.js.map +1 -0
- package/dist/server/test.probe.test.d.ts +2 -0
- package/dist/server/test.probe.test.d.ts.map +1 -0
- package/dist/server/test.probe.test.js +608 -0
- package/dist/server/test.probe.test.js.map +1 -0
- package/dist/server/test.remote.test.d.ts +2 -0
- package/dist/server/test.remote.test.d.ts.map +1 -0
- package/dist/server/test.remote.test.js +149 -0
- package/dist/server/test.remote.test.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 +76 -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 +68 -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 +153 -0
- package/dist/ui/parse-stdout.js.map +1 -0
- package/package.json +63 -0
- package/skills/agentmail/SKILL.md +70 -0
- package/skills/paperclip/SKILL.md +712 -0
- package/skills/paperclip/references/api-reference.md +1675 -0
- package/skills/paperclip/references/artifacts.md +158 -0
- package/skills/paperclip/references/cases.md +295 -0
- package/skills/paperclip/references/company-skills.md +266 -0
- package/skills/paperclip/references/issue-workspaces.md +80 -0
- package/skills/paperclip/references/routines.md +231 -0
- package/skills/paperclip/references/workflows.md +141 -0
- package/skills/paperclip/scripts/paperclip-upload-artifact.sh +592 -0
- package/skills/paperclip-board/SKILL.md +619 -0
- package/skills/paperclip-converting-plans-to-tasks/SKILL.md +60 -0
- package/skills/paperclip-create-agent/SKILL.md +179 -0
- package/skills/paperclip-create-agent/references/agent-instruction-templates.md +123 -0
- package/skills/paperclip-create-agent/references/agents/coder.md +64 -0
- package/skills/paperclip-create-agent/references/agents/qa.md +88 -0
- package/skills/paperclip-create-agent/references/agents/securityengineer.md +135 -0
- package/skills/paperclip-create-agent/references/agents/uxdesigner.md +115 -0
- package/skills/paperclip-create-agent/references/api-reference.md +110 -0
- package/skills/paperclip-create-agent/references/baseline-role-guide.md +168 -0
- package/skills/paperclip-create-agent/references/draft-review-checklist.md +95 -0
- package/skills/para-memory-files/SKILL.md +100 -0
- package/skills/para-memory-files/references/schemas.md +35 -0
- package/skills/slack/SKILL.md +65 -0
|
@@ -0,0 +1,1153 @@
|
|
|
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
|
+
// Wrap the shared staging seam in a call-recording spy that still delegates to
|
|
7
|
+
// the real implementation (a runner-backed sandbox test exercises it end to
|
|
8
|
+
// end against the local sandbox stand-in). This lets a test assert the exact
|
|
9
|
+
// `assets` the Claude remote managed-home seam sends it without changing any
|
|
10
|
+
// real behavior for the other tests.
|
|
11
|
+
vi.mock("@tickernelz/paperclip-pro-adapter-utils/execution-target", async (importActual) => {
|
|
12
|
+
const actual = await importActual();
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
prepareAdapterExecutionTargetRuntime: vi.fn(actual.prepareAdapterExecutionTargetRuntime),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
import { prepareAdapterExecutionTargetRuntime } from "@tickernelz/paperclip-pro-adapter-utils/execution-target";
|
|
19
|
+
import { buildClaudeAcpConfig, createClaudeAcpExecutor, nodeVersionMeetsClaudeAcpMinimum, resolveClaudeAcpBillingIdentity, resolveClaudeExecutionEngine, resolveClaudeExecutionEngineForRun, testClaudeAcpEnvironment, } from "./acp.js";
|
|
20
|
+
// A local stand-in for a sandbox runner: runs the managed-runtime staging
|
|
21
|
+
// scripts (mkdir/tar/find) as real child processes so the remote ACP lane can
|
|
22
|
+
// be exercised end-to-end against the host filesystem.
|
|
23
|
+
function createLocalSandboxRunner() {
|
|
24
|
+
let counter = 0;
|
|
25
|
+
return {
|
|
26
|
+
execute: async (input) => {
|
|
27
|
+
counter += 1;
|
|
28
|
+
const command = input.command === "bash" ? "/bin/bash" : input.command;
|
|
29
|
+
return await runChildProcess(`claude-acp-sandbox-run-${counter}`, command, input.args ?? [], {
|
|
30
|
+
cwd: input.cwd ?? process.cwd(),
|
|
31
|
+
env: input.env ?? {},
|
|
32
|
+
stdin: input.stdin,
|
|
33
|
+
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
|
|
34
|
+
graceSec: 5,
|
|
35
|
+
onLog: input.onLog ?? (async () => { }),
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const tempRoots = [];
|
|
41
|
+
const originalNodeVersion = process.version;
|
|
42
|
+
const originalEnv = {
|
|
43
|
+
PAPERCLIP_HOME: process.env.PAPERCLIP_HOME,
|
|
44
|
+
PAPERCLIP_INSTANCE_ID: process.env.PAPERCLIP_INSTANCE_ID,
|
|
45
|
+
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
|
46
|
+
};
|
|
47
|
+
function setNodeVersion(version) {
|
|
48
|
+
Object.defineProperty(process, "version", {
|
|
49
|
+
configurable: true,
|
|
50
|
+
enumerable: true,
|
|
51
|
+
value: version,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
setNodeVersion(originalNodeVersion);
|
|
56
|
+
for (const [key, value] of Object.entries(originalEnv)) {
|
|
57
|
+
if (value === undefined)
|
|
58
|
+
delete process.env[key];
|
|
59
|
+
else
|
|
60
|
+
process.env[key] = value;
|
|
61
|
+
}
|
|
62
|
+
// The sandbox process-session bridge writes event files asynchronously; on slow
|
|
63
|
+
// CI shards a final write can race the recursive rm (ENOTEMPTY on the events
|
|
64
|
+
// dir), so let fs.rm retry until the writer has quiesced.
|
|
65
|
+
await Promise.all(tempRoots
|
|
66
|
+
.splice(0)
|
|
67
|
+
.map((root) => fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 })));
|
|
68
|
+
});
|
|
69
|
+
class FakeRuntime {
|
|
70
|
+
options;
|
|
71
|
+
events;
|
|
72
|
+
terminal;
|
|
73
|
+
ensureInputs = [];
|
|
74
|
+
startInputs = [];
|
|
75
|
+
closeInputs = [];
|
|
76
|
+
setConfigInputs = [];
|
|
77
|
+
ensureCount = 0;
|
|
78
|
+
constructor(options, events = [
|
|
79
|
+
{ type: "text_delta", text: "hello", stream: "output", tag: "agent_message_chunk" },
|
|
80
|
+
], terminal = { status: "completed", stopReason: "end_turn" }) {
|
|
81
|
+
this.options = options;
|
|
82
|
+
this.events = events;
|
|
83
|
+
this.terminal = terminal;
|
|
84
|
+
}
|
|
85
|
+
async ensureSession(input) {
|
|
86
|
+
this.ensureInputs.push(input);
|
|
87
|
+
this.ensureCount += 1;
|
|
88
|
+
return {
|
|
89
|
+
sessionKey: input.sessionKey,
|
|
90
|
+
backend: "acpx",
|
|
91
|
+
runtimeSessionName: `runtime-${this.ensureCount}`,
|
|
92
|
+
cwd: input.cwd,
|
|
93
|
+
acpxRecordId: `record-${this.ensureCount}`,
|
|
94
|
+
backendSessionId: `acp-${this.ensureCount}`,
|
|
95
|
+
agentSessionId: `agent-${this.ensureCount}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
startTurn(input) {
|
|
99
|
+
this.startInputs.push(input);
|
|
100
|
+
const events = this.events;
|
|
101
|
+
const terminal = this.terminal;
|
|
102
|
+
return {
|
|
103
|
+
requestId: input.requestId,
|
|
104
|
+
events: {
|
|
105
|
+
[Symbol.asyncIterator]: async function* () {
|
|
106
|
+
for (const event of events)
|
|
107
|
+
yield event;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
result: Promise.resolve(terminal),
|
|
111
|
+
cancel: async () => { },
|
|
112
|
+
closeStream: async () => { },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
runTurn() {
|
|
116
|
+
throw new Error("not used");
|
|
117
|
+
}
|
|
118
|
+
getCapabilities() {
|
|
119
|
+
return { controls: [] };
|
|
120
|
+
}
|
|
121
|
+
getStatus() {
|
|
122
|
+
return Promise.resolve({});
|
|
123
|
+
}
|
|
124
|
+
async setConfigOption(input) {
|
|
125
|
+
this.setConfigInputs.push(input);
|
|
126
|
+
}
|
|
127
|
+
async setMode() { }
|
|
128
|
+
async cancel() { }
|
|
129
|
+
async close(input) {
|
|
130
|
+
this.closeInputs.push(input);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function makeTempRoot(prefix) {
|
|
134
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
135
|
+
tempRoots.push(root);
|
|
136
|
+
return root;
|
|
137
|
+
}
|
|
138
|
+
async function createRuntimeSkill(root) {
|
|
139
|
+
const source = path.join(root, "skills", "review");
|
|
140
|
+
await fs.mkdir(source, { recursive: true });
|
|
141
|
+
await fs.writeFile(path.join(source, "SKILL.md"), "---\n---\nUse the review skill.\n", "utf8");
|
|
142
|
+
return {
|
|
143
|
+
key: "company/review",
|
|
144
|
+
runtimeName: "review",
|
|
145
|
+
source,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function buildContext(root, overrides = {}) {
|
|
149
|
+
return {
|
|
150
|
+
runId: "run-1",
|
|
151
|
+
agent: {
|
|
152
|
+
id: "agent-1",
|
|
153
|
+
companyId: "company-1",
|
|
154
|
+
name: "Claude ACP",
|
|
155
|
+
adapterType: "claude_local",
|
|
156
|
+
adapterConfig: {},
|
|
157
|
+
},
|
|
158
|
+
runtime: {
|
|
159
|
+
sessionId: null,
|
|
160
|
+
sessionParams: null,
|
|
161
|
+
sessionDisplayId: null,
|
|
162
|
+
taskKey: "PAP-1",
|
|
163
|
+
},
|
|
164
|
+
config: {
|
|
165
|
+
engine: "acp",
|
|
166
|
+
cwd: root,
|
|
167
|
+
stateDir: path.join(root, "state"),
|
|
168
|
+
promptTemplate: "Do the assigned work.",
|
|
169
|
+
},
|
|
170
|
+
context: {
|
|
171
|
+
issueId: "issue-1",
|
|
172
|
+
paperclipTaskMarkdown: "Task context",
|
|
173
|
+
paperclipWorkspace: {
|
|
174
|
+
cwd: root,
|
|
175
|
+
source: "project_workspace",
|
|
176
|
+
workspaceId: "workspace-1",
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
onLog: async () => { },
|
|
180
|
+
...overrides,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
describe("claude_local ACP lane", () => {
|
|
184
|
+
it("uses the same default model in ACP startup and session identity", async () => {
|
|
185
|
+
const root = await makeTempRoot("paperclip-claude-acp-default-");
|
|
186
|
+
const meta = [];
|
|
187
|
+
const execute = createClaudeAcpExecutor({
|
|
188
|
+
createRuntime: (options) => new FakeRuntime(options),
|
|
189
|
+
});
|
|
190
|
+
const result = await execute(buildContext(root, {
|
|
191
|
+
onMeta: async (payload) => { meta.push(payload); },
|
|
192
|
+
}));
|
|
193
|
+
expect(result.exitCode).toBe(0);
|
|
194
|
+
expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-opus-5");
|
|
195
|
+
});
|
|
196
|
+
it("keeps ACP model precedence consistent with CLI and provider overrides", () => {
|
|
197
|
+
expect(buildClaudeAcpConfig({ model: "claude-sonnet-4-5", env: { ANTHROPIC_MODEL: "opus" } }))
|
|
198
|
+
.toMatchObject({ model: "claude-sonnet-4-5", env: { ANTHROPIC_MODEL: "claude-sonnet-4-5" } });
|
|
199
|
+
expect(buildClaudeAcpConfig({}, { ANTHROPIC_MODEL: "custom-model" }))
|
|
200
|
+
.toMatchObject({ model: "custom-model", env: { ANTHROPIC_MODEL: "custom-model" } });
|
|
201
|
+
expect(buildClaudeAcpConfig({}, { CLAUDE_CODE_USE_BEDROCK: "1" }).model).toBe("");
|
|
202
|
+
expect(buildClaudeAcpConfig({ env: { CLAUDE_CODE_USE_VERTEX: "1" } }).model).toBe("");
|
|
203
|
+
});
|
|
204
|
+
it("maps Claude config to the ACPX Claude target", () => {
|
|
205
|
+
expect(buildClaudeAcpConfig({
|
|
206
|
+
engine: "acp",
|
|
207
|
+
cwd: "/repo",
|
|
208
|
+
model: "claude-opus-4-7",
|
|
209
|
+
effort: "high",
|
|
210
|
+
agentCommand: "custom-claude-acp",
|
|
211
|
+
warmHandleIdleMs: 25,
|
|
212
|
+
})).toMatchObject({
|
|
213
|
+
agent: "claude",
|
|
214
|
+
cwd: "/repo",
|
|
215
|
+
model: "claude-opus-4-7",
|
|
216
|
+
effort: "high",
|
|
217
|
+
agentCommand: "custom-claude-acp",
|
|
218
|
+
mode: "persistent",
|
|
219
|
+
permissionMode: "approve-all",
|
|
220
|
+
nonInteractivePermissions: "deny",
|
|
221
|
+
warmHandleIdleMs: 25,
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
it("checks the Node version required by the Claude ACP runtime", () => {
|
|
225
|
+
setNodeVersion("v24.10.0");
|
|
226
|
+
expect(nodeVersionMeetsClaudeAcpMinimum()).toBe(false);
|
|
227
|
+
setNodeVersion("v24.11.0");
|
|
228
|
+
expect(nodeVersionMeetsClaudeAcpMinimum()).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
it("keeps ACP selected and reports unavailable prerequisites for default and explicit engines", async () => {
|
|
231
|
+
const root = await makeTempRoot("paperclip-claude-acp-default-");
|
|
232
|
+
const commandPath = path.join(root, "bin", "claude-agent-acp");
|
|
233
|
+
await fs.mkdir(path.dirname(commandPath), { recursive: true });
|
|
234
|
+
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
|
|
235
|
+
setNodeVersion("v24.11.0");
|
|
236
|
+
expect(resolveClaudeExecutionEngine({})).toEqual({ engine: "acp", explicit: false });
|
|
237
|
+
await expect(resolveClaudeExecutionEngineForRun({
|
|
238
|
+
config: { agentCommand: commandPath },
|
|
239
|
+
executionTarget: null,
|
|
240
|
+
})).resolves.toEqual({ engine: "acp", explicit: false });
|
|
241
|
+
await expect(resolveClaudeExecutionEngineForRun({
|
|
242
|
+
config: { engine: "cli", agentCommand: commandPath },
|
|
243
|
+
executionTarget: null,
|
|
244
|
+
})).resolves.toEqual({ engine: "cli", explicit: true });
|
|
245
|
+
setNodeVersion("v24.10.0");
|
|
246
|
+
await expect(resolveClaudeExecutionEngineForRun({
|
|
247
|
+
config: { agentCommand: commandPath },
|
|
248
|
+
executionTarget: null,
|
|
249
|
+
})).resolves.toMatchObject({
|
|
250
|
+
engine: "acp",
|
|
251
|
+
explicit: false,
|
|
252
|
+
unavailableReason: expect.stringContaining("Node"),
|
|
253
|
+
});
|
|
254
|
+
await expect(resolveClaudeExecutionEngineForRun({
|
|
255
|
+
config: { engine: "acp", agentCommand: "/missing/claude-agent-acp" },
|
|
256
|
+
executionTarget: null,
|
|
257
|
+
})).resolves.toMatchObject({ engine: "acp", explicit: true, unavailableReason: expect.stringContaining("Node") });
|
|
258
|
+
});
|
|
259
|
+
it("requires explicit CLI selection for local filesystem or network scope", async () => {
|
|
260
|
+
await expect(resolveClaudeExecutionEngineForRun({
|
|
261
|
+
config: { filesystemScope: "workspace" },
|
|
262
|
+
executionTarget: null,
|
|
263
|
+
})).resolves.toMatchObject({
|
|
264
|
+
engine: "acp",
|
|
265
|
+
explicit: false,
|
|
266
|
+
unavailableReason: expect.stringContaining("confinement"),
|
|
267
|
+
});
|
|
268
|
+
await expect(resolveClaudeExecutionEngineForRun({
|
|
269
|
+
config: { engine: "acp", filesystemScope: "workspace" },
|
|
270
|
+
executionTarget: null,
|
|
271
|
+
})).resolves.toMatchObject({ engine: "acp", unavailableReason: expect.stringContaining("ACP confinement is not supported") });
|
|
272
|
+
await expect(resolveClaudeExecutionEngineForRun({
|
|
273
|
+
config: { networkScope: "deny" },
|
|
274
|
+
executionTarget: null,
|
|
275
|
+
})).resolves.toMatchObject({
|
|
276
|
+
engine: "acp",
|
|
277
|
+
explicit: false,
|
|
278
|
+
unavailableReason: expect.stringContaining("confinement"),
|
|
279
|
+
});
|
|
280
|
+
await expect(resolveClaudeExecutionEngineForRun({
|
|
281
|
+
config: { networkScope: "public" },
|
|
282
|
+
executionTarget: null,
|
|
283
|
+
})).rejects.toThrow('networkScope must be "deny" or "allowlist"');
|
|
284
|
+
});
|
|
285
|
+
it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => {
|
|
286
|
+
setNodeVersion("v24.11.0");
|
|
287
|
+
await expect(resolveClaudeExecutionEngineForRun({
|
|
288
|
+
config: { agentCommand: "claude-agent-acp" },
|
|
289
|
+
executionTarget: {
|
|
290
|
+
kind: "remote",
|
|
291
|
+
transport: "sandbox",
|
|
292
|
+
providerKey: "fake-plugin",
|
|
293
|
+
remoteCwd: "/work",
|
|
294
|
+
runner: {
|
|
295
|
+
execute: async () => ({
|
|
296
|
+
exitCode: 0,
|
|
297
|
+
signal: null,
|
|
298
|
+
timedOut: false,
|
|
299
|
+
stdout: "",
|
|
300
|
+
stderr: "",
|
|
301
|
+
pid: null,
|
|
302
|
+
startedAt: new Date().toISOString(),
|
|
303
|
+
}),
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
})).resolves.toEqual({ engine: "acp", explicit: false });
|
|
307
|
+
});
|
|
308
|
+
it("reports unavailable ACP for one-shot sandbox auto runs", async () => {
|
|
309
|
+
setNodeVersion("v24.11.0");
|
|
310
|
+
await expect(resolveClaudeExecutionEngineForRun({
|
|
311
|
+
config: {},
|
|
312
|
+
executionTarget: {
|
|
313
|
+
kind: "remote",
|
|
314
|
+
transport: "sandbox",
|
|
315
|
+
providerKey: "fake-plugin",
|
|
316
|
+
remoteCwd: "/work",
|
|
317
|
+
},
|
|
318
|
+
})).resolves.toMatchObject({
|
|
319
|
+
engine: "acp",
|
|
320
|
+
explicit: false,
|
|
321
|
+
unavailableReason: expect.stringContaining("bidirectional remote process"),
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
it("reports unavailable ACP for non-sandbox remote auto runs", async () => {
|
|
325
|
+
setNodeVersion("v24.11.0");
|
|
326
|
+
await expect(resolveClaudeExecutionEngineForRun({
|
|
327
|
+
config: {},
|
|
328
|
+
executionTarget: {
|
|
329
|
+
kind: "remote",
|
|
330
|
+
transport: "ssh",
|
|
331
|
+
remoteCwd: "/work",
|
|
332
|
+
spec: {
|
|
333
|
+
host: "127.0.0.1",
|
|
334
|
+
port: 22,
|
|
335
|
+
username: "fixture",
|
|
336
|
+
remoteCwd: "/work",
|
|
337
|
+
remoteWorkspacePath: "/work",
|
|
338
|
+
privateKey: null,
|
|
339
|
+
knownHosts: null,
|
|
340
|
+
strictHostKeyChecking: true,
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
})).resolves.toMatchObject({
|
|
344
|
+
engine: "acp",
|
|
345
|
+
explicit: false,
|
|
346
|
+
unavailableReason: expect.stringContaining("sandbox remote targets only"),
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
it.each([undefined, "/sandbox/configured-workspace"])("checks sandbox directories on the sandbox (configured cwd=%s)", async (configuredCwd) => {
|
|
350
|
+
const remoteCwd = "/sandbox/workspace";
|
|
351
|
+
const mkdir = vi.spyOn(fs, "mkdir").mockRejectedValue(new Error("Host filesystem must not be used"));
|
|
352
|
+
const execute = vi.fn(async () => ({
|
|
353
|
+
exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "",
|
|
354
|
+
pid: null, startedAt: new Date().toISOString(),
|
|
355
|
+
}));
|
|
356
|
+
try {
|
|
357
|
+
const result = await testClaudeAcpEnvironment({
|
|
358
|
+
companyId: "company-1", adapterType: "claude_local",
|
|
359
|
+
config: { cwd: configuredCwd, agentCommand: "claude-agent-acp", env: { ANTHROPIC_API_KEY: "fixture" } },
|
|
360
|
+
executionTarget: { kind: "remote", transport: "sandbox", remoteCwd, runner: { execute } },
|
|
361
|
+
});
|
|
362
|
+
expect(result.status, JSON.stringify(result.checks)).toBe("pass");
|
|
363
|
+
expect(result.checks).toContainEqual(expect.objectContaining({
|
|
364
|
+
code: "claude_acp_cwd_valid", message: `Working directory is valid: ${configuredCwd ?? remoteCwd}`,
|
|
365
|
+
}));
|
|
366
|
+
expect(mkdir).not.toHaveBeenCalled();
|
|
367
|
+
expect(JSON.stringify(execute.mock.calls)).toContain(`mkdir -p '${configuredCwd ?? remoteCwd}'`);
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
mkdir.mockRestore();
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
it("reports ACP prerequisites for the ACP lane", async () => {
|
|
374
|
+
const root = await makeTempRoot("paperclip-claude-acp-env-");
|
|
375
|
+
const commandPath = path.join(root, "bin", "claude-agent-acp");
|
|
376
|
+
await fs.mkdir(path.dirname(commandPath), { recursive: true });
|
|
377
|
+
await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8");
|
|
378
|
+
setNodeVersion("v24.11.0");
|
|
379
|
+
// The ACP lane now verifies auth: without a credential the lane runs a real
|
|
380
|
+
// host login probe, so its status depends on the host login state. Give the
|
|
381
|
+
// config a Bedrock credential to make the auth path deterministic. Bedrock
|
|
382
|
+
// gates off the host login probe, so the result reflects only the ACP
|
|
383
|
+
// prerequisites, and a valid credential lets the lane report a pass.
|
|
384
|
+
const result = await testClaudeAcpEnvironment({
|
|
385
|
+
adapterType: "claude_local",
|
|
386
|
+
companyId: "company-1",
|
|
387
|
+
config: {
|
|
388
|
+
engine: "acp",
|
|
389
|
+
cwd: root,
|
|
390
|
+
agentCommand: commandPath,
|
|
391
|
+
env: { CLAUDE_CODE_USE_BEDROCK: "1" },
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
expect(result.status).toBe("pass");
|
|
395
|
+
expect(result.checks).toContainEqual(expect.objectContaining({
|
|
396
|
+
code: "claude_engine_selected",
|
|
397
|
+
level: "info",
|
|
398
|
+
}));
|
|
399
|
+
expect(result.checks).toContainEqual(expect.objectContaining({
|
|
400
|
+
code: "claude_acp_command_resolvable",
|
|
401
|
+
level: "info",
|
|
402
|
+
}));
|
|
403
|
+
expect(result.checks).toContainEqual(expect.objectContaining({
|
|
404
|
+
code: "claude_acp_bedrock_auth",
|
|
405
|
+
level: "info",
|
|
406
|
+
}));
|
|
407
|
+
expect(result.checks).toContainEqual(expect.objectContaining({
|
|
408
|
+
code: "claude_acp_runtime_scaffold",
|
|
409
|
+
level: "info",
|
|
410
|
+
}));
|
|
411
|
+
});
|
|
412
|
+
it("executes through ACPX with Claude model env, settings.local.json, and ephemeral skills", async () => {
|
|
413
|
+
const root = await makeTempRoot("paperclip-claude-acp-exec-");
|
|
414
|
+
const skill = await createRuntimeSkill(root);
|
|
415
|
+
const runtimes = [];
|
|
416
|
+
const meta = [];
|
|
417
|
+
const execute = createClaudeAcpExecutor({
|
|
418
|
+
createRuntime: (options) => {
|
|
419
|
+
const runtime = new FakeRuntime(options);
|
|
420
|
+
runtimes.push(runtime);
|
|
421
|
+
return runtime;
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
const result = await execute(buildContext(root, {
|
|
425
|
+
config: {
|
|
426
|
+
engine: "acp",
|
|
427
|
+
cwd: root,
|
|
428
|
+
stateDir: path.join(root, "state"),
|
|
429
|
+
model: "claude-opus-4-7",
|
|
430
|
+
effort: "high",
|
|
431
|
+
promptTemplate: "Do the assigned work.",
|
|
432
|
+
paperclipRuntimeSkills: [skill],
|
|
433
|
+
paperclipSkillSync: { desiredSkills: [skill.key] },
|
|
434
|
+
},
|
|
435
|
+
onMeta: async (payload) => {
|
|
436
|
+
meta.push(payload);
|
|
437
|
+
},
|
|
438
|
+
}));
|
|
439
|
+
expect(result.exitCode).toBe(0);
|
|
440
|
+
expect(result.sessionParams).toMatchObject({
|
|
441
|
+
agent: "claude",
|
|
442
|
+
mode: "persistent",
|
|
443
|
+
acpSessionId: "acp-1",
|
|
444
|
+
workspaceId: "workspace-1",
|
|
445
|
+
});
|
|
446
|
+
expect(result.sessionParams?.skills).toMatchObject({
|
|
447
|
+
mode: "claude",
|
|
448
|
+
selectedSkills: ["review"],
|
|
449
|
+
});
|
|
450
|
+
const skillRoot = (result.sessionParams?.skills).skillRoot;
|
|
451
|
+
expect(skillRoot).toBeTruthy();
|
|
452
|
+
await expect(fs.readFile(path.join(skillRoot, "review", "SKILL.md"), "utf8")).resolves.toContain("review skill");
|
|
453
|
+
expect(runtimes[0]?.setConfigInputs.map((input) => [input.key, input.value])).toEqual([["effort", "high"]]);
|
|
454
|
+
expect(meta[0]?.commandNotes?.join("\n")).toContain("set via ANTHROPIC_MODEL");
|
|
455
|
+
expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-opus-4-7");
|
|
456
|
+
const settings = JSON.parse(await fs.readFile(path.join(root, ".claude", "settings.local.json"), "utf8"));
|
|
457
|
+
expect(settings.permissions.defaultMode).toBe("default");
|
|
458
|
+
expect(settings.permissions.allow).toEqual(expect.arrayContaining(["Bash(curl:*)", "Bash(env)"]));
|
|
459
|
+
});
|
|
460
|
+
it("stages the skill bundle as a no-follow-symlinks asset for a remote ACP run, and points the prompt at the in-sandbox skill root", async () => {
|
|
461
|
+
vi.mocked(prepareAdapterExecutionTargetRuntime).mockClear();
|
|
462
|
+
const root = await makeTempRoot("paperclip-claude-acp-skills-remote-");
|
|
463
|
+
const skill = await createRuntimeSkill(root);
|
|
464
|
+
const localCwd = path.join(root, "worktree");
|
|
465
|
+
const remoteCwd = path.join(root, "remote-workspace");
|
|
466
|
+
await fs.mkdir(localCwd, { recursive: true });
|
|
467
|
+
await fs.mkdir(remoteCwd, { recursive: true });
|
|
468
|
+
const runtimes = [];
|
|
469
|
+
const execute = createClaudeAcpExecutor({
|
|
470
|
+
createRuntime: (options) => {
|
|
471
|
+
const runtime = new FakeRuntime(options);
|
|
472
|
+
runtimes.push(runtime);
|
|
473
|
+
return runtime;
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
const result = await execute(buildContext(localCwd, {
|
|
477
|
+
config: {
|
|
478
|
+
engine: "acp",
|
|
479
|
+
cwd: localCwd,
|
|
480
|
+
agentCommand: "node ./fake-acp.js",
|
|
481
|
+
stateDir: path.join(root, "state"),
|
|
482
|
+
promptTemplate: "Do the assigned work.",
|
|
483
|
+
paperclipRuntimeSkills: [skill],
|
|
484
|
+
paperclipSkillSync: { desiredSkills: [skill.key] },
|
|
485
|
+
},
|
|
486
|
+
context: {
|
|
487
|
+
issueId: "issue-1",
|
|
488
|
+
paperclipTaskMarkdown: "Task context",
|
|
489
|
+
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
490
|
+
},
|
|
491
|
+
executionTarget: {
|
|
492
|
+
kind: "remote",
|
|
493
|
+
transport: "sandbox",
|
|
494
|
+
providerKey: "fake-plugin",
|
|
495
|
+
remoteCwd,
|
|
496
|
+
runner: createLocalSandboxRunner(),
|
|
497
|
+
},
|
|
498
|
+
authToken: "real-run-jwt",
|
|
499
|
+
}));
|
|
500
|
+
expect(result.exitCode).toBe(0);
|
|
501
|
+
// The real seam sent the bundle to the shared staging call with
|
|
502
|
+
// `followSymlinks: false`: the bundle holds a plain copy of each skill's
|
|
503
|
+
// files, so staging never needs to carry a symbolic link's target
|
|
504
|
+
// content, and a link planted in the bundle after materialization must
|
|
505
|
+
// not cross into the sandbox.
|
|
506
|
+
const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0][0];
|
|
507
|
+
const skillsAsset = stageArgs.assets?.find((asset) => asset.key === "skills");
|
|
508
|
+
expect(skillsAsset).toMatchObject({ followSymlinks: false });
|
|
509
|
+
// The prompt names the in-sandbox skill root, not the host bundle dir...
|
|
510
|
+
const prompt = String(runtimes[0]?.startInputs[0]?.text ?? "");
|
|
511
|
+
const skillRootMatch = prompt.match(/Skill root: (\S+)/);
|
|
512
|
+
expect(skillRootMatch).toBeTruthy();
|
|
513
|
+
const inSandboxSkillRoot = skillRootMatch[1];
|
|
514
|
+
expect(inSandboxSkillRoot).not.toBe(skillsAsset.localDir);
|
|
515
|
+
expect(prompt).not.toContain(String(skillsAsset.localDir));
|
|
516
|
+
// ...and it really landed there (local runner extracts to the asset dir).
|
|
517
|
+
await expect(fs.readFile(path.join(inSandboxSkillRoot, "review", "SKILL.md"), "utf8")).resolves.toContain("review skill");
|
|
518
|
+
});
|
|
519
|
+
it("stages no skills asset for a remote ACP run with no selected skill", async () => {
|
|
520
|
+
vi.mocked(prepareAdapterExecutionTargetRuntime).mockClear();
|
|
521
|
+
const root = await makeTempRoot("paperclip-claude-acp-skills-remote-empty-");
|
|
522
|
+
// An available-but-undesired skill, so the run resolves a real (empty)
|
|
523
|
+
// selection instead of falling back to the package's own default skill
|
|
524
|
+
// set (that fallback only fires when `paperclipRuntimeSkills` is absent).
|
|
525
|
+
const skill = await createRuntimeSkill(root);
|
|
526
|
+
const localCwd = path.join(root, "worktree");
|
|
527
|
+
const remoteCwd = path.join(root, "remote-workspace");
|
|
528
|
+
await fs.mkdir(localCwd, { recursive: true });
|
|
529
|
+
await fs.mkdir(remoteCwd, { recursive: true });
|
|
530
|
+
const runtimes = [];
|
|
531
|
+
const execute = createClaudeAcpExecutor({
|
|
532
|
+
createRuntime: (options) => {
|
|
533
|
+
const runtime = new FakeRuntime(options);
|
|
534
|
+
runtimes.push(runtime);
|
|
535
|
+
return runtime;
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
const result = await execute(buildContext(localCwd, {
|
|
539
|
+
config: {
|
|
540
|
+
engine: "acp",
|
|
541
|
+
cwd: localCwd,
|
|
542
|
+
agentCommand: "node ./fake-acp.js",
|
|
543
|
+
stateDir: path.join(root, "state"),
|
|
544
|
+
promptTemplate: "Do the assigned work.",
|
|
545
|
+
paperclipRuntimeSkills: [skill],
|
|
546
|
+
paperclipSkillSync: { desiredSkills: [] },
|
|
547
|
+
},
|
|
548
|
+
context: {
|
|
549
|
+
issueId: "issue-1",
|
|
550
|
+
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
551
|
+
},
|
|
552
|
+
executionTarget: {
|
|
553
|
+
kind: "remote",
|
|
554
|
+
transport: "sandbox",
|
|
555
|
+
providerKey: "fake-plugin",
|
|
556
|
+
remoteCwd,
|
|
557
|
+
runner: createLocalSandboxRunner(),
|
|
558
|
+
},
|
|
559
|
+
authToken: "real-run-jwt",
|
|
560
|
+
}));
|
|
561
|
+
expect(result.exitCode).toBe(0);
|
|
562
|
+
const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0][0];
|
|
563
|
+
expect((stageArgs.assets ?? []).some((asset) => asset.key === "skills")).toBe(false);
|
|
564
|
+
expect(String(runtimes[0]?.startInputs[0]?.text ?? "")).not.toContain("Skill root:");
|
|
565
|
+
});
|
|
566
|
+
it("stages the skill bundle inside the sandbox but never syncs it back into the host workspace", async () => {
|
|
567
|
+
// The staged skill bundle lives under `.paperclip-runtime/claude/skills`,
|
|
568
|
+
// inside the same in-sandbox directory the workspace restore reads. The
|
|
569
|
+
// restore excludes the whole `.paperclip-runtime` tree
|
|
570
|
+
// (`sandbox-managed-runtime.ts`'s `restoreExclude` list) for every asset
|
|
571
|
+
// key alike, so this proves it for the new "skills" asset specifically.
|
|
572
|
+
const root = await makeTempRoot("paperclip-claude-acp-skills-no-syncback-");
|
|
573
|
+
const skill = await createRuntimeSkill(root);
|
|
574
|
+
const localCwd = path.join(root, "worktree");
|
|
575
|
+
const remoteCwd = path.join(root, "remote-workspace");
|
|
576
|
+
await fs.mkdir(localCwd, { recursive: true });
|
|
577
|
+
await fs.mkdir(remoteCwd, { recursive: true });
|
|
578
|
+
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
|
579
|
+
const runtimes = [];
|
|
580
|
+
const execute = createClaudeAcpExecutor({
|
|
581
|
+
createRuntime: (options) => {
|
|
582
|
+
const runtime = new FakeRuntime(options);
|
|
583
|
+
runtimes.push(runtime);
|
|
584
|
+
return runtime;
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
const result = await execute(buildContext(localCwd, {
|
|
588
|
+
config: {
|
|
589
|
+
engine: "acp",
|
|
590
|
+
cwd: localCwd,
|
|
591
|
+
agentCommand: "node ./fake-acp.js",
|
|
592
|
+
stateDir: path.join(root, "state"),
|
|
593
|
+
promptTemplate: "Do the assigned work.",
|
|
594
|
+
paperclipRuntimeSkills: [skill],
|
|
595
|
+
paperclipSkillSync: { desiredSkills: [skill.key] },
|
|
596
|
+
},
|
|
597
|
+
context: {
|
|
598
|
+
issueId: "issue-1",
|
|
599
|
+
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
600
|
+
},
|
|
601
|
+
executionTarget: {
|
|
602
|
+
kind: "remote",
|
|
603
|
+
transport: "sandbox",
|
|
604
|
+
providerKey: "fake-plugin",
|
|
605
|
+
remoteCwd,
|
|
606
|
+
runner: createLocalSandboxRunner(),
|
|
607
|
+
},
|
|
608
|
+
authToken: "real-run-jwt",
|
|
609
|
+
}));
|
|
610
|
+
expect(result.exitCode).toBe(0);
|
|
611
|
+
// Positive control: the bundle really did land in the sandbox stand-in
|
|
612
|
+
// during the run, under the in-sandbox skill root the prompt names.
|
|
613
|
+
const prompt = String(runtimes[0]?.startInputs[0]?.text ?? "");
|
|
614
|
+
const inSandboxSkillRoot = prompt.match(/Skill root: (\S+)/)[1];
|
|
615
|
+
expect(inSandboxSkillRoot).toContain(path.join(remoteCwd, ".paperclip-runtime"));
|
|
616
|
+
await expect(fs.readFile(path.join(inSandboxSkillRoot, "review", "SKILL.md"), "utf8")).resolves.toContain("review skill");
|
|
617
|
+
// After the run's workspace restore, the host worktree carries the file the
|
|
618
|
+
// run wrote inside the workspace proper...
|
|
619
|
+
await expect(fs.readFile(path.join(localCwd, "hello.txt"), "utf8")).resolves.toBe("hi");
|
|
620
|
+
// ...but not the staged runtime directory the skill bundle staged into.
|
|
621
|
+
await expect(fs.access(path.join(localCwd, ".paperclip-runtime"))).rejects.toThrow();
|
|
622
|
+
});
|
|
623
|
+
it("passes the exact configured Fable 5.1 ID through ANTHROPIC_MODEL on the ACP lane", async () => {
|
|
624
|
+
const root = await makeTempRoot("paperclip-claude-acp-fable51-");
|
|
625
|
+
const meta = [];
|
|
626
|
+
const execute = createClaudeAcpExecutor({
|
|
627
|
+
createRuntime: (options) => new FakeRuntime(options),
|
|
628
|
+
});
|
|
629
|
+
const result = await execute(buildContext(root, {
|
|
630
|
+
config: {
|
|
631
|
+
engine: "acp",
|
|
632
|
+
cwd: root,
|
|
633
|
+
stateDir: path.join(root, "state"),
|
|
634
|
+
model: "claude-fable-5-1",
|
|
635
|
+
promptTemplate: "Do the assigned work.",
|
|
636
|
+
},
|
|
637
|
+
onMeta: async (payload) => {
|
|
638
|
+
meta.push(payload);
|
|
639
|
+
},
|
|
640
|
+
}));
|
|
641
|
+
expect(result.exitCode).toBe(0);
|
|
642
|
+
expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-fable-5-1");
|
|
643
|
+
});
|
|
644
|
+
it("creates the ACP session on the in-sandbox workspace cwd for runner-backed remote runs", async () => {
|
|
645
|
+
const root = await makeTempRoot("paperclip-claude-acp-remote-cwd-");
|
|
646
|
+
const localCwd = path.join(root, "worktree");
|
|
647
|
+
const remoteCwd = path.join(root, "remote-workspace");
|
|
648
|
+
await fs.mkdir(localCwd, { recursive: true });
|
|
649
|
+
await fs.mkdir(remoteCwd, { recursive: true });
|
|
650
|
+
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
|
651
|
+
const runtimes = [];
|
|
652
|
+
const execute = createClaudeAcpExecutor({
|
|
653
|
+
createRuntime: (options) => {
|
|
654
|
+
const runtime = new FakeRuntime(options);
|
|
655
|
+
runtimes.push(runtime);
|
|
656
|
+
return runtime;
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
const result = await execute(buildContext(localCwd, {
|
|
660
|
+
config: {
|
|
661
|
+
engine: "acp",
|
|
662
|
+
cwd: localCwd,
|
|
663
|
+
// Throwaway ACP command so the process-session bridge does not require
|
|
664
|
+
// a real claude-agent-acp binary in the local sandbox stand-in.
|
|
665
|
+
agentCommand: "node ./fake-acp.js",
|
|
666
|
+
stateDir: path.join(root, "state"),
|
|
667
|
+
promptTemplate: "Do the assigned work.",
|
|
668
|
+
},
|
|
669
|
+
context: {
|
|
670
|
+
issueId: "issue-1",
|
|
671
|
+
paperclipTaskMarkdown: "Task context",
|
|
672
|
+
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
673
|
+
},
|
|
674
|
+
executionTarget: {
|
|
675
|
+
kind: "remote",
|
|
676
|
+
transport: "sandbox",
|
|
677
|
+
providerKey: "fake-plugin",
|
|
678
|
+
remoteCwd,
|
|
679
|
+
runner: createLocalSandboxRunner(),
|
|
680
|
+
},
|
|
681
|
+
authToken: "real-run-jwt",
|
|
682
|
+
}));
|
|
683
|
+
expect(result.exitCode).toBe(0);
|
|
684
|
+
await expect(fs.readFile(path.join(remoteCwd, "hello.txt"), "utf8")).resolves.toBe("hi");
|
|
685
|
+
expect(runtimes[0]?.ensureInputs[0]?.cwd).toBe(remoteCwd);
|
|
686
|
+
expect(runtimes[0]?.ensureInputs[0]?.cwd).not.toBe(localCwd);
|
|
687
|
+
});
|
|
688
|
+
it("seeds the managed Claude config into the sandbox and repoints CLAUDE_CONFIG_DIR to the in-sandbox path", async () => {
|
|
689
|
+
const root = await makeTempRoot("paperclip-claude-acp-home-seed-");
|
|
690
|
+
const localCwd = path.join(root, "worktree");
|
|
691
|
+
const remoteCwd = path.join(root, "remote-workspace");
|
|
692
|
+
const sharedClaudeConfig = path.join(root, "shared-claude-config");
|
|
693
|
+
await fs.mkdir(localCwd, { recursive: true });
|
|
694
|
+
await fs.mkdir(remoteCwd, { recursive: true });
|
|
695
|
+
await fs.mkdir(sharedClaudeConfig, { recursive: true });
|
|
696
|
+
// Host shared Claude config the seed is built from.
|
|
697
|
+
await fs.writeFile(path.join(sharedClaudeConfig, "settings.json"), JSON.stringify({ permissions: { defaultMode: "acceptEdits" } }), "utf8");
|
|
698
|
+
await fs.writeFile(path.join(sharedClaudeConfig, "CLAUDE.md"), "# shared guidance\n", "utf8");
|
|
699
|
+
process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home");
|
|
700
|
+
process.env.PAPERCLIP_INSTANCE_ID = "test";
|
|
701
|
+
process.env.CLAUDE_CONFIG_DIR = sharedClaudeConfig;
|
|
702
|
+
const meta = [];
|
|
703
|
+
const execute = createClaudeAcpExecutor({
|
|
704
|
+
createRuntime: (options) => new FakeRuntime(options),
|
|
705
|
+
});
|
|
706
|
+
const result = await execute(buildContext(localCwd, {
|
|
707
|
+
config: {
|
|
708
|
+
engine: "acp",
|
|
709
|
+
cwd: localCwd,
|
|
710
|
+
agentCommand: "node ./fake-acp.js",
|
|
711
|
+
stateDir: path.join(root, "state"),
|
|
712
|
+
promptTemplate: "Do the assigned work.",
|
|
713
|
+
},
|
|
714
|
+
context: {
|
|
715
|
+
issueId: "issue-1",
|
|
716
|
+
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
717
|
+
},
|
|
718
|
+
executionTarget: {
|
|
719
|
+
kind: "remote",
|
|
720
|
+
transport: "sandbox",
|
|
721
|
+
providerKey: "fake-plugin",
|
|
722
|
+
remoteCwd,
|
|
723
|
+
runner: createLocalSandboxRunner(),
|
|
724
|
+
},
|
|
725
|
+
authToken: "real-run-jwt",
|
|
726
|
+
onMeta: async (payload) => {
|
|
727
|
+
meta.push(payload);
|
|
728
|
+
},
|
|
729
|
+
}));
|
|
730
|
+
expect(result.exitCode).toBe(0);
|
|
731
|
+
const remappedConfigDir = String(meta[0]?.env?.CLAUDE_CONFIG_DIR ?? "");
|
|
732
|
+
// C2 — CLAUDE_CONFIG_DIR repointed onto an in-sandbox path, distinct from the
|
|
733
|
+
// host shared config dir.
|
|
734
|
+
expect(remappedConfigDir).not.toBe(sharedClaudeConfig);
|
|
735
|
+
expect(remappedConfigDir).toContain(".paperclip-runtime");
|
|
736
|
+
expect(remappedConfigDir.endsWith("/config")).toBe(true);
|
|
737
|
+
// Seeded: settings.json was materialized into the in-sandbox config dir (the
|
|
738
|
+
// local runner uses the host FS, so this is a real host path).
|
|
739
|
+
await expect(fs.readFile(path.join(remappedConfigDir, "settings.json"), "utf8")).resolves.toContain("permissions");
|
|
740
|
+
// C4 — no XDG_* variable is introduced for in-sandbox credential discovery.
|
|
741
|
+
expect(Object.keys(meta[0]?.env ?? {}).filter((key) => key.startsWith("XDG_"))).toEqual([]);
|
|
742
|
+
});
|
|
743
|
+
it("test_claude_acp_seam_registers_workspace_sync_back", async () => {
|
|
744
|
+
const root = await makeTempRoot("paperclip-claude-acp-syncback-");
|
|
745
|
+
const localCwd = path.join(root, "worktree");
|
|
746
|
+
const remoteCwd = path.join(root, "remote-workspace");
|
|
747
|
+
const sharedClaudeConfig = path.join(root, "shared-claude-config");
|
|
748
|
+
await fs.mkdir(localCwd, { recursive: true });
|
|
749
|
+
await fs.mkdir(remoteCwd, { recursive: true });
|
|
750
|
+
await fs.mkdir(sharedClaudeConfig, { recursive: true });
|
|
751
|
+
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
|
752
|
+
await fs.writeFile(path.join(sharedClaudeConfig, "settings.json"), JSON.stringify({ permissions: { defaultMode: "acceptEdits" } }), "utf8");
|
|
753
|
+
await fs.writeFile(path.join(sharedClaudeConfig, "CLAUDE.md"), "# shared guidance\n", "utf8");
|
|
754
|
+
process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home");
|
|
755
|
+
process.env.PAPERCLIP_INSTANCE_ID = "test";
|
|
756
|
+
process.env.CLAUDE_CONFIG_DIR = sharedClaudeConfig;
|
|
757
|
+
// The runtime writes a NEW file into the in-sandbox workspace during the turn.
|
|
758
|
+
// The seam must register a workspace sync-back teardown, so the file lands in
|
|
759
|
+
// the host worktree after the run.
|
|
760
|
+
const runtime = new FakeRuntime({});
|
|
761
|
+
const startTurn = runtime.startTurn.bind(runtime);
|
|
762
|
+
runtime.startTurn = (input) => {
|
|
763
|
+
const turn = startTurn(input);
|
|
764
|
+
const remoteWorkspaceCwd = input.handle.cwd ?? remoteCwd;
|
|
765
|
+
return {
|
|
766
|
+
...turn,
|
|
767
|
+
result: (async () => {
|
|
768
|
+
await fs.writeFile(path.join(remoteWorkspaceCwd, "from-sandbox.txt"), "synced", "utf8");
|
|
769
|
+
return await turn.result;
|
|
770
|
+
})(),
|
|
771
|
+
};
|
|
772
|
+
};
|
|
773
|
+
const execute = createClaudeAcpExecutor({
|
|
774
|
+
createRuntime: (options) => {
|
|
775
|
+
Object.assign(runtime.options, options);
|
|
776
|
+
return runtime;
|
|
777
|
+
},
|
|
778
|
+
});
|
|
779
|
+
const result = await execute(buildContext(localCwd, {
|
|
780
|
+
config: {
|
|
781
|
+
engine: "acp",
|
|
782
|
+
cwd: localCwd,
|
|
783
|
+
agentCommand: "node ./fake-acp.js",
|
|
784
|
+
stateDir: path.join(root, "state"),
|
|
785
|
+
promptTemplate: "Do the assigned work.",
|
|
786
|
+
},
|
|
787
|
+
context: {
|
|
788
|
+
issueId: "issue-1",
|
|
789
|
+
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
790
|
+
},
|
|
791
|
+
executionTarget: {
|
|
792
|
+
kind: "remote",
|
|
793
|
+
transport: "sandbox",
|
|
794
|
+
providerKey: "fake-plugin",
|
|
795
|
+
remoteCwd,
|
|
796
|
+
runner: createLocalSandboxRunner(),
|
|
797
|
+
},
|
|
798
|
+
authToken: "real-run-jwt",
|
|
799
|
+
}));
|
|
800
|
+
expect(result.exitCode).toBe(0);
|
|
801
|
+
// The teardown fired `restoreWorkspace`, so the sandbox-authored file is now
|
|
802
|
+
// in the host worktree.
|
|
803
|
+
await expect(fs.readFile(path.join(localCwd, "from-sandbox.txt"), "utf8")).resolves.toBe("synced");
|
|
804
|
+
});
|
|
805
|
+
it("test_claude_acp_teardown_restore_failure_sanitizes_the_run_log", async () => {
|
|
806
|
+
// Security regression for a workspace-restore write failure: the run log
|
|
807
|
+
// is readable by any same-company actor, so the teardown must never write
|
|
808
|
+
// the caught error's own message there — that message can carry the host
|
|
809
|
+
// workspace path. Force a real EACCES by making the workspace read-only,
|
|
810
|
+
// and name it with a sentinel marker so any leak is easy to spot.
|
|
811
|
+
const root = await makeTempRoot("paperclip-claude-acp-restore-failure-");
|
|
812
|
+
const localCwd = path.join(root, "SENTINEL-HOST-PATH-marker", "worktree");
|
|
813
|
+
const remoteCwd = path.join(root, "remote-workspace");
|
|
814
|
+
await fs.mkdir(localCwd, { recursive: true });
|
|
815
|
+
await fs.mkdir(remoteCwd, { recursive: true });
|
|
816
|
+
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
|
|
817
|
+
process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home");
|
|
818
|
+
process.env.PAPERCLIP_INSTANCE_ID = "test";
|
|
819
|
+
// The runtime writes a new file into the in-sandbox workspace during the
|
|
820
|
+
// turn, so the teardown's restore has something to copy back — and a new
|
|
821
|
+
// file is exactly what a read-only workspace directory rejects. The
|
|
822
|
+
// workspace turns read-only only after the turn's own writes (settings
|
|
823
|
+
// seeded at startup, the sandbox-authored file) — the teardown restore
|
|
824
|
+
// that runs after the turn is the write this test forces to fail.
|
|
825
|
+
const runtime = new FakeRuntime({});
|
|
826
|
+
const startTurn = runtime.startTurn.bind(runtime);
|
|
827
|
+
runtime.startTurn = (input) => {
|
|
828
|
+
const turn = startTurn(input);
|
|
829
|
+
const remoteWorkspaceCwd = input.handle.cwd ?? remoteCwd;
|
|
830
|
+
return {
|
|
831
|
+
...turn,
|
|
832
|
+
result: (async () => {
|
|
833
|
+
await fs.writeFile(path.join(remoteWorkspaceCwd, "from-sandbox.txt"), "synced", "utf8");
|
|
834
|
+
await fs.chmod(localCwd, 0o500);
|
|
835
|
+
return await turn.result;
|
|
836
|
+
})(),
|
|
837
|
+
};
|
|
838
|
+
};
|
|
839
|
+
const execute = createClaudeAcpExecutor({
|
|
840
|
+
createRuntime: (options) => {
|
|
841
|
+
Object.assign(runtime.options, options);
|
|
842
|
+
return runtime;
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
const loggedLines = [];
|
|
846
|
+
try {
|
|
847
|
+
const result = await execute(buildContext(localCwd, {
|
|
848
|
+
config: {
|
|
849
|
+
engine: "acp",
|
|
850
|
+
cwd: localCwd,
|
|
851
|
+
agentCommand: "node ./fake-acp.js",
|
|
852
|
+
stateDir: path.join(root, "state"),
|
|
853
|
+
promptTemplate: "Do the assigned work.",
|
|
854
|
+
},
|
|
855
|
+
context: {
|
|
856
|
+
issueId: "issue-1",
|
|
857
|
+
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
858
|
+
},
|
|
859
|
+
executionTarget: {
|
|
860
|
+
kind: "remote",
|
|
861
|
+
transport: "sandbox",
|
|
862
|
+
providerKey: "fake-plugin",
|
|
863
|
+
remoteCwd,
|
|
864
|
+
runner: createLocalSandboxRunner(),
|
|
865
|
+
},
|
|
866
|
+
authToken: "real-run-jwt",
|
|
867
|
+
onLog: async (_stream, chunk) => {
|
|
868
|
+
loggedLines.push(chunk);
|
|
869
|
+
},
|
|
870
|
+
}));
|
|
871
|
+
// Fail-open: the restore miss never changes the run's exit code or
|
|
872
|
+
// status, and it surfaces as one allowlisted code — never the raw error.
|
|
873
|
+
expect(result.exitCode).toBe(0);
|
|
874
|
+
expect(result.resultJson?.workspaceRestoreFailure).toBe("restore_permission_denied");
|
|
875
|
+
const allLogs = loggedLines.join("");
|
|
876
|
+
expect(allLogs).not.toContain("SENTINEL-HOST-PATH-marker");
|
|
877
|
+
expect(allLogs).not.toContain(localCwd);
|
|
878
|
+
expect(allLogs).not.toContain("EACCES");
|
|
879
|
+
expect(allLogs).toContain("permission denied");
|
|
880
|
+
}
|
|
881
|
+
finally {
|
|
882
|
+
await fs.chmod(localCwd, 0o700).catch(() => undefined);
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
it("remaps a workspace-relative explicit CLAUDE_CONFIG_DIR onto the in-sandbox workspace path", async () => {
|
|
886
|
+
const root = await makeTempRoot("paperclip-claude-acp-explicit-inworkspace-");
|
|
887
|
+
const localCwd = path.join(root, "worktree");
|
|
888
|
+
const remoteCwd = path.join(root, "remote-workspace");
|
|
889
|
+
await fs.mkdir(localCwd, { recursive: true });
|
|
890
|
+
await fs.mkdir(remoteCwd, { recursive: true });
|
|
891
|
+
// Operator pins a config dir that lives INSIDE the workspace cwd, so it is
|
|
892
|
+
// staged into the sandbox and its host prefix must be remapped onto the
|
|
893
|
+
// in-sandbox workspace dir (never forwarded as the host path).
|
|
894
|
+
const operatorConfigDir = path.join(localCwd, ".claude-config");
|
|
895
|
+
await fs.mkdir(operatorConfigDir, { recursive: true });
|
|
896
|
+
await fs.writeFile(path.join(operatorConfigDir, "settings.json"), JSON.stringify({ permissions: { defaultMode: "acceptEdits" } }), "utf8");
|
|
897
|
+
process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home");
|
|
898
|
+
process.env.PAPERCLIP_INSTANCE_ID = "test";
|
|
899
|
+
const meta = [];
|
|
900
|
+
const logs = [];
|
|
901
|
+
const execute = createClaudeAcpExecutor({
|
|
902
|
+
createRuntime: (options) => new FakeRuntime(options),
|
|
903
|
+
});
|
|
904
|
+
const result = await execute(buildContext(localCwd, {
|
|
905
|
+
config: {
|
|
906
|
+
engine: "acp",
|
|
907
|
+
cwd: localCwd,
|
|
908
|
+
agentCommand: "node ./fake-acp.js",
|
|
909
|
+
stateDir: path.join(root, "state"),
|
|
910
|
+
promptTemplate: "Do the assigned work.",
|
|
911
|
+
env: { CLAUDE_CONFIG_DIR: operatorConfigDir },
|
|
912
|
+
},
|
|
913
|
+
context: {
|
|
914
|
+
issueId: "issue-1",
|
|
915
|
+
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
916
|
+
},
|
|
917
|
+
executionTarget: {
|
|
918
|
+
kind: "remote",
|
|
919
|
+
transport: "sandbox",
|
|
920
|
+
providerKey: "fake-plugin",
|
|
921
|
+
remoteCwd,
|
|
922
|
+
runner: createLocalSandboxRunner(),
|
|
923
|
+
},
|
|
924
|
+
authToken: "real-run-jwt",
|
|
925
|
+
onLog: async (_stream, chunk) => {
|
|
926
|
+
logs.push(chunk);
|
|
927
|
+
},
|
|
928
|
+
onMeta: async (payload) => {
|
|
929
|
+
meta.push(payload);
|
|
930
|
+
},
|
|
931
|
+
}));
|
|
932
|
+
expect(result.exitCode).toBe(0);
|
|
933
|
+
// Prefix remapped host→sandbox: same relative subpath, in-sandbox workspace root.
|
|
934
|
+
expect(meta[0]?.env?.CLAUDE_CONFIG_DIR).toBe(path.posix.join(remoteCwd, ".claude-config"));
|
|
935
|
+
expect(meta[0]?.env?.CLAUDE_CONFIG_DIR).not.toBe(operatorConfigDir);
|
|
936
|
+
// No managed config seed is materialized — the operator dir is authoritative.
|
|
937
|
+
expect(String(meta[0]?.env?.CLAUDE_CONFIG_DIR ?? "")).not.toContain(".paperclip-runtime");
|
|
938
|
+
expect(logs.join("")).toContain(`Remapped operator CLAUDE_CONFIG_DIR from host path ${operatorConfigDir}`);
|
|
939
|
+
});
|
|
940
|
+
it("ignores a host-only explicit CLAUDE_CONFIG_DIR that cannot reach the sandbox and seeds the managed config instead", async () => {
|
|
941
|
+
const root = await makeTempRoot("paperclip-claude-acp-explicit-hostonly-");
|
|
942
|
+
const localCwd = path.join(root, "worktree");
|
|
943
|
+
const remoteCwd = path.join(root, "remote-workspace");
|
|
944
|
+
const sharedClaudeConfig = path.join(root, "shared-claude-config");
|
|
945
|
+
// An operator-pinned config dir OUTSIDE the workspace cwd: a host-only path the
|
|
946
|
+
// sandbox cannot reach, so it must not be forwarded verbatim.
|
|
947
|
+
const operatorConfigDir = path.join(root, "operator-claude-config");
|
|
948
|
+
await fs.mkdir(localCwd, { recursive: true });
|
|
949
|
+
await fs.mkdir(remoteCwd, { recursive: true });
|
|
950
|
+
await fs.mkdir(sharedClaudeConfig, { recursive: true });
|
|
951
|
+
// Host shared Claude config the managed seed is built from.
|
|
952
|
+
await fs.writeFile(path.join(sharedClaudeConfig, "settings.json"), JSON.stringify({ permissions: { defaultMode: "acceptEdits" } }), "utf8");
|
|
953
|
+
await fs.writeFile(path.join(sharedClaudeConfig, "CLAUDE.md"), "# shared guidance\n", "utf8");
|
|
954
|
+
process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home");
|
|
955
|
+
process.env.PAPERCLIP_INSTANCE_ID = "test";
|
|
956
|
+
process.env.CLAUDE_CONFIG_DIR = sharedClaudeConfig;
|
|
957
|
+
const meta = [];
|
|
958
|
+
const logs = [];
|
|
959
|
+
const execute = createClaudeAcpExecutor({
|
|
960
|
+
createRuntime: (options) => new FakeRuntime(options),
|
|
961
|
+
});
|
|
962
|
+
const result = await execute(buildContext(localCwd, {
|
|
963
|
+
config: {
|
|
964
|
+
engine: "acp",
|
|
965
|
+
cwd: localCwd,
|
|
966
|
+
agentCommand: "node ./fake-acp.js",
|
|
967
|
+
stateDir: path.join(root, "state"),
|
|
968
|
+
promptTemplate: "Do the assigned work.",
|
|
969
|
+
// Explicit user-managed CLAUDE_CONFIG_DIR (adapter config env, not a host
|
|
970
|
+
// env leak) pointing at a host-only path.
|
|
971
|
+
env: { CLAUDE_CONFIG_DIR: operatorConfigDir },
|
|
972
|
+
},
|
|
973
|
+
context: {
|
|
974
|
+
issueId: "issue-1",
|
|
975
|
+
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
|
|
976
|
+
},
|
|
977
|
+
executionTarget: {
|
|
978
|
+
kind: "remote",
|
|
979
|
+
transport: "sandbox",
|
|
980
|
+
providerKey: "fake-plugin",
|
|
981
|
+
remoteCwd,
|
|
982
|
+
runner: createLocalSandboxRunner(),
|
|
983
|
+
},
|
|
984
|
+
authToken: "real-run-jwt",
|
|
985
|
+
onLog: async (_stream, chunk) => {
|
|
986
|
+
logs.push(chunk);
|
|
987
|
+
},
|
|
988
|
+
onMeta: async (payload) => {
|
|
989
|
+
meta.push(payload);
|
|
990
|
+
},
|
|
991
|
+
}));
|
|
992
|
+
expect(result.exitCode).toBe(0);
|
|
993
|
+
const remappedConfigDir = String(meta[0]?.env?.CLAUDE_CONFIG_DIR ?? "");
|
|
994
|
+
// The un-portable host path is dropped; managed config is seeded in-sandbox.
|
|
995
|
+
expect(remappedConfigDir).not.toBe(operatorConfigDir);
|
|
996
|
+
expect(remappedConfigDir).toContain(".paperclip-runtime");
|
|
997
|
+
expect(remappedConfigDir.endsWith("/config")).toBe(true);
|
|
998
|
+
await expect(fs.readFile(path.join(remappedConfigDir, "settings.json"), "utf8")).resolves.toContain("permissions");
|
|
999
|
+
// Observability: the un-portable override is flagged so the substitution is diagnosable.
|
|
1000
|
+
expect(logs.join("")).toContain(`operator-provided CLAUDE_CONFIG_DIR=${operatorConfigDir} is outside the staged workspace`);
|
|
1001
|
+
});
|
|
1002
|
+
it("reports unavailable ACP for a runner-less sandbox even when the ACP command is set", async () => {
|
|
1003
|
+
setNodeVersion("v24.11.0");
|
|
1004
|
+
await expect(resolveClaudeExecutionEngineForRun({
|
|
1005
|
+
config: { agentCommand: "claude-agent-acp" },
|
|
1006
|
+
executionTarget: {
|
|
1007
|
+
kind: "remote",
|
|
1008
|
+
transport: "sandbox",
|
|
1009
|
+
providerKey: "fake-plugin",
|
|
1010
|
+
remoteCwd: "/work",
|
|
1011
|
+
},
|
|
1012
|
+
})).resolves.toMatchObject({
|
|
1013
|
+
engine: "acp",
|
|
1014
|
+
explicit: false,
|
|
1015
|
+
unavailableReason: expect.stringContaining("bidirectional remote process"),
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
it("delivers the issue description exactly once per prompt and compacts non-assignment resume deltas", async () => {
|
|
1019
|
+
const root = await makeTempRoot("paperclip-claude-acp-brief-");
|
|
1020
|
+
const runtimes = [];
|
|
1021
|
+
const execute = createClaudeAcpExecutor({
|
|
1022
|
+
createRuntime: (options) => {
|
|
1023
|
+
const runtime = new FakeRuntime(options);
|
|
1024
|
+
runtimes.push(runtime);
|
|
1025
|
+
return runtime;
|
|
1026
|
+
},
|
|
1027
|
+
});
|
|
1028
|
+
const description = "Update launch-card.svg and change the CTA to Try Team free.";
|
|
1029
|
+
const fullTaskMarkdown = [
|
|
1030
|
+
"Paperclip task context:",
|
|
1031
|
+
"- Issue: \"PAP-15271\"",
|
|
1032
|
+
"- Title: \"Preserve the task brief\"",
|
|
1033
|
+
"",
|
|
1034
|
+
"Issue description:",
|
|
1035
|
+
"```text",
|
|
1036
|
+
description,
|
|
1037
|
+
"```",
|
|
1038
|
+
].join("\n");
|
|
1039
|
+
const compactTaskMarkdown = [
|
|
1040
|
+
"Paperclip task context:",
|
|
1041
|
+
"- Issue: \"PAP-15271\"",
|
|
1042
|
+
"- Title: \"Preserve the task brief\"",
|
|
1043
|
+
].join("\n");
|
|
1044
|
+
const wakeContext = (reason) => ({
|
|
1045
|
+
issueId: "issue-1",
|
|
1046
|
+
paperclipTaskMarkdown: fullTaskMarkdown,
|
|
1047
|
+
paperclipTaskMarkdownCompact: compactTaskMarkdown,
|
|
1048
|
+
paperclipWake: {
|
|
1049
|
+
reason,
|
|
1050
|
+
issue: {
|
|
1051
|
+
id: "issue-1",
|
|
1052
|
+
identifier: "PAP-15271",
|
|
1053
|
+
title: "Preserve the task brief",
|
|
1054
|
+
description,
|
|
1055
|
+
descriptionTruncated: false,
|
|
1056
|
+
status: "in_progress",
|
|
1057
|
+
},
|
|
1058
|
+
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
|
|
1059
|
+
comments: [],
|
|
1060
|
+
fallbackFetchNeeded: false,
|
|
1061
|
+
},
|
|
1062
|
+
paperclipWorkspace: {
|
|
1063
|
+
cwd: root,
|
|
1064
|
+
source: "project_workspace",
|
|
1065
|
+
workspaceId: "workspace-1",
|
|
1066
|
+
},
|
|
1067
|
+
});
|
|
1068
|
+
const first = await execute(buildContext(root, { context: wakeContext("issue_assigned") }));
|
|
1069
|
+
const freshPrompt = runtimes[0]?.startInputs[0]?.text ?? "";
|
|
1070
|
+
expect(freshPrompt.split(description)).toHaveLength(2);
|
|
1071
|
+
expect(freshPrompt).toContain("Paperclip task context:");
|
|
1072
|
+
const second = await execute(buildContext(root, {
|
|
1073
|
+
runtime: {
|
|
1074
|
+
sessionId: first.sessionId ?? null,
|
|
1075
|
+
sessionParams: first.sessionParams ?? null,
|
|
1076
|
+
sessionDisplayId: first.sessionDisplayId ?? null,
|
|
1077
|
+
taskKey: "PAP-1",
|
|
1078
|
+
},
|
|
1079
|
+
context: wakeContext("issue_commented"),
|
|
1080
|
+
}));
|
|
1081
|
+
expect(second.exitCode).toBe(0);
|
|
1082
|
+
const resumePrompt = runtimes[1]?.startInputs[0]?.text ?? "";
|
|
1083
|
+
expect(resumePrompt).not.toContain(description);
|
|
1084
|
+
expect(resumePrompt).toContain("Paperclip task context:");
|
|
1085
|
+
expect(resumePrompt).toContain("- issue description: omitted from this resume delta; fetch the issue if you need the latest brief");
|
|
1086
|
+
});
|
|
1087
|
+
it("resumes compatible ACP sessions on later Claude ACP runs", async () => {
|
|
1088
|
+
const root = await makeTempRoot("paperclip-claude-acp-resume-");
|
|
1089
|
+
const runtimes = [];
|
|
1090
|
+
const execute = createClaudeAcpExecutor({
|
|
1091
|
+
createRuntime: (options) => {
|
|
1092
|
+
const runtime = new FakeRuntime(options);
|
|
1093
|
+
runtimes.push(runtime);
|
|
1094
|
+
return runtime;
|
|
1095
|
+
},
|
|
1096
|
+
});
|
|
1097
|
+
const first = await execute(buildContext(root));
|
|
1098
|
+
const second = await execute(buildContext(root, {
|
|
1099
|
+
runtime: {
|
|
1100
|
+
sessionId: first.sessionId ?? null,
|
|
1101
|
+
sessionParams: first.sessionParams ?? null,
|
|
1102
|
+
sessionDisplayId: first.sessionDisplayId ?? null,
|
|
1103
|
+
taskKey: "PAP-1",
|
|
1104
|
+
},
|
|
1105
|
+
}));
|
|
1106
|
+
expect(second.exitCode).toBe(0);
|
|
1107
|
+
expect(runtimes).toHaveLength(2);
|
|
1108
|
+
expect(runtimes[1]?.ensureInputs[0]?.resumeSessionId).toBe("acp-1");
|
|
1109
|
+
});
|
|
1110
|
+
});
|
|
1111
|
+
describe("resolveClaudeAcpBillingIdentity", () => {
|
|
1112
|
+
const originalApiKey = process.env.ANTHROPIC_API_KEY;
|
|
1113
|
+
const originalBedrock = process.env.CLAUDE_CODE_USE_BEDROCK;
|
|
1114
|
+
const originalBedrockBase = process.env.ANTHROPIC_BEDROCK_BASE_URL;
|
|
1115
|
+
afterEach(() => {
|
|
1116
|
+
if (originalApiKey === undefined)
|
|
1117
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
1118
|
+
else
|
|
1119
|
+
process.env.ANTHROPIC_API_KEY = originalApiKey;
|
|
1120
|
+
if (originalBedrock === undefined)
|
|
1121
|
+
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
|
1122
|
+
else
|
|
1123
|
+
process.env.CLAUDE_CODE_USE_BEDROCK = originalBedrock;
|
|
1124
|
+
if (originalBedrockBase === undefined)
|
|
1125
|
+
delete process.env.ANTHROPIC_BEDROCK_BASE_URL;
|
|
1126
|
+
else
|
|
1127
|
+
process.env.ANTHROPIC_BEDROCK_BASE_URL = originalBedrockBase;
|
|
1128
|
+
});
|
|
1129
|
+
it("classifies an adapter-config API key as api billing", () => {
|
|
1130
|
+
expect(resolveClaudeAcpBillingIdentity({ config: { env: { ANTHROPIC_API_KEY: "sk-ant-test" } } })).toEqual({ provider: "anthropic", biller: "anthropic", billingType: "api" });
|
|
1131
|
+
});
|
|
1132
|
+
it("classifies Bedrock auth as metered_api billed to aws_bedrock", () => {
|
|
1133
|
+
expect(resolveClaudeAcpBillingIdentity({ config: { env: { CLAUDE_CODE_USE_BEDROCK: "1" } } })).toEqual({ provider: "anthropic", biller: "aws_bedrock", billingType: "metered_api" });
|
|
1134
|
+
});
|
|
1135
|
+
it("falls back to subscription without API-key or Bedrock auth", () => {
|
|
1136
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
1137
|
+
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
|
1138
|
+
delete process.env.ANTHROPIC_BEDROCK_BASE_URL;
|
|
1139
|
+
expect(resolveClaudeAcpBillingIdentity({ config: {} })).toEqual({
|
|
1140
|
+
provider: "anthropic",
|
|
1141
|
+
biller: "anthropic",
|
|
1142
|
+
billingType: "subscription",
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
it("ignores host env for remote execution targets", () => {
|
|
1146
|
+
process.env.ANTHROPIC_API_KEY = "sk-ant-host-only";
|
|
1147
|
+
expect(resolveClaudeAcpBillingIdentity({
|
|
1148
|
+
config: {},
|
|
1149
|
+
executionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/work" },
|
|
1150
|
+
}).billingType).toBe("subscription");
|
|
1151
|
+
});
|
|
1152
|
+
});
|
|
1153
|
+
//# sourceMappingURL=acp.test.js.map
|