@fusionkit/tool-claude 0.1.6
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/dist/harness.d.ts +25 -0
- package/dist/harness.js +399 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +57 -0
- package/dist/launch.d.ts +5 -0
- package/dist/launch.js +18 -0
- package/dist/test/claude-code.test.d.ts +1 -0
- package/dist/test/claude-code.test.js +157 -0
- package/package.json +37 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { NetworkPolicy } from "@fusionkit/protocol";
|
|
2
|
+
import type { SessionBackend } from "@fusionkit/runner";
|
|
3
|
+
import type { ClaudeCodeBindingOptions } from "@fusionkit/session-harness";
|
|
4
|
+
import type { HarnessAdapter } from "@fusionkit/ensemble";
|
|
5
|
+
export type ClaudeCodeHarnessEnv = Record<string, string | undefined>;
|
|
6
|
+
export type ClaudeCodeHarnessOptions = ClaudeCodeBindingOptions & {
|
|
7
|
+
id?: string;
|
|
8
|
+
/** Defaults to `process.env`; tests can pass `{}` for deterministic skips. */
|
|
9
|
+
env?: ClaudeCodeHarnessEnv;
|
|
10
|
+
/** Already-released secret values forwarded through the session backend seam. */
|
|
11
|
+
secrets?: {
|
|
12
|
+
name: string;
|
|
13
|
+
value: string;
|
|
14
|
+
}[];
|
|
15
|
+
/** Test/extension seam. Defaults to `aiSdkHarnessBackend(...)`. */
|
|
16
|
+
backend?: SessionBackend;
|
|
17
|
+
pool?: string;
|
|
18
|
+
network?: NetworkPolicy;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
logMaxBytes?: number;
|
|
21
|
+
skipWhenUnavailable?: boolean;
|
|
22
|
+
};
|
|
23
|
+
export declare function claudeCodeHarnessCredentialSkipReason(env?: ClaudeCodeHarnessEnv, options?: ClaudeCodeHarnessOptions): string | undefined;
|
|
24
|
+
export declare function createClaudeCodeHarness(options?: ClaudeCodeHarnessOptions): HarnessAdapter;
|
|
25
|
+
export declare function claudeCodeHarness(options?: ClaudeCodeHarnessOptions): HarnessAdapter;
|
package/dist/harness.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { artifactHash } from "@fusionkit/protocol";
|
|
2
|
+
import { CapabilityMismatchError, prepareExecution } from "@fusionkit/runner";
|
|
3
|
+
import { aiSdkHarnessBackend } from "@fusionkit/session-harness";
|
|
4
|
+
import { hardeningToJson } from "@fusionkit/ensemble";
|
|
5
|
+
const ZERO_HASH = "0".repeat(64);
|
|
6
|
+
const ZERO_GIT_SHA = "0".repeat(40);
|
|
7
|
+
const DEFAULT_POOL = "ensemble";
|
|
8
|
+
const DEFAULT_RUNTIME = "node24";
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
10
|
+
const DEFAULT_LOG_MAX_BYTES = 256 * 1024;
|
|
11
|
+
const DEFAULT_CLAUDE_NETWORK = {
|
|
12
|
+
defaultDeny: true,
|
|
13
|
+
allowHosts: ["registry.npmjs.org", "api.anthropic.com", "ai-gateway.vercel.sh"]
|
|
14
|
+
};
|
|
15
|
+
const AUTH_ENV_NAMES = [
|
|
16
|
+
"AI_GATEWAY_API_KEY",
|
|
17
|
+
"AI_GATEWAY_BASE_URL",
|
|
18
|
+
"ANTHROPIC_API_KEY",
|
|
19
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
20
|
+
"ANTHROPIC_BASE_URL"
|
|
21
|
+
];
|
|
22
|
+
function candidateId(input) {
|
|
23
|
+
return `${input.descriptor.id}_${input.model.id}_${input.ordinal}`;
|
|
24
|
+
}
|
|
25
|
+
function envValue(env, name) {
|
|
26
|
+
const value = env[name];
|
|
27
|
+
return value && value.length > 0 ? value : undefined;
|
|
28
|
+
}
|
|
29
|
+
function authEnvFrom(env) {
|
|
30
|
+
const authEnv = {};
|
|
31
|
+
for (const name of AUTH_ENV_NAMES) {
|
|
32
|
+
const value = envValue(env, name);
|
|
33
|
+
if (value !== undefined)
|
|
34
|
+
authEnv[name] = value;
|
|
35
|
+
}
|
|
36
|
+
return authEnv;
|
|
37
|
+
}
|
|
38
|
+
function credentialGate(env, options) {
|
|
39
|
+
const missing = [];
|
|
40
|
+
const hasProviderCredential = envValue(env, "AI_GATEWAY_API_KEY") ??
|
|
41
|
+
envValue(env, "ANTHROPIC_API_KEY") ??
|
|
42
|
+
envValue(env, "ANTHROPIC_AUTH_TOKEN");
|
|
43
|
+
const hasSandboxCredential = options.backend !== undefined ||
|
|
44
|
+
options.createSandboxProvider !== undefined ||
|
|
45
|
+
options.token !== undefined ||
|
|
46
|
+
envValue(env, "VERCEL_TOKEN") !== undefined;
|
|
47
|
+
if (!hasSandboxCredential)
|
|
48
|
+
missing.push("VERCEL_TOKEN");
|
|
49
|
+
if (!hasProviderCredential) {
|
|
50
|
+
missing.push("ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|AI_GATEWAY_API_KEY");
|
|
51
|
+
}
|
|
52
|
+
if (missing.length > 0) {
|
|
53
|
+
return {
|
|
54
|
+
available: false,
|
|
55
|
+
missing,
|
|
56
|
+
reason: "Claude Code harness skipped: missing Claude Code credential/env; set VERCEL_TOKEN and one of " +
|
|
57
|
+
"ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or AI_GATEWAY_API_KEY."
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { available: true, authEnv: authEnvFrom(env) };
|
|
61
|
+
}
|
|
62
|
+
export function claudeCodeHarnessCredentialSkipReason(env = process.env, options = {}) {
|
|
63
|
+
const gate = credentialGate(env, options);
|
|
64
|
+
return gate.available ? undefined : gate.reason;
|
|
65
|
+
}
|
|
66
|
+
function backendFor(options, env) {
|
|
67
|
+
return (options.backend ??
|
|
68
|
+
aiSdkHarnessBackend({
|
|
69
|
+
...(options.runtime !== undefined ? { runtime: options.runtime } : {}),
|
|
70
|
+
...(options.bridgePort !== undefined ? { bridgePort: options.bridgePort } : {}),
|
|
71
|
+
token: options.token ?? envValue(env, "VERCEL_TOKEN"),
|
|
72
|
+
teamId: options.teamId ?? envValue(env, "VERCEL_TEAM_ID"),
|
|
73
|
+
projectId: options.projectId ?? envValue(env, "VERCEL_PROJECT_ID"),
|
|
74
|
+
...(options.model !== undefined ? { model: options.model } : {}),
|
|
75
|
+
...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
|
|
76
|
+
...(options.thinking !== undefined ? { thinking: options.thinking } : {}),
|
|
77
|
+
...(options.startupTimeoutMs !== undefined
|
|
78
|
+
? { startupTimeoutMs: options.startupTimeoutMs }
|
|
79
|
+
: {}),
|
|
80
|
+
...(options.createHarness !== undefined ? { createHarness: options.createHarness } : {}),
|
|
81
|
+
...(options.createSandboxProvider !== undefined
|
|
82
|
+
? { createSandboxProvider: options.createSandboxProvider }
|
|
83
|
+
: {})
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
function contractFor(input) {
|
|
87
|
+
const timeoutMs = input.options.timeoutMs ?? input.descriptor.policy.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
88
|
+
return {
|
|
89
|
+
version: "warrant.contract.v1",
|
|
90
|
+
runId: `ensemble_${input.candidateId}`,
|
|
91
|
+
issuedAt: new Date().toISOString(),
|
|
92
|
+
issuer: { keyId: "ensemble-claude-code", role: "plane" },
|
|
93
|
+
requestedBy: { kind: "service", id: "handoffkit-ensemble" },
|
|
94
|
+
agent: { kind: "claude-code" },
|
|
95
|
+
task: { prompt: input.descriptor.prompt },
|
|
96
|
+
runner: {
|
|
97
|
+
pool: input.options.pool ??
|
|
98
|
+
input.descriptor.runtime.environmentId ??
|
|
99
|
+
input.descriptor.runtime.id ??
|
|
100
|
+
DEFAULT_POOL
|
|
101
|
+
},
|
|
102
|
+
workspace: {
|
|
103
|
+
version: "warrant.manifest.v1",
|
|
104
|
+
baseRef: (input.repoBaseSha ?? input.descriptor.baseGitSha) || ZERO_GIT_SHA,
|
|
105
|
+
bundleHash: ZERO_HASH,
|
|
106
|
+
untrackedFiles: [],
|
|
107
|
+
deniedPatterns: [],
|
|
108
|
+
deniedPaths: []
|
|
109
|
+
},
|
|
110
|
+
policyHash: ZERO_HASH,
|
|
111
|
+
secrets: input.options.secrets?.map((secret) => ({ name: secret.name, scope: "ensemble" })) ?? [],
|
|
112
|
+
network: input.options.network ??
|
|
113
|
+
(input.descriptor.runtime.isolation?.networkPolicy
|
|
114
|
+
? {
|
|
115
|
+
defaultDeny: input.descriptor.runtime.isolation.networkPolicy.defaultDeny,
|
|
116
|
+
allowHosts: [...input.descriptor.runtime.isolation.networkPolicy.allowHosts]
|
|
117
|
+
}
|
|
118
|
+
: DEFAULT_CLAUDE_NETWORK),
|
|
119
|
+
budget: {
|
|
120
|
+
...(input.descriptor.policy.budgetUsd !== undefined
|
|
121
|
+
? { maxSpendUsd: input.descriptor.policy.budgetUsd }
|
|
122
|
+
: {}),
|
|
123
|
+
maxDurationMin: Math.ceil(timeoutMs / 60_000)
|
|
124
|
+
},
|
|
125
|
+
disclosure: "minimal-context",
|
|
126
|
+
isolation: "vercel-sandbox",
|
|
127
|
+
execution: {
|
|
128
|
+
kind: "agent",
|
|
129
|
+
agent: { kind: "claude-code" },
|
|
130
|
+
prompt: input.descriptor.prompt,
|
|
131
|
+
timeoutMs,
|
|
132
|
+
env: { vars: input.gate.authEnv, egressProxy: false },
|
|
133
|
+
log: {
|
|
134
|
+
stdout: "capture",
|
|
135
|
+
stderr: "merge",
|
|
136
|
+
maxBytes: input.options.logMaxBytes ?? DEFAULT_LOG_MAX_BYTES
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
expiresAt: new Date(Date.now() + timeoutMs).toISOString(),
|
|
140
|
+
signatures: []
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function hardeningFor(input) {
|
|
144
|
+
const networkPolicy = input.options.network ??
|
|
145
|
+
input.descriptor.runtime.isolation?.networkPolicy ??
|
|
146
|
+
DEFAULT_CLAUDE_NETWORK;
|
|
147
|
+
const mountPolicy = input.descriptor.runtime.isolation?.mountPolicy;
|
|
148
|
+
const secretPolicy = input.descriptor.runtime.isolation?.secretPolicy;
|
|
149
|
+
return {
|
|
150
|
+
requested_isolation: "microvm",
|
|
151
|
+
actual_isolation: input.finished ? "vercel-sandbox" : "process",
|
|
152
|
+
runtime: {
|
|
153
|
+
provider: "vercel-sandbox",
|
|
154
|
+
runtime: input.options.runtime ??
|
|
155
|
+
(input.descriptor.runtime.isolation?.kind === "microvm"
|
|
156
|
+
? input.descriptor.runtime.isolation.runtime
|
|
157
|
+
: undefined) ??
|
|
158
|
+
DEFAULT_RUNTIME,
|
|
159
|
+
workdir: mountPolicy?.workdir ?? input.repoDir
|
|
160
|
+
},
|
|
161
|
+
mount_policy: {
|
|
162
|
+
worktree_writable: mountPolicy?.worktreeWritable ?? true,
|
|
163
|
+
read_only_caches: [...(mountPolicy?.readOnlyCachePaths ?? [])],
|
|
164
|
+
ignored_dirs: [...(mountPolicy?.ignoredDirs ?? [".git", "node_modules", ".warrant"])]
|
|
165
|
+
},
|
|
166
|
+
network_policy: {
|
|
167
|
+
default_deny: networkPolicy.defaultDeny,
|
|
168
|
+
allow_hosts: [...networkPolicy.allowHosts],
|
|
169
|
+
enforced: input.finished
|
|
170
|
+
},
|
|
171
|
+
cleanup: input.finished
|
|
172
|
+
? { attempted: true, succeeded: true, status: "succeeded" }
|
|
173
|
+
: { attempted: false, succeeded: true, status: "not_required" },
|
|
174
|
+
secret_absence: {
|
|
175
|
+
secret_names: [
|
|
176
|
+
...(secretPolicy?.secretNames ?? input.options.secrets?.map((secret) => secret.name) ?? [])
|
|
177
|
+
],
|
|
178
|
+
secret_value_hashes: [...(secretPolicy?.secretValueHashes ?? [])],
|
|
179
|
+
injected_env_names: [...(secretPolicy?.injectedEnvNames ?? input.authEnvNames)],
|
|
180
|
+
scanned: false,
|
|
181
|
+
leaks_found: false,
|
|
182
|
+
scan_scope: [],
|
|
183
|
+
leak_count: 0
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function skippedOutput(input) {
|
|
188
|
+
const evidenceHash = artifactHash(input.reason);
|
|
189
|
+
const repoDir = input.runInput.worktree?.path ?? input.runInput.descriptor.sourceRepo;
|
|
190
|
+
return {
|
|
191
|
+
candidateId: candidateId(input.runInput),
|
|
192
|
+
model: input.runInput.model,
|
|
193
|
+
status: "skipped",
|
|
194
|
+
...(input.runInput.worktree
|
|
195
|
+
? {
|
|
196
|
+
branchName: input.runInput.worktree.branchName,
|
|
197
|
+
worktreePath: input.runInput.worktree.path
|
|
198
|
+
}
|
|
199
|
+
: {}),
|
|
200
|
+
transcript: input.reason,
|
|
201
|
+
summary: input.reason,
|
|
202
|
+
error: {
|
|
203
|
+
kind: "capability_missing",
|
|
204
|
+
message: input.reason,
|
|
205
|
+
retryable: false
|
|
206
|
+
},
|
|
207
|
+
verification: {
|
|
208
|
+
status: "skipped",
|
|
209
|
+
evidence: [input.reason, evidenceHash],
|
|
210
|
+
exitCode: 0
|
|
211
|
+
},
|
|
212
|
+
metadata: {
|
|
213
|
+
adapter: "claude-code",
|
|
214
|
+
credential_gate: "skipped",
|
|
215
|
+
missing_credentials: [...input.missing],
|
|
216
|
+
hardening: hardeningToJson(hardeningFor({
|
|
217
|
+
descriptor: input.runInput.descriptor,
|
|
218
|
+
options: input.options,
|
|
219
|
+
repoDir,
|
|
220
|
+
authEnvNames: [],
|
|
221
|
+
finished: false
|
|
222
|
+
}))
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function failureOutput(input) {
|
|
227
|
+
const message = input.error instanceof Error ? input.error.message : String(input.error);
|
|
228
|
+
const errorHash = artifactHash(message);
|
|
229
|
+
const repoDir = input.runInput.worktree?.path ?? input.runInput.descriptor.sourceRepo;
|
|
230
|
+
return {
|
|
231
|
+
candidateId: candidateId(input.runInput),
|
|
232
|
+
model: input.runInput.model,
|
|
233
|
+
status: "failed",
|
|
234
|
+
...(input.runInput.worktree
|
|
235
|
+
? {
|
|
236
|
+
branchName: input.runInput.worktree.branchName,
|
|
237
|
+
worktreePath: input.runInput.worktree.path
|
|
238
|
+
}
|
|
239
|
+
: {}),
|
|
240
|
+
transcript: `Claude Code harness failed: ${message}`,
|
|
241
|
+
error: {
|
|
242
|
+
kind: "provider_error",
|
|
243
|
+
message,
|
|
244
|
+
retryable: true
|
|
245
|
+
},
|
|
246
|
+
verification: {
|
|
247
|
+
status: "failed",
|
|
248
|
+
evidence: [errorHash],
|
|
249
|
+
exitCode: 1
|
|
250
|
+
},
|
|
251
|
+
metadata: {
|
|
252
|
+
adapter: "claude-code",
|
|
253
|
+
credential_gate: "available",
|
|
254
|
+
event_count: 0,
|
|
255
|
+
auth_env_names: [...input.authEnvNames],
|
|
256
|
+
hardening: hardeningToJson(hardeningFor({
|
|
257
|
+
descriptor: input.runInput.descriptor,
|
|
258
|
+
options: input.options,
|
|
259
|
+
repoDir,
|
|
260
|
+
authEnvNames: input.authEnvNames,
|
|
261
|
+
finished: false
|
|
262
|
+
}))
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
export function createClaudeCodeHarness(options = {}) {
|
|
267
|
+
const id = options.id ?? "claude-code";
|
|
268
|
+
const env = options.env ?? process.env;
|
|
269
|
+
const skipWhenUnavailable = options.skipWhenUnavailable ?? true;
|
|
270
|
+
return {
|
|
271
|
+
id,
|
|
272
|
+
harnessKind: "claude_code",
|
|
273
|
+
prepare: () => {
|
|
274
|
+
const gate = credentialGate(env, options);
|
|
275
|
+
if (!gate.available) {
|
|
276
|
+
if (skipWhenUnavailable)
|
|
277
|
+
return { gate };
|
|
278
|
+
throw new CapabilityMismatchError(gate.reason);
|
|
279
|
+
}
|
|
280
|
+
return { gate, backend: backendFor(options, env) };
|
|
281
|
+
},
|
|
282
|
+
capabilities: () => {
|
|
283
|
+
const gate = credentialGate(env, options);
|
|
284
|
+
return {
|
|
285
|
+
workspace_read: gate.available ? "supported" : "degraded",
|
|
286
|
+
workspace_write: gate.available ? "supported" : "degraded",
|
|
287
|
+
apply_patch: gate.available ? "supported" : "degraded",
|
|
288
|
+
tool_records: "supported",
|
|
289
|
+
verification: gate.available ? "supported" : "degraded",
|
|
290
|
+
microvm_isolation: gate.available ? "supported" : "degraded",
|
|
291
|
+
credential_gate: gate.available ? "supported" : "degraded"
|
|
292
|
+
};
|
|
293
|
+
},
|
|
294
|
+
verificationProfile: () => ({
|
|
295
|
+
id: `${id}-verification`,
|
|
296
|
+
requiredEvidence: ["structured transcript", "exit code", "worktree diff or skip reason"]
|
|
297
|
+
}),
|
|
298
|
+
run: async (runInput) => {
|
|
299
|
+
const state = runInput.prepared;
|
|
300
|
+
if (!state.gate.available) {
|
|
301
|
+
return skippedOutput({
|
|
302
|
+
runInput,
|
|
303
|
+
reason: state.gate.reason,
|
|
304
|
+
missing: state.gate.missing,
|
|
305
|
+
options
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
const id = candidateId(runInput);
|
|
309
|
+
const repoDir = runInput.worktree?.path ?? runInput.descriptor.workspace ?? runInput.descriptor.sourceRepo;
|
|
310
|
+
const backend = state.backend ?? backendFor(options, env);
|
|
311
|
+
const contract = contractFor({
|
|
312
|
+
descriptor: runInput.descriptor,
|
|
313
|
+
candidateId: id,
|
|
314
|
+
options,
|
|
315
|
+
gate: state.gate,
|
|
316
|
+
...(runInput.worktree ? { repoBaseSha: runInput.worktree.baseGitSha } : {})
|
|
317
|
+
});
|
|
318
|
+
const events = [];
|
|
319
|
+
const authEnvNames = Object.keys(state.gate.authEnv);
|
|
320
|
+
try {
|
|
321
|
+
const result = await backend.execute({
|
|
322
|
+
contract,
|
|
323
|
+
repoDir,
|
|
324
|
+
secrets: options.secrets ?? [],
|
|
325
|
+
execution: prepareExecution({ contract }),
|
|
326
|
+
emit: (event) => {
|
|
327
|
+
events.push(event);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
const transcript = result.log.toString("utf8");
|
|
331
|
+
const outputHash = artifactHash(transcript);
|
|
332
|
+
const status = result.exitCode === 0 ? "succeeded" : "failed";
|
|
333
|
+
return {
|
|
334
|
+
candidateId: id,
|
|
335
|
+
model: runInput.model,
|
|
336
|
+
status,
|
|
337
|
+
...(runInput.worktree
|
|
338
|
+
? {
|
|
339
|
+
branchName: runInput.worktree.branchName,
|
|
340
|
+
worktreePath: runInput.worktree.path
|
|
341
|
+
}
|
|
342
|
+
: {}),
|
|
343
|
+
transcript,
|
|
344
|
+
toolRecords: [
|
|
345
|
+
{
|
|
346
|
+
execution_id: `exec_${id}`,
|
|
347
|
+
plan_id: `plan_${id}`,
|
|
348
|
+
status,
|
|
349
|
+
output_hash: outputHash
|
|
350
|
+
}
|
|
351
|
+
],
|
|
352
|
+
verification: {
|
|
353
|
+
status,
|
|
354
|
+
evidence: [`exit_code=${result.exitCode}`, outputHash],
|
|
355
|
+
exitCode: result.exitCode
|
|
356
|
+
},
|
|
357
|
+
...(status === "failed"
|
|
358
|
+
? {
|
|
359
|
+
error: {
|
|
360
|
+
kind: "provider_error",
|
|
361
|
+
message: "Claude Code harness exited non-zero",
|
|
362
|
+
retryable: true
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
: {}),
|
|
366
|
+
metadata: {
|
|
367
|
+
adapter: "claude-code",
|
|
368
|
+
backend_isolation: backend.isolation,
|
|
369
|
+
credential_gate: "available",
|
|
370
|
+
event_count: events.length,
|
|
371
|
+
auth_env_names: authEnvNames,
|
|
372
|
+
hardening: hardeningToJson(hardeningFor({
|
|
373
|
+
descriptor: runInput.descriptor,
|
|
374
|
+
options,
|
|
375
|
+
repoDir,
|
|
376
|
+
authEnvNames,
|
|
377
|
+
finished: true
|
|
378
|
+
}))
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
if (skipWhenUnavailable && error instanceof CapabilityMismatchError) {
|
|
384
|
+
return skippedOutput({
|
|
385
|
+
runInput,
|
|
386
|
+
reason: error.message,
|
|
387
|
+
missing: ["capability_mismatch"],
|
|
388
|
+
options
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return failureOutput({ runInput, error, options, authEnvNames });
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
collectArtifacts: () => []
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
export function claudeCodeHarness(options = {}) {
|
|
398
|
+
return createClaudeCodeHarness(options);
|
|
399
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ToolIntegration } from "@fusionkit/tools";
|
|
2
|
+
export declare const claudeTool: ToolIntegration;
|
|
3
|
+
export { claudeCodeHarness, claudeCodeHarnessCredentialSkipReason, createClaudeCodeHarness } from "./harness.js";
|
|
4
|
+
export type { ClaudeCodeHarnessEnv, ClaudeCodeHarnessOptions } from "./harness.js";
|
|
5
|
+
export { claudeEnv, launchClaude } from "./launch.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { claudeCodeHarness, claudeCodeHarnessCredentialSkipReason, createClaudeCodeHarness } from "./harness.js";
|
|
2
|
+
import { launchClaude } from "./launch.js";
|
|
3
|
+
const LIVE_SMOKE_PROMPT = "Read README.md if present, then reply exactly CLAUDE_LIVE_SMOKE_OK. Do not modify files.";
|
|
4
|
+
export const claudeTool = {
|
|
5
|
+
id: "claude",
|
|
6
|
+
aliases: ["claude-code"],
|
|
7
|
+
displayName: "Claude Code",
|
|
8
|
+
pickerHint: "Claude Code",
|
|
9
|
+
binary: "claude",
|
|
10
|
+
modes: ["fusion", "local"],
|
|
11
|
+
harnessKinds: ["claude-code"],
|
|
12
|
+
launch: launchClaude,
|
|
13
|
+
createHarness: (_kind, options) => createClaudeCodeHarness({
|
|
14
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {})
|
|
15
|
+
}),
|
|
16
|
+
harness: {
|
|
17
|
+
harnessKind: "claude_code",
|
|
18
|
+
sideEffects: "writes_workspace",
|
|
19
|
+
responseShape: "Return a Claude Code-style transcript summary with patch/worktree evidence."
|
|
20
|
+
},
|
|
21
|
+
dashboard: {
|
|
22
|
+
id: "claude-code",
|
|
23
|
+
harnessKind: "claude_code",
|
|
24
|
+
displayName: "Claude Code",
|
|
25
|
+
availability: "credential_gated",
|
|
26
|
+
capabilities: {
|
|
27
|
+
model_override: "supported",
|
|
28
|
+
transcript_capture: "supported",
|
|
29
|
+
diff_capture: "supported",
|
|
30
|
+
tool_loop_capture: "supported",
|
|
31
|
+
patch_apply_visibility: "supported",
|
|
32
|
+
route_model_observation: "degraded",
|
|
33
|
+
verification_hint: "supported",
|
|
34
|
+
replay_support: "degraded"
|
|
35
|
+
},
|
|
36
|
+
notes: ["Credential-gated; dashboard smoke uses an empty env skip path."],
|
|
37
|
+
makeMatrixHarness: (env) => claudeCodeHarness({ env }),
|
|
38
|
+
credentialSkipReason: (env) => claudeCodeHarnessCredentialSkipReason(env),
|
|
39
|
+
smoke: {
|
|
40
|
+
taskId: "claude-code-skipped",
|
|
41
|
+
model: { id: "claude", model: "claude-sonnet-4-6" },
|
|
42
|
+
sideEffects: "writes_workspace",
|
|
43
|
+
allowedTools: ["read_file", "write_file", "apply_patch"],
|
|
44
|
+
makeHarness: () => claudeCodeHarness({ env: {} })
|
|
45
|
+
},
|
|
46
|
+
liveSmoke: {
|
|
47
|
+
taskId: "claude-code-live",
|
|
48
|
+
envName: "FUSIONKIT_CLAUDE_SMOKE",
|
|
49
|
+
prompt: LIVE_SMOKE_PROMPT,
|
|
50
|
+
modelEnvName: "FUSIONKIT_CLAUDE_SMOKE_MODEL",
|
|
51
|
+
defaultModel: "claude-sonnet-4-6",
|
|
52
|
+
makeHarness: (env) => claudeCodeHarness({ env, skipWhenUnavailable: false })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
export { claudeCodeHarness, claudeCodeHarnessCredentialSkipReason, createClaudeCodeHarness } from "./harness.js";
|
|
57
|
+
export { claudeEnv, launchClaude } from "./launch.js";
|
package/dist/launch.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ToolLaunchContext } from "@fusionkit/tools";
|
|
2
|
+
/** Environment for Claude Code: point it at the gateway's Anthropic surface. */
|
|
3
|
+
export declare function claudeEnv(gatewayUrl: string, authToken?: string): Record<string, string>;
|
|
4
|
+
/** Boot the Claude Code CLI pointed at the gateway's Anthropic surface. */
|
|
5
|
+
export declare function launchClaude(ctx: ToolLaunchContext): Promise<number>;
|
package/dist/launch.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { LOCAL_MODEL_LABEL, spawnTool } from "@fusionkit/tools";
|
|
2
|
+
/** Environment for Claude Code: point it at the gateway's Anthropic surface. */
|
|
3
|
+
export function claudeEnv(gatewayUrl, authToken) {
|
|
4
|
+
return {
|
|
5
|
+
ANTHROPIC_BASE_URL: gatewayUrl,
|
|
6
|
+
ANTHROPIC_AUTH_TOKEN: authToken ?? LOCAL_MODEL_LABEL,
|
|
7
|
+
// Surface the local model in the /model picker (Anthropic discovery).
|
|
8
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1"
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** Boot the Claude Code CLI pointed at the gateway's Anthropic surface. */
|
|
12
|
+
export async function launchClaude(ctx) {
|
|
13
|
+
ctx.prepareForPassthrough();
|
|
14
|
+
if (ctx.mode === "fusion") {
|
|
15
|
+
ctx.log("fusion: launching claude...");
|
|
16
|
+
}
|
|
17
|
+
return await spawnTool("claude", ctx.toolArgs, claudeEnv(ctx.gatewayUrl, ctx.authToken), ctx.repo);
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assertHarnessRunResultV1, requestHash } from "@fusionkit/protocol";
|
|
7
|
+
import { gitText } from "@fusionkit/workspace";
|
|
8
|
+
import { createMockHarness, runEnsemble } from "@fusionkit/ensemble";
|
|
9
|
+
import { claudeCodeHarness, claudeCodeHarnessCredentialSkipReason } from "../index.js";
|
|
10
|
+
const BASE_DESCRIPTOR = {
|
|
11
|
+
id: "ensemble_test",
|
|
12
|
+
models: [{ id: "claude", model: "claude-sonnet-4-6" }],
|
|
13
|
+
runtime: { id: "local" },
|
|
14
|
+
judge: { id: "judge", model: "fake-judge" },
|
|
15
|
+
policy: {
|
|
16
|
+
id: "policy",
|
|
17
|
+
allowedTools: ["read_file"],
|
|
18
|
+
sideEffects: "read_only",
|
|
19
|
+
timeoutMs: 1_000
|
|
20
|
+
},
|
|
21
|
+
prompt: "Summarize model-fusion evidence.",
|
|
22
|
+
sourceRepo: "handoffkit",
|
|
23
|
+
baseGitSha: "a".repeat(40)
|
|
24
|
+
};
|
|
25
|
+
function descriptor(overrides = {}) {
|
|
26
|
+
return {
|
|
27
|
+
...BASE_DESCRIPTOR,
|
|
28
|
+
harness: createMockHarness(),
|
|
29
|
+
...overrides
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function makeRepo() {
|
|
33
|
+
const root = mkdtempSync(join(tmpdir(), "ensemble-repo-"));
|
|
34
|
+
const repo = join(root, "repo");
|
|
35
|
+
mkdirSync(repo);
|
|
36
|
+
gitText(repo, ["init", "--quiet", "--initial-branch=main"]);
|
|
37
|
+
gitText(repo, ["config", "user.email", "ensemble@warrant.local"]);
|
|
38
|
+
gitText(repo, ["config", "user.name", "ensemble"]);
|
|
39
|
+
writeFileSync(join(repo, "README.md"), "# ensemble\n");
|
|
40
|
+
gitText(repo, ["add", "-A"]);
|
|
41
|
+
gitText(repo, ["commit", "--quiet", "-m", "init"]);
|
|
42
|
+
return {
|
|
43
|
+
repo,
|
|
44
|
+
outputRoot: join(root, "out"),
|
|
45
|
+
head: gitText(repo, ["rev-parse", "HEAD"]).trim(),
|
|
46
|
+
cleanup: () => rmSync(root, { recursive: true, force: true })
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function liveClaudeSmokeSkipReason() {
|
|
50
|
+
if ((process.env.FUSIONKIT_CLAUDE_SMOKE ?? process.env.WARRANT_CLAUDE_SMOKE) !== "1") {
|
|
51
|
+
return "set FUSIONKIT_CLAUDE_SMOKE=1 plus Claude Code credentials to run the live Claude Code smoke";
|
|
52
|
+
}
|
|
53
|
+
return claudeCodeHarnessCredentialSkipReason() ?? false;
|
|
54
|
+
}
|
|
55
|
+
test("claude-code adapter can replace mock and skip clearly without credentials", async () => {
|
|
56
|
+
const result = await runEnsemble(descriptor({
|
|
57
|
+
models: [{ id: "claude", model: "claude-sonnet-4-6" }],
|
|
58
|
+
harness: claudeCodeHarness({ env: {} })
|
|
59
|
+
}));
|
|
60
|
+
assert.equal(result.candidates.length, 1);
|
|
61
|
+
assert.equal(result.harnessRunResult.status, "skipped");
|
|
62
|
+
assert.equal(result.candidates[0]?.status, "skipped");
|
|
63
|
+
assert.equal(result.candidates[0]?.error?.kind, "capability_missing");
|
|
64
|
+
assert.match(result.candidates[0]?.error?.message ?? "", /missing Claude Code credential/);
|
|
65
|
+
assert.match(result.summary?.candidates[0]?.verification?.evidence[0] ?? "", /missing Claude/);
|
|
66
|
+
});
|
|
67
|
+
test("claude-code adapter delegates through a session backend from a generic descriptor", async () => {
|
|
68
|
+
const repo = makeRepo();
|
|
69
|
+
const seen = {};
|
|
70
|
+
const backend = {
|
|
71
|
+
isolation: "vercel-sandbox",
|
|
72
|
+
supports: () => true,
|
|
73
|
+
execute: async (input) => {
|
|
74
|
+
seen.agentKind = input.contract.agent.kind;
|
|
75
|
+
seen.env = input.execution.env;
|
|
76
|
+
seen.repoDir = input.repoDir;
|
|
77
|
+
assert.equal(input.contract.isolation, "vercel-sandbox");
|
|
78
|
+
assert.equal(input.contract.execution?.kind, "agent");
|
|
79
|
+
assert.equal(input.secrets.length, 0);
|
|
80
|
+
writeFileSync(join(input.repoDir, "CLAUDE_RESULT.md"), "fake claude result\n");
|
|
81
|
+
input.emit({
|
|
82
|
+
type: "command.executed",
|
|
83
|
+
argvHash: requestHash({ adapter: "claude-code" }),
|
|
84
|
+
exitCode: 0
|
|
85
|
+
});
|
|
86
|
+
return { exitCode: 0, log: Buffer.from("fake claude transcript") };
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
try {
|
|
90
|
+
const result = await runEnsemble(descriptor({
|
|
91
|
+
models: [{ id: "claude", model: "claude-sonnet-4-6" }],
|
|
92
|
+
harness: claudeCodeHarness({
|
|
93
|
+
env: {
|
|
94
|
+
ANTHROPIC_API_KEY: "sk-ant-test",
|
|
95
|
+
VERCEL_TOKEN: "vercel-test"
|
|
96
|
+
},
|
|
97
|
+
backend
|
|
98
|
+
}),
|
|
99
|
+
workspace: repo.repo,
|
|
100
|
+
baseGitSha: repo.head,
|
|
101
|
+
outputRoot: repo.outputRoot,
|
|
102
|
+
cleanupWorktrees: true
|
|
103
|
+
}));
|
|
104
|
+
assert.equal(result.harnessRunResult.status, "succeeded");
|
|
105
|
+
assert.equal(result.candidates[0]?.status, "succeeded");
|
|
106
|
+
assert.equal(seen.agentKind, "claude-code");
|
|
107
|
+
assert.equal(seen.env?.ANTHROPIC_API_KEY, "sk-ant-test");
|
|
108
|
+
assert.equal(Object.hasOwn(seen.env ?? {}, "VERCEL_TOKEN"), false);
|
|
109
|
+
assert.notEqual(seen.repoDir, repo.repo);
|
|
110
|
+
assert.ok(result.artifacts.some((artifact) => artifact.kind === "patch"));
|
|
111
|
+
assert.match(result.candidates[0]?.metadata?.adapter, /claude-code/);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
repo.cleanup();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
test("smoke: claude-code adapter runs live when credentials are available", { skip: liveClaudeSmokeSkipReason() }, async () => {
|
|
118
|
+
const repo = makeRepo();
|
|
119
|
+
try {
|
|
120
|
+
const result = await runEnsemble(descriptor({
|
|
121
|
+
id: "claude_smoke",
|
|
122
|
+
models: [{ id: "claude", model: "claude-sonnet-4-6" }],
|
|
123
|
+
harness: claudeCodeHarness(),
|
|
124
|
+
runtime: {
|
|
125
|
+
id: "vercel-sandbox",
|
|
126
|
+
isolation: {
|
|
127
|
+
kind: "microvm",
|
|
128
|
+
networkPolicy: {
|
|
129
|
+
defaultDeny: true,
|
|
130
|
+
allowHosts: [
|
|
131
|
+
"registry.npmjs.org",
|
|
132
|
+
"api.anthropic.com",
|
|
133
|
+
"ai-gateway.vercel.sh"
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
policy: {
|
|
139
|
+
id: "claude-smoke-policy",
|
|
140
|
+
allowedTools: ["read_file"],
|
|
141
|
+
sideEffects: "read_only",
|
|
142
|
+
timeoutMs: 180_000
|
|
143
|
+
},
|
|
144
|
+
prompt: "Read README.md if present, then reply exactly CLAUDE_LIVE_SMOKE_OK. Do not modify files.",
|
|
145
|
+
workspace: repo.repo,
|
|
146
|
+
baseGitSha: repo.head,
|
|
147
|
+
outputRoot: repo.outputRoot,
|
|
148
|
+
cleanupWorktrees: true
|
|
149
|
+
}));
|
|
150
|
+
assertHarnessRunResultV1(result.harnessRunResult);
|
|
151
|
+
assert.equal(result.harnessRunResult.status, "succeeded");
|
|
152
|
+
assert.equal(result.candidates[0]?.status, "succeeded");
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
repo.cleanup();
|
|
156
|
+
}
|
|
157
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fusionkit/tool-claude",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.1.6",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/velum-labs/handoffkit.git",
|
|
8
|
+
"directory": "packages/tool-claude"
|
|
9
|
+
},
|
|
10
|
+
"description": "Claude Code tool integration for fusionkit: launcher env shim plus the ensemble Claude Code harness adapter.",
|
|
11
|
+
"license": "UNLICENSED",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"registry": "https://registry.npmjs.org",
|
|
24
|
+
"access": "public",
|
|
25
|
+
"provenance": true
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@fusionkit/ensemble": "0.1.6",
|
|
29
|
+
"@fusionkit/protocol": "0.1.6",
|
|
30
|
+
"@fusionkit/runner": "0.1.6",
|
|
31
|
+
"@fusionkit/session-harness": "0.1.6",
|
|
32
|
+
"@fusionkit/tools": "0.1.6"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@fusionkit/workspace": "0.1.6"
|
|
36
|
+
}
|
|
37
|
+
}
|