@fusionkit/session-harness 0.1.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/dist/auth.d.ts +34 -0
- package/dist/auth.js +119 -0
- package/dist/backend.d.ts +63 -0
- package/dist/backend.js +154 -0
- package/dist/claude-code.d.ts +57 -0
- package/dist/claude-code.js +84 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +18 -0
- package/dist/pi.d.ts +66 -0
- package/dist/pi.js +72 -0
- package/dist/test/fakes.d.ts +24 -0
- package/dist/test/fakes.js +187 -0
- package/dist/test/harness.test.d.ts +1 -0
- package/dist/test/harness.test.js +275 -0
- package/dist/test/pi.test.d.ts +1 -0
- package/dist/test/pi.test.js +135 -0
- package/dist/transcript.d.ts +33 -0
- package/dist/transcript.js +214 -0
- package/package.json +45 -0
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed mapping from a governed session's resolved environment (the
|
|
3
|
+
* contract's env policy plus broker-released secrets) onto the explicit
|
|
4
|
+
* auth settings of the `@ai-sdk/harness-claude-code` adapter.
|
|
5
|
+
*
|
|
6
|
+
* The adapter's default behavior resolves credentials from the *host*
|
|
7
|
+
* process environment — which on a Warrant runner would bypass the secret
|
|
8
|
+
* broker entirely (the runner operator's own ANTHROPIC_API_KEY would leak
|
|
9
|
+
* into the session). This module therefore always constructs an explicit
|
|
10
|
+
* auth object, and fills unset credential fields with empty strings: the
|
|
11
|
+
* adapter treats "" as present-but-falsy, so it neither falls back to the
|
|
12
|
+
* host environment nor exports the variable into the bridge.
|
|
13
|
+
*/
|
|
14
|
+
import type { ClaudeCodeAuthOptions } from "@ai-sdk/harness-claude-code";
|
|
15
|
+
import type { PiAuthOptions } from "@ai-sdk/harness-pi";
|
|
16
|
+
/**
|
|
17
|
+
* Build the explicit claude-code auth settings from the session env.
|
|
18
|
+
*
|
|
19
|
+
* Fails closed when the env carries variables this path cannot deliver to
|
|
20
|
+
* the agent runtime, and when no credential is present at all (the adapter
|
|
21
|
+
* would otherwise fall back to the runner host's own credentials).
|
|
22
|
+
*/
|
|
23
|
+
export declare function claudeCodeAuthFromEnv(env: Record<string, string>): ClaudeCodeAuthOptions;
|
|
24
|
+
/**
|
|
25
|
+
* Build explicit Pi auth from the session env, fail-closed.
|
|
26
|
+
*
|
|
27
|
+
* Pi's default resolution reaches into the *host* process environment for an
|
|
28
|
+
* AI Gateway key or `VERCEL_OIDC_TOKEN`. On a Warrant runner that would
|
|
29
|
+
* bypass the secret broker, so this always passes an explicit `customEnv`
|
|
30
|
+
* built only from broker-released vars. It fails closed when the env carries
|
|
31
|
+
* a variable Pi cannot honor, and when no provider key is present at all
|
|
32
|
+
* (otherwise Pi would fall back to the host environment).
|
|
33
|
+
*/
|
|
34
|
+
export declare function piAuthFromEnv(env: Record<string, string>): PiAuthOptions;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { CapabilityMismatchError } from "@fusionkit/runner";
|
|
2
|
+
/**
|
|
3
|
+
* The only environment variables this harness path can honor: the adapter
|
|
4
|
+
* forwards exactly these to the in-sandbox bridge. Pinned here so anything
|
|
5
|
+
* else in the contract's env policy fails closed instead of being silently
|
|
6
|
+
* dropped.
|
|
7
|
+
*/
|
|
8
|
+
const SUPPORTED_AUTH_VARS = [
|
|
9
|
+
"AI_GATEWAY_API_KEY",
|
|
10
|
+
"AI_GATEWAY_BASE_URL",
|
|
11
|
+
"ANTHROPIC_API_KEY",
|
|
12
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
13
|
+
"ANTHROPIC_BASE_URL"
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* The adapter's documented default gateway URL. Passed explicitly when the
|
|
17
|
+
* session selects gateway auth without a base URL, so the adapter never
|
|
18
|
+
* consults the host environment for it.
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh";
|
|
21
|
+
/**
|
|
22
|
+
* Build the explicit claude-code auth settings from the session env.
|
|
23
|
+
*
|
|
24
|
+
* Fails closed when the env carries variables this path cannot deliver to
|
|
25
|
+
* the agent runtime, and when no credential is present at all (the adapter
|
|
26
|
+
* would otherwise fall back to the runner host's own credentials).
|
|
27
|
+
*/
|
|
28
|
+
export function claudeCodeAuthFromEnv(env) {
|
|
29
|
+
const supported = new Set(SUPPORTED_AUTH_VARS);
|
|
30
|
+
const unsupported = Object.keys(env).filter((name) => !supported.has(name));
|
|
31
|
+
if (unsupported.length > 0) {
|
|
32
|
+
throw new CapabilityMismatchError(`ai-sdk harness backend cannot deliver env vars [${unsupported.join(", ")}] ` +
|
|
33
|
+
`to the agent runtime; supported: ${SUPPORTED_AUTH_VARS.join(", ")}`);
|
|
34
|
+
}
|
|
35
|
+
if (env.AI_GATEWAY_API_KEY) {
|
|
36
|
+
return {
|
|
37
|
+
gateway: {
|
|
38
|
+
apiKey: env.AI_GATEWAY_API_KEY,
|
|
39
|
+
baseUrl: env.AI_GATEWAY_BASE_URL || DEFAULT_GATEWAY_BASE_URL
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN) {
|
|
44
|
+
// Empty strings deliberately occupy the unset fields: the adapter's
|
|
45
|
+
// `explicit ?? process.env.*` resolution then never reaches the host
|
|
46
|
+
// environment, and falsy values are not exported to the bridge.
|
|
47
|
+
return {
|
|
48
|
+
anthropic: {
|
|
49
|
+
apiKey: env.ANTHROPIC_API_KEY ?? "",
|
|
50
|
+
authToken: env.ANTHROPIC_AUTH_TOKEN ?? "",
|
|
51
|
+
baseUrl: env.ANTHROPIC_BASE_URL ?? ""
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
throw new CapabilityMismatchError("claude-code over the ai-sdk harness requires a credential released into the " +
|
|
56
|
+
"session env (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, or AI_GATEWAY_API_KEY); " +
|
|
57
|
+
"refusing to fall back to the runner host environment");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The provider env-var pairs the Pi adapter understands. Pi maps each
|
|
61
|
+
* `<PREFIX>_API_KEY` (with an optional `<PREFIX>_BASE_URL`) to a provider:
|
|
62
|
+
* OPENAI → openai, ANTHROPIC → anthropic, AI_GATEWAY → vercel-ai-gateway.
|
|
63
|
+
* For the swarm's local-model workers the meaningful pair is
|
|
64
|
+
* `OPENAI_BASE_URL` + `OPENAI_API_KEY` pointing at a local OpenAI-compatible
|
|
65
|
+
* endpoint (Ollama / mlx-lm), where the key is typically a dummy value.
|
|
66
|
+
*
|
|
67
|
+
* Pinned here so anything else in the contract's env policy fails closed
|
|
68
|
+
* rather than being silently dropped, exactly like the claude-code path.
|
|
69
|
+
*/
|
|
70
|
+
const PI_SUPPORTED_AUTH_VARS = [
|
|
71
|
+
"OPENAI_API_KEY",
|
|
72
|
+
"OPENAI_BASE_URL",
|
|
73
|
+
"AI_GATEWAY_API_KEY",
|
|
74
|
+
"AI_GATEWAY_BASE_URL",
|
|
75
|
+
"ANTHROPIC_API_KEY",
|
|
76
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
77
|
+
"ANTHROPIC_BASE_URL"
|
|
78
|
+
];
|
|
79
|
+
/** A `<PREFIX>_API_KEY` is what actually selects a provider for Pi. */
|
|
80
|
+
const PI_API_KEY_VARS = [
|
|
81
|
+
"OPENAI_API_KEY",
|
|
82
|
+
"AI_GATEWAY_API_KEY",
|
|
83
|
+
"ANTHROPIC_API_KEY",
|
|
84
|
+
"ANTHROPIC_AUTH_TOKEN"
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* Build explicit Pi auth from the session env, fail-closed.
|
|
88
|
+
*
|
|
89
|
+
* Pi's default resolution reaches into the *host* process environment for an
|
|
90
|
+
* AI Gateway key or `VERCEL_OIDC_TOKEN`. On a Warrant runner that would
|
|
91
|
+
* bypass the secret broker, so this always passes an explicit `customEnv`
|
|
92
|
+
* built only from broker-released vars. It fails closed when the env carries
|
|
93
|
+
* a variable Pi cannot honor, and when no provider key is present at all
|
|
94
|
+
* (otherwise Pi would fall back to the host environment).
|
|
95
|
+
*/
|
|
96
|
+
export function piAuthFromEnv(env) {
|
|
97
|
+
const supported = new Set(PI_SUPPORTED_AUTH_VARS);
|
|
98
|
+
const unsupported = Object.keys(env).filter((name) => !supported.has(name));
|
|
99
|
+
if (unsupported.length > 0) {
|
|
100
|
+
throw new CapabilityMismatchError(`pi over the ai-sdk harness cannot deliver env vars [${unsupported.join(", ")}] ` +
|
|
101
|
+
`to the agent runtime; supported: ${PI_SUPPORTED_AUTH_VARS.join(", ")}`);
|
|
102
|
+
}
|
|
103
|
+
const hasKey = PI_API_KEY_VARS.some((name) => env[name]);
|
|
104
|
+
if (!hasKey) {
|
|
105
|
+
throw new CapabilityMismatchError("pi over the ai-sdk harness requires a provider credential released into the " +
|
|
106
|
+
"session env (OPENAI_API_KEY with OPENAI_BASE_URL for a local endpoint, " +
|
|
107
|
+
"ANTHROPIC_API_KEY, or AI_GATEWAY_API_KEY); refusing to fall back to the " +
|
|
108
|
+
"runner host environment");
|
|
109
|
+
}
|
|
110
|
+
// Forward only the recognized, present pairs as resolved customEnv. Pi
|
|
111
|
+
// honors customEnv ahead of any ambient gateway credential, so the host
|
|
112
|
+
// environment is never consulted.
|
|
113
|
+
const customEnv = {};
|
|
114
|
+
for (const name of PI_SUPPORTED_AUTH_VARS) {
|
|
115
|
+
if (env[name])
|
|
116
|
+
customEnv[name] = env[name];
|
|
117
|
+
}
|
|
118
|
+
return { customEnv };
|
|
119
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { HarnessAgentAdapter, HarnessAgentSettings } from "@ai-sdk/harness/agent";
|
|
2
|
+
import type { AgentKind, RunContract, SessionIsolation } from "@fusionkit/protocol";
|
|
3
|
+
import type { BackendExecutionKind, SessionBackend, SessionBackendResult, SessionExecution } from "@fusionkit/runner";
|
|
4
|
+
/**
|
|
5
|
+
* A harness adapter regardless of its builtin tool set, typed from the agent
|
|
6
|
+
* module itself so the seam always matches what `HarnessAgent` accepts.
|
|
7
|
+
* (`HarnessV1`'s default `ToolSet` parameter rejects concrete adapters like
|
|
8
|
+
* `createClaudeCode()`/`createPi()` because tool input schemas are not
|
|
9
|
+
* assignable across the index signature; the AI SDK's own settings use the
|
|
10
|
+
* same `any`-tools parameterization.)
|
|
11
|
+
*/
|
|
12
|
+
export type HarnessAdapter = HarnessAgentAdapter<any>;
|
|
13
|
+
/**
|
|
14
|
+
* The sandbox provider type `HarnessAgent` expects, derived from its settings
|
|
15
|
+
* rather than imported from `@ai-sdk/harness` directly: pnpm can instantiate
|
|
16
|
+
* that package once per zod peer context, and deriving the type keeps this
|
|
17
|
+
* seam pinned to the agent's own instance.
|
|
18
|
+
*/
|
|
19
|
+
export type HarnessSandboxProvider = HarnessAgentSettings["sandbox"];
|
|
20
|
+
export type CreateHarnessInput = {
|
|
21
|
+
/** Resolved session env: contract env policy plus released secrets. */
|
|
22
|
+
env: Record<string, string>;
|
|
23
|
+
contract: RunContract;
|
|
24
|
+
};
|
|
25
|
+
export type CreateSandboxProviderInput = {
|
|
26
|
+
contract: RunContract;
|
|
27
|
+
timeoutMs: number;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Everything a single harness contributes to the generic backend: which agent
|
|
31
|
+
* kind it serves, the isolation tier its runs are labeled with, how to build
|
|
32
|
+
* the adapter and the sandbox provider for a given session, and any
|
|
33
|
+
* session-state directories that are runtime plumbing rather than workspace
|
|
34
|
+
* output (excluded from both staging and mirror-back).
|
|
35
|
+
*/
|
|
36
|
+
export type HarnessBinding = {
|
|
37
|
+
readonly agentKind: AgentKind;
|
|
38
|
+
readonly isolation: SessionIsolation;
|
|
39
|
+
createHarness(input: CreateHarnessInput): HarnessAdapter | Promise<HarnessAdapter>;
|
|
40
|
+
createSandboxProvider(input: CreateSandboxProviderInput): HarnessSandboxProvider | Promise<HarnessSandboxProvider>;
|
|
41
|
+
readonly extraIgnores?: readonly string[];
|
|
42
|
+
};
|
|
43
|
+
/** True when the contract's execution is an agent run of the given kind. */
|
|
44
|
+
export declare function isAgentRunFor(contract: RunContract, kind: AgentKind): boolean;
|
|
45
|
+
export declare class AiSdkHarnessBackend implements SessionBackend {
|
|
46
|
+
readonly isolation: SessionIsolation;
|
|
47
|
+
private readonly binding;
|
|
48
|
+
private readonly fallback;
|
|
49
|
+
private readonly ignoredSegments;
|
|
50
|
+
constructor(input: {
|
|
51
|
+
binding: HarnessBinding;
|
|
52
|
+
fallback: SessionBackend;
|
|
53
|
+
});
|
|
54
|
+
private isBindingRun;
|
|
55
|
+
supports(kind: BackendExecutionKind, contract: RunContract): boolean;
|
|
56
|
+
execute(input: SessionExecution): Promise<SessionBackendResult>;
|
|
57
|
+
private executeHarness;
|
|
58
|
+
}
|
|
59
|
+
/** Host a single harness binding plus a fallback backend for its tier. */
|
|
60
|
+
export declare function harnessBackend(input: {
|
|
61
|
+
binding: HarnessBinding;
|
|
62
|
+
fallback: SessionBackend;
|
|
63
|
+
}): AiSdkHarnessBackend;
|
package/dist/backend.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic AI SDK harness session backend: drives one vendor agent harness
|
|
3
|
+
* through the AI SDK harness abstraction (`HarnessAgent`) inside a sandbox,
|
|
4
|
+
* under the same governed-session contract as every other backend.
|
|
5
|
+
*
|
|
6
|
+
* What varies between harnesses — which adapter runs, which sandbox provider
|
|
7
|
+
* hosts it, which isolation tier the run is labeled with, and how credentials
|
|
8
|
+
* map into the adapter's explicit auth — is captured by a `HarnessBinding`.
|
|
9
|
+
* Everything that does *not* vary lives here once: workspace staging into the
|
|
10
|
+
* sandbox, the structured transcript artifact, git-based mirror-back, the
|
|
11
|
+
* boundary event, and delegation of non-matching runs to a fallback backend.
|
|
12
|
+
*
|
|
13
|
+
* Concrete bindings live alongside this file: `claudeCodeBinding` (Claude
|
|
14
|
+
* Code in a Vercel Sandbox microVM) and `piBinding` (Pi on a local just-bash
|
|
15
|
+
* sandbox driving a local model). A backend hosts exactly one binding plus a
|
|
16
|
+
* fallback for everything else in its tier — the single sanctioned way to
|
|
17
|
+
* combine behaviors within an isolation tier (see runner `runSession`).
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { HarnessAgent } from "@ai-sdk/harness/agent";
|
|
22
|
+
import { CapabilityMismatchError, executionHash, executionSpecFor, resolveSessionEnv } from "@fusionkit/runner";
|
|
23
|
+
import { SANDBOX_IGNORED_DIRS, listWorkspaceFiles, shellQuote, writeMirroredFile } from "@fusionkit/session-vercel-sandbox";
|
|
24
|
+
import { TranscriptRecorder } from "./transcript.js";
|
|
25
|
+
function posixJoin(base, rel) {
|
|
26
|
+
return `${base}/${rel.split("\\").join("/")}`;
|
|
27
|
+
}
|
|
28
|
+
/** True when the contract's execution is an agent run of the given kind. */
|
|
29
|
+
export function isAgentRunFor(contract, kind) {
|
|
30
|
+
const spec = executionSpecFor(contract);
|
|
31
|
+
return spec.kind === "agent" && spec.agent.kind === kind;
|
|
32
|
+
}
|
|
33
|
+
export class AiSdkHarnessBackend {
|
|
34
|
+
isolation;
|
|
35
|
+
binding;
|
|
36
|
+
fallback;
|
|
37
|
+
ignoredSegments;
|
|
38
|
+
constructor(input) {
|
|
39
|
+
this.binding = input.binding;
|
|
40
|
+
this.fallback = input.fallback;
|
|
41
|
+
this.isolation = input.binding.isolation;
|
|
42
|
+
this.ignoredSegments = new Set([
|
|
43
|
+
...SANDBOX_IGNORED_DIRS,
|
|
44
|
+
...(input.binding.extraIgnores ?? [])
|
|
45
|
+
]);
|
|
46
|
+
}
|
|
47
|
+
isBindingRun(contract) {
|
|
48
|
+
return isAgentRunFor(contract, this.binding.agentKind);
|
|
49
|
+
}
|
|
50
|
+
supports(kind, contract) {
|
|
51
|
+
if (this.isBindingRun(contract))
|
|
52
|
+
return true;
|
|
53
|
+
return this.fallback.supports ? this.fallback.supports(kind, contract) : true;
|
|
54
|
+
}
|
|
55
|
+
async execute(input) {
|
|
56
|
+
if (!this.isBindingRun(input.contract)) {
|
|
57
|
+
return this.fallback.execute(input);
|
|
58
|
+
}
|
|
59
|
+
return this.executeHarness(input);
|
|
60
|
+
}
|
|
61
|
+
async executeHarness(input) {
|
|
62
|
+
const { contract, repoDir, secrets, execution, emit } = input;
|
|
63
|
+
const spec = executionSpecFor(contract);
|
|
64
|
+
if (spec.kind !== "agent") {
|
|
65
|
+
throw new CapabilityMismatchError("harness path requires an agent execution spec");
|
|
66
|
+
}
|
|
67
|
+
const env = resolveSessionEnv(execution.env, secrets);
|
|
68
|
+
const extraIgnores = this.binding.extraIgnores ?? [];
|
|
69
|
+
const harness = await this.binding.createHarness({ env, contract });
|
|
70
|
+
const provider = await this.binding.createSandboxProvider({
|
|
71
|
+
contract,
|
|
72
|
+
timeoutMs: execution.timeoutMs
|
|
73
|
+
});
|
|
74
|
+
// One signal covers session creation, sandbox startup, and the turn.
|
|
75
|
+
const abortSignal = AbortSignal.timeout(execution.timeoutMs);
|
|
76
|
+
let staged;
|
|
77
|
+
const agent = new HarnessAgent({
|
|
78
|
+
harness,
|
|
79
|
+
sandbox: provider,
|
|
80
|
+
onSandboxSession: async ({ session, sessionWorkDir }) => {
|
|
81
|
+
staged = { session, workDir: sessionWorkDir };
|
|
82
|
+
for (const rel of listWorkspaceFiles(repoDir, extraIgnores)) {
|
|
83
|
+
await session.writeBinaryFile({
|
|
84
|
+
path: posixJoin(sessionWorkDir, rel),
|
|
85
|
+
content: readFileSync(join(repoDir, rel))
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
const transcript = new TranscriptRecorder();
|
|
91
|
+
const session = await agent.createSession({ abortSignal });
|
|
92
|
+
try {
|
|
93
|
+
try {
|
|
94
|
+
const result = await agent.stream({
|
|
95
|
+
session,
|
|
96
|
+
prompt: spec.prompt,
|
|
97
|
+
abortSignal
|
|
98
|
+
});
|
|
99
|
+
for await (const part of result.fullStream) {
|
|
100
|
+
transcript.ingest(part);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
// A failed turn is still evidence: record it, mirror back whatever
|
|
105
|
+
// the harness already changed, and report a non-zero exit code.
|
|
106
|
+
transcript.fail(error);
|
|
107
|
+
}
|
|
108
|
+
if (staged) {
|
|
109
|
+
await mirrorBack(staged.session, staged.workDir, repoDir, this.ignoredSegments);
|
|
110
|
+
}
|
|
111
|
+
const exitCode = transcript.exitCode();
|
|
112
|
+
emit({
|
|
113
|
+
type: "command.executed",
|
|
114
|
+
argvHash: executionHash(execution),
|
|
115
|
+
exitCode
|
|
116
|
+
});
|
|
117
|
+
return { exitCode, log: transcript.toBuffer(execution.logMaxBytes) };
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await session.destroy().catch(() => undefined);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Mirror the session working tree back onto the local checkout so the runner's
|
|
126
|
+
* standard git-based output collection sees the changes. The sandbox FS
|
|
127
|
+
* surface has no directory listing, so the file list comes from one `find`
|
|
128
|
+
* inside the sandbox; every path is validated before it is written inside the
|
|
129
|
+
* local workspace.
|
|
130
|
+
*/
|
|
131
|
+
async function mirrorBack(session, workDir, repoDir, ignoredSegments) {
|
|
132
|
+
const listing = await session.run({
|
|
133
|
+
command: `find ${shellQuote(workDir)} -type f`
|
|
134
|
+
});
|
|
135
|
+
if (listing.exitCode !== 0) {
|
|
136
|
+
throw new CapabilityMismatchError(`harness mirror-back failed to list session files: ${listing.stderr}`);
|
|
137
|
+
}
|
|
138
|
+
for (const line of listing.stdout.split("\n")) {
|
|
139
|
+
const remote = line.trim();
|
|
140
|
+
if (!remote.startsWith(`${workDir}/`))
|
|
141
|
+
continue;
|
|
142
|
+
const rel = remote.slice(workDir.length + 1);
|
|
143
|
+
if (rel.split("/").some((segment) => ignoredSegments.has(segment)))
|
|
144
|
+
continue;
|
|
145
|
+
const content = await session.readBinaryFile({ path: remote });
|
|
146
|
+
if (content === null)
|
|
147
|
+
continue;
|
|
148
|
+
writeMirroredFile(repoDir, rel, content);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Host a single harness binding plus a fallback backend for its tier. */
|
|
152
|
+
export function harnessBackend(input) {
|
|
153
|
+
return new AiSdkHarnessBackend(input);
|
|
154
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { RunContract } from "@fusionkit/protocol";
|
|
2
|
+
import type { SessionBackend } from "@fusionkit/runner";
|
|
3
|
+
import { AiSdkHarnessBackend } from "./backend.js";
|
|
4
|
+
import type { CreateHarnessInput, CreateSandboxProviderInput, HarnessAdapter, HarnessBinding, HarnessSandboxProvider } from "./backend.js";
|
|
5
|
+
export type ClaudeCodeBindingOptions = {
|
|
6
|
+
/** Sandbox runtime image for the harness bridge. Defaults to node24. */
|
|
7
|
+
runtime?: string;
|
|
8
|
+
/** Sandbox port the in-VM bridge listens on. Defaults to 4000. */
|
|
9
|
+
bridgePort?: number;
|
|
10
|
+
/** Vercel credentials; fall back to the ambient environment. */
|
|
11
|
+
token?: string;
|
|
12
|
+
teamId?: string;
|
|
13
|
+
projectId?: string;
|
|
14
|
+
/** Anthropic model id passed to the claude-code runtime. */
|
|
15
|
+
model?: string;
|
|
16
|
+
/** Cap on internal harness turns before yielding. */
|
|
17
|
+
maxTurns?: number;
|
|
18
|
+
/** Extended-thinking behavior of the underlying runtime. */
|
|
19
|
+
thinking?: "off" | "on" | "adaptive";
|
|
20
|
+
/** Max milliseconds to wait for the in-sandbox bridge to start. */
|
|
21
|
+
startupTimeoutMs?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Test/extension seam: supply the harness adapter. The default builds
|
|
24
|
+
* `createClaudeCode` with explicit auth from the session env (fail-closed,
|
|
25
|
+
* see auth.ts); overrides take on that responsibility themselves.
|
|
26
|
+
*/
|
|
27
|
+
createHarness?: (input: CreateHarnessInput) => HarnessAdapter;
|
|
28
|
+
/**
|
|
29
|
+
* Test/extension seam: supply the sandbox provider. The default builds
|
|
30
|
+
* `createVercelSandbox` with the contract's network policy applied at VM
|
|
31
|
+
* creation; overrides take on that responsibility themselves.
|
|
32
|
+
*/
|
|
33
|
+
createSandboxProvider?: (input: CreateSandboxProviderInput) => HarnessSandboxProvider;
|
|
34
|
+
};
|
|
35
|
+
/** True when the contract asks for the claude-code agent harness. */
|
|
36
|
+
export declare function isClaudeCodeAgentRun(contract: RunContract): boolean;
|
|
37
|
+
/** The Claude Code harness binding (vercel-sandbox isolation tier). */
|
|
38
|
+
export declare function claudeCodeBinding(options?: ClaudeCodeBindingOptions): HarnessBinding;
|
|
39
|
+
/**
|
|
40
|
+
* Options for the backward-compatible `aiSdkHarnessBackend()` factory: the
|
|
41
|
+
* Claude Code binding options plus a fallback backend.
|
|
42
|
+
*/
|
|
43
|
+
export type AiSdkHarnessBackendOptions = ClaudeCodeBindingOptions & {
|
|
44
|
+
/**
|
|
45
|
+
* Backend that executes everything this one does not (shell/argv
|
|
46
|
+
* executions, other agent kinds). Defaults to `vercelSandboxBackend()` with
|
|
47
|
+
* the same credentials, so installing this backend never narrows what the
|
|
48
|
+
* "vercel-sandbox" tier could already run.
|
|
49
|
+
*/
|
|
50
|
+
fallback?: SessionBackend;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Create an AI SDK harness session backend for the Claude Code runtime. The
|
|
54
|
+
* historical entry point; equivalent to hosting `claudeCodeBinding(options)`
|
|
55
|
+
* with a vercel-sandbox fallback.
|
|
56
|
+
*/
|
|
57
|
+
export declare function aiSdkHarnessBackend(options?: AiSdkHarnessBackendOptions): AiSdkHarnessBackend;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Claude Code binding: drives the claude-code harness through the AI SDK
|
|
3
|
+
* harness abstraction inside a Vercel Sandbox microVM. The adapter bootstraps
|
|
4
|
+
* the Claude Code runtime inside the sandbox itself (pinned bridge deps,
|
|
5
|
+
* frozen lockfile), so the microVM needs no pre-baked vendor tooling.
|
|
6
|
+
*
|
|
7
|
+
* Status: experimental and integration-gated, like the plain vercel-sandbox
|
|
8
|
+
* backend. It compiles against the real canary packages; running the real
|
|
9
|
+
* path needs Vercel credentials plus a contract-released Anthropic or AI
|
|
10
|
+
* Gateway credential. Non-claude-code executions are delegated unchanged to a
|
|
11
|
+
* fallback backend (by default `vercelSandboxBackend()`), so a runner can
|
|
12
|
+
* install this backend as its single "vercel-sandbox" tier.
|
|
13
|
+
*/
|
|
14
|
+
import { createClaudeCode } from "@ai-sdk/harness-claude-code";
|
|
15
|
+
import { createVercelSandbox } from "@ai-sdk/sandbox-vercel";
|
|
16
|
+
import { toVercelNetwork, vercelCredentialsFromEnv, vercelSandboxBackend } from "@fusionkit/session-vercel-sandbox";
|
|
17
|
+
import { claudeCodeAuthFromEnv } from "./auth.js";
|
|
18
|
+
import { AiSdkHarnessBackend, isAgentRunFor } from "./backend.js";
|
|
19
|
+
const DEFAULT_RUNTIME = "node24";
|
|
20
|
+
const DEFAULT_BRIDGE_PORT = 4000;
|
|
21
|
+
// On top of the shared sandbox ignore set, the harness's own session-state
|
|
22
|
+
// directories are runtime plumbing, not workspace output.
|
|
23
|
+
const CLAUDE_CODE_EXTRA_IGNORES = [".claude", ".agent-runs"];
|
|
24
|
+
/** True when the contract asks for the claude-code agent harness. */
|
|
25
|
+
export function isClaudeCodeAgentRun(contract) {
|
|
26
|
+
return isAgentRunFor(contract, "claude-code");
|
|
27
|
+
}
|
|
28
|
+
function defaultClaudeHarness(options) {
|
|
29
|
+
return (input) => {
|
|
30
|
+
const auth = claudeCodeAuthFromEnv(input.env);
|
|
31
|
+
const adapter = createClaudeCode({
|
|
32
|
+
auth,
|
|
33
|
+
...(options.model !== undefined ? { model: options.model } : {}),
|
|
34
|
+
...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
|
|
35
|
+
...(options.thinking !== undefined ? { thinking: options.thinking } : {}),
|
|
36
|
+
...(options.startupTimeoutMs !== undefined
|
|
37
|
+
? { startupTimeoutMs: options.startupTimeoutMs }
|
|
38
|
+
: {})
|
|
39
|
+
});
|
|
40
|
+
// pnpm gives @ai-sdk/harness-claude-code its own @ai-sdk/harness instance
|
|
41
|
+
// (different zod peer context), so its HarnessV1 is nominally distinct
|
|
42
|
+
// from the agent's despite being byte-identical. Bridge that split here.
|
|
43
|
+
return adapter;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function defaultClaudeSandbox(options) {
|
|
47
|
+
return (input) => {
|
|
48
|
+
return createVercelSandbox({
|
|
49
|
+
...vercelCredentialsFromEnv(options),
|
|
50
|
+
runtime: options.runtime ?? DEFAULT_RUNTIME,
|
|
51
|
+
timeout: input.timeoutMs,
|
|
52
|
+
ports: [options.bridgePort ?? DEFAULT_BRIDGE_PORT],
|
|
53
|
+
// Deny-by-default egress from the signed contract, applied at the VM
|
|
54
|
+
// boundary. The bridge bootstrap and the model API are subject to it:
|
|
55
|
+
// a contract that wants this path live must allow the registry and
|
|
56
|
+
// api.anthropic.com (or the gateway host) explicitly.
|
|
57
|
+
networkPolicy: toVercelNetwork(input.contract.network)
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** The Claude Code harness binding (vercel-sandbox isolation tier). */
|
|
62
|
+
export function claudeCodeBinding(options = {}) {
|
|
63
|
+
return {
|
|
64
|
+
agentKind: "claude-code",
|
|
65
|
+
isolation: "vercel-sandbox",
|
|
66
|
+
extraIgnores: CLAUDE_CODE_EXTRA_IGNORES,
|
|
67
|
+
createHarness: options.createHarness ?? defaultClaudeHarness(options),
|
|
68
|
+
createSandboxProvider: options.createSandboxProvider ?? defaultClaudeSandbox(options)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Create an AI SDK harness session backend for the Claude Code runtime. The
|
|
73
|
+
* historical entry point; equivalent to hosting `claudeCodeBinding(options)`
|
|
74
|
+
* with a vercel-sandbox fallback.
|
|
75
|
+
*/
|
|
76
|
+
export function aiSdkHarnessBackend(options = {}) {
|
|
77
|
+
const fallback = options.fallback ??
|
|
78
|
+
vercelSandboxBackend({
|
|
79
|
+
...(options.token !== undefined ? { token: options.token } : {}),
|
|
80
|
+
...(options.teamId !== undefined ? { teamId: options.teamId } : {}),
|
|
81
|
+
...(options.projectId !== undefined ? { projectId: options.projectId } : {})
|
|
82
|
+
});
|
|
83
|
+
return new AiSdkHarnessBackend({ binding: claudeCodeBinding(options), fallback });
|
|
84
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fusionkit/session-harness — drives vendor agent harnesses through the AI SDK
|
|
3
|
+
* harness abstraction (`HarnessAgent`) inside a sandbox, under the same
|
|
4
|
+
* governed-session contract as every other backend: workspace staged in,
|
|
5
|
+
* structured evidence in the receipt, secrets via the broker.
|
|
6
|
+
*
|
|
7
|
+
* The generic backend is binding-driven. Two bindings ship here:
|
|
8
|
+
*
|
|
9
|
+
* - `claudeCodeBinding` / `aiSdkHarnessBackend`: Claude Code in a Vercel
|
|
10
|
+
* Sandbox microVM (vercel-sandbox tier).
|
|
11
|
+
* - `piBinding` / `piHarnessBackend`: Pi on a local just-bash sandbox driving
|
|
12
|
+
* a local model (hermetic tier) — the cheap worker for a local swarm.
|
|
13
|
+
*/
|
|
14
|
+
export { AiSdkHarnessBackend, harnessBackend, isAgentRunFor } from "./backend.js";
|
|
15
|
+
export type { CreateHarnessInput, CreateSandboxProviderInput, HarnessAdapter, HarnessBinding, HarnessSandboxProvider } from "./backend.js";
|
|
16
|
+
export { aiSdkHarnessBackend, claudeCodeBinding, isClaudeCodeAgentRun } from "./claude-code.js";
|
|
17
|
+
export type { AiSdkHarnessBackendOptions, ClaudeCodeBindingOptions } from "./claude-code.js";
|
|
18
|
+
export { isPiAgentRun, piBinding, piHarnessBackend } from "./pi.js";
|
|
19
|
+
export type { PiBindingOptions, PiHarnessBackendOptions } from "./pi.js";
|
|
20
|
+
export { claudeCodeAuthFromEnv, piAuthFromEnv } from "./auth.js";
|
|
21
|
+
export { TranscriptRecorder } from "./transcript.js";
|
|
22
|
+
export type { TranscriptLine } from "./transcript.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fusionkit/session-harness — drives vendor agent harnesses through the AI SDK
|
|
3
|
+
* harness abstraction (`HarnessAgent`) inside a sandbox, under the same
|
|
4
|
+
* governed-session contract as every other backend: workspace staged in,
|
|
5
|
+
* structured evidence in the receipt, secrets via the broker.
|
|
6
|
+
*
|
|
7
|
+
* The generic backend is binding-driven. Two bindings ship here:
|
|
8
|
+
*
|
|
9
|
+
* - `claudeCodeBinding` / `aiSdkHarnessBackend`: Claude Code in a Vercel
|
|
10
|
+
* Sandbox microVM (vercel-sandbox tier).
|
|
11
|
+
* - `piBinding` / `piHarnessBackend`: Pi on a local just-bash sandbox driving
|
|
12
|
+
* a local model (hermetic tier) — the cheap worker for a local swarm.
|
|
13
|
+
*/
|
|
14
|
+
export { AiSdkHarnessBackend, harnessBackend, isAgentRunFor } from "./backend.js";
|
|
15
|
+
export { aiSdkHarnessBackend, claudeCodeBinding, isClaudeCodeAgentRun } from "./claude-code.js";
|
|
16
|
+
export { isPiAgentRun, piBinding, piHarnessBackend } from "./pi.js";
|
|
17
|
+
export { claudeCodeAuthFromEnv, piAuthFromEnv } from "./auth.js";
|
|
18
|
+
export { TranscriptRecorder } from "./transcript.js";
|
package/dist/pi.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Pi binding: drives the Pi coding harness through the AI SDK harness
|
|
3
|
+
* abstraction on a local just-bash sandbox. Pi is a *host-runtime* harness —
|
|
4
|
+
* it runs in the runner's own Node process and uses the sandbox only as a
|
|
5
|
+
* virtual filesystem and shell, so there is no microVM and no per-run image
|
|
6
|
+
* to provision. That makes it the cheap worker for a swarm of local models.
|
|
7
|
+
*
|
|
8
|
+
* Two honest limits, stated here and in the README:
|
|
9
|
+
*
|
|
10
|
+
* - just-bash is a *simulated* shell over a virtual filesystem. A Pi worker
|
|
11
|
+
* can read, write, edit, grep, and glob the staged workspace, but cannot
|
|
12
|
+
* run real builds or test suites. Acceptance of a worker's output is
|
|
13
|
+
* judged from evidence (the diff and receipt), and work that genuinely
|
|
14
|
+
* needs execution is escalated to a real-OS tier (claude-code on
|
|
15
|
+
* process/vercel). A Pi binding over `createVercelSandbox` is possible
|
|
16
|
+
* through the same seam when a worker truly needs execution; it is not the
|
|
17
|
+
* default.
|
|
18
|
+
* - Because Pi runs on the host, its model API call leaves from the host
|
|
19
|
+
* process, not through the just-bash interpreter's network allowlist. The
|
|
20
|
+
* contract's network policy therefore does not gate Pi's model traffic;
|
|
21
|
+
* the governed boundary here is the workspace and the released credential
|
|
22
|
+
* (the local endpoint URL), recorded in the receipt as every other run.
|
|
23
|
+
*
|
|
24
|
+
* Non-pi executions are delegated to a fallback backend (by default
|
|
25
|
+
* `hermeticBackend()`), so a runner can install this backend as its single
|
|
26
|
+
* "hermetic" tier.
|
|
27
|
+
*/
|
|
28
|
+
import type { RunContract } from "@fusionkit/protocol";
|
|
29
|
+
import type { SessionBackend } from "@fusionkit/runner";
|
|
30
|
+
import { AiSdkHarnessBackend } from "./backend.js";
|
|
31
|
+
import type { CreateHarnessInput, CreateSandboxProviderInput, HarnessAdapter, HarnessBinding, HarnessSandboxProvider } from "./backend.js";
|
|
32
|
+
export type PiBindingOptions = {
|
|
33
|
+
/** Pi model id (or name) sent to the local endpoint. */
|
|
34
|
+
model?: string;
|
|
35
|
+
/** Pi's extended-thinking budget level. */
|
|
36
|
+
thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
37
|
+
/**
|
|
38
|
+
* Test/extension seam: supply the harness adapter. The default builds
|
|
39
|
+
* `createPi` with explicit auth from the session env (fail-closed, see
|
|
40
|
+
* auth.ts); overrides take on that responsibility themselves.
|
|
41
|
+
*/
|
|
42
|
+
createHarness?: (input: CreateHarnessInput) => HarnessAdapter;
|
|
43
|
+
/**
|
|
44
|
+
* Test/extension seam: supply the sandbox provider. The default builds a
|
|
45
|
+
* fresh just-bash sandbox per session; overrides take on that themselves.
|
|
46
|
+
*/
|
|
47
|
+
createSandboxProvider?: (input: CreateSandboxProviderInput) => HarnessSandboxProvider;
|
|
48
|
+
};
|
|
49
|
+
/** True when the contract asks for the pi agent harness. */
|
|
50
|
+
export declare function isPiAgentRun(contract: RunContract): boolean;
|
|
51
|
+
/** The Pi harness binding (hermetic isolation tier). */
|
|
52
|
+
export declare function piBinding(options?: PiBindingOptions): HarnessBinding;
|
|
53
|
+
export type PiHarnessBackendOptions = PiBindingOptions & {
|
|
54
|
+
/**
|
|
55
|
+
* Backend that executes everything this one does not (shell commands, other
|
|
56
|
+
* agent kinds). Defaults to `hermeticBackend()`, so installing this backend
|
|
57
|
+
* never narrows what the "hermetic" tier could already run.
|
|
58
|
+
*/
|
|
59
|
+
fallback?: SessionBackend;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Create an AI SDK harness session backend for the Pi runtime: hosts
|
|
63
|
+
* `piBinding(options)` with a hermetic fallback. This is the runner's single
|
|
64
|
+
* "hermetic" tier when local-model swarm workers are in play.
|
|
65
|
+
*/
|
|
66
|
+
export declare function piHarnessBackend(options?: PiHarnessBackendOptions): AiSdkHarnessBackend;
|