orizu 0.4.0 → 0.5.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/README.md +22 -13
- package/dist/artifact-archive.js +152 -0
- package/dist/connectors-cli.js +80 -0
- package/dist/credentials.js +4 -1
- package/dist/daytona-slice-rehearsal.js +255 -0
- package/dist/daytona-slice.js +441 -0
- package/dist/egress-allowlist-cli.js +132 -0
- package/dist/egress-policy.js +159 -0
- package/dist/git-credential.js +131 -0
- package/dist/github-setup.js +226 -0
- package/dist/help.js +276 -21
- package/dist/hosted-bootstrap-rehearsal.js +235 -0
- package/dist/hosted-bootstrap.js +365 -0
- package/dist/hosted-broker-server.js +220 -0
- package/dist/hosted-git-http-server.js +136 -0
- package/dist/hosted-harness-claude.js +493 -0
- package/dist/hosted-harness-opencode.js +729 -0
- package/dist/hosted-harness.js +60 -0
- package/dist/hosted-loop.js +477 -0
- package/dist/hosted-run-event-sink.js +499 -0
- package/dist/hosted-runtime-assets.js +216 -0
- package/dist/hosted-session-cli.js +642 -0
- package/dist/index.js +945 -258
- package/dist/kill-switch-cli.js +89 -0
- package/dist/manifests-cli.js +143 -0
- package/dist/preview-runtime.js +4 -1
- package/dist/sandbox-provider.js +238 -0
- package/dist/secret-redaction.js +112 -0
- package/dist/session-timeline.js +103 -0
- package/dist/setup-prompt.js +4 -2
- package/dist/skill-installer.js +9 -1
- package/dist/smoke.js +250 -0
- package/dist/task-assignment-manifest.js +96 -0
- package/dist/vercel-sandbox-provider.js +260 -0
- package/dist/workbench-cli.js +493 -0
- package/dist/workspace-sync.js +924 -0
- package/dist/workspace.js +868 -57
- package/package.json +4 -1
- package/src/artifact-archive.ts +182 -0
- package/src/connectors-cli.ts +115 -0
- package/src/credentials.ts +6 -2
- package/src/daytona-slice-rehearsal.ts +320 -0
- package/src/daytona-slice.ts +598 -0
- package/src/egress-allowlist-cli.ts +147 -0
- package/src/egress-policy.ts +188 -0
- package/src/git-credential.ts +155 -0
- package/src/github-setup.ts +366 -0
- package/src/help.ts +277 -21
- package/src/hosted-bootstrap-rehearsal.ts +312 -0
- package/src/hosted-bootstrap.ts +571 -0
- package/src/hosted-broker-server.ts +278 -0
- package/src/hosted-git-http-server.ts +164 -0
- package/src/hosted-harness-claude.ts +621 -0
- package/src/hosted-harness-opencode.ts +823 -0
- package/src/hosted-harness.ts +124 -0
- package/src/hosted-loop.ts +680 -0
- package/src/hosted-run-event-sink.ts +632 -0
- package/src/hosted-runtime-assets.ts +272 -0
- package/src/hosted-session-cli.ts +844 -0
- package/src/index.ts +1102 -317
- package/src/kill-switch-cli.ts +113 -0
- package/src/manifests-cli.ts +168 -0
- package/src/preview-runtime.ts +14 -9
- package/src/sandbox-provider.ts +440 -0
- package/src/secret-redaction.ts +124 -0
- package/src/session-timeline.ts +152 -0
- package/src/setup-prompt.ts +4 -2
- package/src/skill-installer.ts +13 -1
- package/src/smoke.ts +342 -0
- package/src/task-assignment-manifest.ts +164 -0
- package/src/vercel-sandbox-provider.ts +388 -0
- package/src/workbench-cli.ts +701 -0
- package/src/workspace-sync.ts +1148 -0
- package/src/workspace.ts +1051 -64
- package/vendor/skills/orizu-cli/SKILL.md +8 -4
- package/vendor/skills/orizu-cli/references/cli-reference.md +12 -4
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone authenticated smart-HTTP git server for the ALI-925 rehearsal.
|
|
3
|
+
*
|
|
4
|
+
* Runs as its OWN process (spawned via `bun <this file>`), for the same reason
|
|
5
|
+
* the fake broker does: the local-sim `SandboxSession.exec` blocks on a
|
|
6
|
+
* `spawnSync` git clone, and the in-sandbox credential helper that clone drives
|
|
7
|
+
* makes real HTTP calls out. A server sharing the test's event loop could not
|
|
8
|
+
* answer while that loop is blocked — so the git server must be its own process.
|
|
9
|
+
*
|
|
10
|
+
* It proves the integrated credential path end-to-end: it (a) requires HTTP Basic
|
|
11
|
+
* auth and rejects unauthenticated requests with 401 + `WWW-Authenticate`, forcing
|
|
12
|
+
* git to invoke the credential helper, and (b) on authenticated requests serves a
|
|
13
|
+
* real bare repo by shelling to `git http-backend` as CGI. Every Basic-auth
|
|
14
|
+
* password it receives is journaled to a file so the rehearsal can assert the
|
|
15
|
+
* password git presented equals the token the broker minted (git → helper →
|
|
16
|
+
* broker → token → authenticated clone).
|
|
17
|
+
*
|
|
18
|
+
* Not shipped to end users — a test/rehearsal fixture only.
|
|
19
|
+
*/
|
|
20
|
+
import { execFileSync } from 'child_process';
|
|
21
|
+
import { appendFileSync } from 'fs';
|
|
22
|
+
import { basename, dirname, join } from 'path';
|
|
23
|
+
function bun() {
|
|
24
|
+
const b = globalThis.Bun;
|
|
25
|
+
if (!b?.serve || !b?.spawn)
|
|
26
|
+
throw new Error('hosted-git-http-server requires bun');
|
|
27
|
+
return b;
|
|
28
|
+
}
|
|
29
|
+
/** Resolve the `git-http-backend` CGI binary from git's exec-path. */
|
|
30
|
+
function resolveHttpBackend() {
|
|
31
|
+
const execPath = execFileSync('git', ['--exec-path']).toString().trim();
|
|
32
|
+
return join(execPath, 'git-http-backend');
|
|
33
|
+
}
|
|
34
|
+
export function readGitHttpConfigFromEnv(env) {
|
|
35
|
+
return {
|
|
36
|
+
repoDir: env.HG_REPO_DIR ?? '',
|
|
37
|
+
authLogFile: env.HG_AUTH_LOG,
|
|
38
|
+
port: env.HG_PORT ? Number.parseInt(env.HG_PORT, 10) : 0,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** The env keys the server reads (kept here so callers do not hand-roll them). */
|
|
42
|
+
export function gitHttpEnv(config) {
|
|
43
|
+
const env = { HG_REPO_DIR: config.repoDir };
|
|
44
|
+
if (config.authLogFile)
|
|
45
|
+
env.HG_AUTH_LOG = config.authLogFile;
|
|
46
|
+
return env;
|
|
47
|
+
}
|
|
48
|
+
/** The clone path segment a client uses (the bare repo's basename). */
|
|
49
|
+
export function gitHttpRepoSegment(config) {
|
|
50
|
+
return basename(config.repoDir);
|
|
51
|
+
}
|
|
52
|
+
export function startGitHttpServer(config) {
|
|
53
|
+
const backend = resolveHttpBackend();
|
|
54
|
+
const projectRoot = dirname(config.repoDir);
|
|
55
|
+
const unauthorized = () => new Response('authentication required', {
|
|
56
|
+
status: 401,
|
|
57
|
+
headers: { 'WWW-Authenticate': 'Basic realm="orizu-sim"' },
|
|
58
|
+
});
|
|
59
|
+
return bun().serve({
|
|
60
|
+
port: config.port ?? 0,
|
|
61
|
+
hostname: '127.0.0.1',
|
|
62
|
+
async fetch(request) {
|
|
63
|
+
// (a) Require Basic auth; reject unauthenticated requests so git invokes the
|
|
64
|
+
// credential helper (which mints from the broker) before retrying.
|
|
65
|
+
const authHeader = request.headers.get('authorization') ?? '';
|
|
66
|
+
if (!authHeader.startsWith('Basic '))
|
|
67
|
+
return unauthorized();
|
|
68
|
+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8');
|
|
69
|
+
const sep = decoded.indexOf(':');
|
|
70
|
+
const user = sep >= 0 ? decoded.slice(0, sep) : decoded;
|
|
71
|
+
const pass = sep >= 0 ? decoded.slice(sep + 1) : '';
|
|
72
|
+
// Journal the credential git presented (proves password === minted token).
|
|
73
|
+
if (config.authLogFile)
|
|
74
|
+
appendFileSync(config.authLogFile, `${JSON.stringify({ user, pass })}\n`);
|
|
75
|
+
if (!pass)
|
|
76
|
+
return unauthorized();
|
|
77
|
+
// (b) Serve the bare repo via `git http-backend` as CGI.
|
|
78
|
+
const url = new URL(request.url);
|
|
79
|
+
const body = request.method === 'POST' ? new Uint8Array(await request.arrayBuffer()) : undefined;
|
|
80
|
+
const cgiEnv = {
|
|
81
|
+
...process.env,
|
|
82
|
+
GIT_PROJECT_ROOT: projectRoot,
|
|
83
|
+
GIT_HTTP_EXPORT_ALL: '1',
|
|
84
|
+
PATH_INFO: url.pathname,
|
|
85
|
+
QUERY_STRING: url.search.replace(/^\?/, ''),
|
|
86
|
+
REQUEST_METHOD: request.method,
|
|
87
|
+
CONTENT_TYPE: request.headers.get('content-type') ?? '',
|
|
88
|
+
REMOTE_USER: user,
|
|
89
|
+
};
|
|
90
|
+
const contentEncoding = request.headers.get('content-encoding');
|
|
91
|
+
if (contentEncoding)
|
|
92
|
+
cgiEnv.HTTP_CONTENT_ENCODING = contentEncoding;
|
|
93
|
+
if (body)
|
|
94
|
+
cgiEnv.CONTENT_LENGTH = String(body.length);
|
|
95
|
+
const proc = bun().spawn({
|
|
96
|
+
cmd: [backend],
|
|
97
|
+
env: cgiEnv,
|
|
98
|
+
stdin: body ?? 'ignore',
|
|
99
|
+
stdout: 'pipe',
|
|
100
|
+
stderr: 'ignore',
|
|
101
|
+
});
|
|
102
|
+
const out = Buffer.from(await new Response(proc.stdout).arrayBuffer());
|
|
103
|
+
await proc.exited;
|
|
104
|
+
// Split the CGI response (headers, blank line, body) — body may be binary.
|
|
105
|
+
let boundary = out.indexOf('\r\n\r\n');
|
|
106
|
+
let boundaryLen = 4;
|
|
107
|
+
if (boundary < 0) {
|
|
108
|
+
boundary = out.indexOf('\n\n');
|
|
109
|
+
boundaryLen = 2;
|
|
110
|
+
}
|
|
111
|
+
const headerText = boundary >= 0 ? out.subarray(0, boundary).toString('utf8') : '';
|
|
112
|
+
const payload = boundary >= 0 ? out.subarray(boundary + boundaryLen) : out;
|
|
113
|
+
let status = 200;
|
|
114
|
+
const headers = new Headers();
|
|
115
|
+
for (const line of headerText.split(/\r?\n/)) {
|
|
116
|
+
const colon = line.indexOf(':');
|
|
117
|
+
if (colon < 0)
|
|
118
|
+
continue;
|
|
119
|
+
const key = line.slice(0, colon).trim();
|
|
120
|
+
const value = line.slice(colon + 1).trim();
|
|
121
|
+
if (key.toLowerCase() === 'status') {
|
|
122
|
+
status = Number.parseInt(value, 10) || 200;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
headers.set(key, value);
|
|
126
|
+
}
|
|
127
|
+
return new Response(payload, { status, headers });
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
// When executed directly (`bun hosted-git-http-server.ts`), start the server and
|
|
132
|
+
// print `LISTENING <port>` so the spawning parent can resolve the port.
|
|
133
|
+
if (import.meta.main) {
|
|
134
|
+
const server = startGitHttpServer(readGitHttpConfigFromEnv(process.env));
|
|
135
|
+
process.stdout.write(`LISTENING ${server.port}\n`);
|
|
136
|
+
}
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude-Agent-SDK driver for the AgentHarness seam (ALI-929 / P3.6, per ADR-005
|
|
3
|
+
* §4a — the swappability proof).
|
|
4
|
+
*
|
|
5
|
+
* This is the SECOND `AgentHarness` implementation behind the same seam as the
|
|
6
|
+
* OpenCode driver (`hosted-harness-opencode.ts`). It drives
|
|
7
|
+
* `@anthropic-ai/claude-agent-sdk`'s `query()` loop — an IN-PROCESS agent loop,
|
|
8
|
+
* with NO server process to spawn and no localhost SSE to parse — and normalizes
|
|
9
|
+
* the SDK's message/tool stream into the SAME `HarnessEvent` vocabulary the
|
|
10
|
+
* event bridge (`hosted-run-event-sink.ts`) already consumes. It carries ZERO
|
|
11
|
+
* Claude-Agent-SDK types across the seam: everything below stays behind
|
|
12
|
+
* `createClaudeAgentHarness`, exactly as the OpenCode SSE schema stays behind
|
|
13
|
+
* `createOpenCodeHarness`.
|
|
14
|
+
*
|
|
15
|
+
* The founder's locked escape-hatch ("OpenInspect, but we can move to our own
|
|
16
|
+
* harness whenever we want") is proven by this module existing and passing the
|
|
17
|
+
* SAME session-cell invariants through the SAME sink — see
|
|
18
|
+
* `test/cli/hosted-harness-swap.test.ts`.
|
|
19
|
+
*
|
|
20
|
+
* INJECTABLE LOADER: like the Daytona/Vercel SDK adapters, the SDK is imported
|
|
21
|
+
* LAZILY through a NON-LITERAL specifier so the CLI/app build resolve without the
|
|
22
|
+
* package, and a `loadModule` dep is injectable so tests exercise the
|
|
23
|
+
* normalization against a fake `query()` with no real SDK and no model calls.
|
|
24
|
+
*
|
|
25
|
+
* ── query() → runPrompt() mapping ────────────────────────────────────────────
|
|
26
|
+
* ONE `query()` call drives ONE `runPrompt()` to completion. The SDK's async
|
|
27
|
+
* iterable yields `SDKMessage`s; we translate them:
|
|
28
|
+
* • system/init → capture session_id; emit `ready` once.
|
|
29
|
+
* • assistant.message → text blocks → `token` (keyed by the SDK message id
|
|
30
|
+
* for coalescing); tool_use blocks → `tool_call`. The
|
|
31
|
+
* per-turn `message.usage` the SDK ALWAYS populates is
|
|
32
|
+
* deliberately NOT emitted as a `step_finish` (see
|
|
33
|
+
* leak #3): the driver coalesces to ONE terminal
|
|
34
|
+
* step_finish so both harnesses share one granularity.
|
|
35
|
+
* • user.message → tool_result blocks → `tool_result` (paired to the
|
|
36
|
+
* tool_use by callId, exactly once).
|
|
37
|
+
* • stream_event (partial) → text deltas are accumulated into the running token
|
|
38
|
+
* snapshot for coalescing; consumed, not 1:1 mapped.
|
|
39
|
+
* • result/success → final `step_finish` with cost+tokens, then
|
|
40
|
+
* `execution_complete` (success, or aborted on stop).
|
|
41
|
+
* • result/error_* → `error` (critical terminal).
|
|
42
|
+
* The stream ALWAYS ends with exactly one terminal event.
|
|
43
|
+
*
|
|
44
|
+
* ── WHAT LEAKS THROUGH THE BOUNDARY (map-conservatively evidence) ─────────────
|
|
45
|
+
* These SDK signals have NO clean `HarnessEvent` kind and are DROPPED here (not a
|
|
46
|
+
* reason to widen the seam — this is the audit of the boundary's fit):
|
|
47
|
+
* 1. `thinking` / `redacted_thinking` content blocks — the harness vocabulary
|
|
48
|
+
* has no reasoning kind (OpenCode's driver drops reasoning too), so extended
|
|
49
|
+
* thinking is not forwarded. Cost of surfacing it: a new `thinking` kind on
|
|
50
|
+
* the seam + a bridge mapping — a deliberate future decision, not done here.
|
|
51
|
+
* 2. `system/compact_boundary` — context-compaction markers are dropped (the
|
|
52
|
+
* OpenCode driver handles `session.compacted` internally and forwards
|
|
53
|
+
* nothing either; parity, not a leak).
|
|
54
|
+
* 3. Per-assistant-turn USAGE granularity — the real contract is that every
|
|
55
|
+
* `SDKAssistantMessage.message` is a `BetaMessage` whose `usage: BetaUsage`
|
|
56
|
+
* is ALWAYS populated (input/output token counts are non-optional), so the
|
|
57
|
+
* SDK reports usage on EACH assistant turn, not only at the terminal. The
|
|
58
|
+
* driver deliberately DOES NOT emit a `step_finish` per assistant turn: it
|
|
59
|
+
* coalesces to exactly ONE terminal `step_finish` at the `result` message,
|
|
60
|
+
* carrying `total_cost_usd` + aggregate `usage`. OpenCode emits one
|
|
61
|
+
* `step_finish` per `step-finish` part; for a one-tool task that is also a
|
|
62
|
+
* single terminal step, so the two streams are legitimately identical at the
|
|
63
|
+
* seam. This coalescing is itself a swappability win — the seam absorbs the
|
|
64
|
+
* raw per-turn/per-step granularity difference so downstream sees one
|
|
65
|
+
* normalized `agent_step_finish` from either harness. (Cost is only known at
|
|
66
|
+
* the terminal `result`; per-turn `usage` carries tokens but no cost, which
|
|
67
|
+
* is why folding to the terminal loses nothing the seam surfaces.)
|
|
68
|
+
* 4. `result.num_turns` / `duration_ms` / `permission_denials` — folded into
|
|
69
|
+
* the terminal transition's shape (success/aborted/error), not surfaced as
|
|
70
|
+
* distinct events.
|
|
71
|
+
* 5. Git author identity is plumbed via `GIT_AUTHOR_*` / `GIT_COMMITTER_*`
|
|
72
|
+
* ENV on the query options (the SDK has no first-class author param), where
|
|
73
|
+
* OpenCode takes it structurally. Same effect, different plumbing point.
|
|
74
|
+
* NOTE the `env` CONTRACT: the SDK's `env` REPLACES the subprocess
|
|
75
|
+
* environment entirely (it is NOT merged with `process.env`), so we spread
|
|
76
|
+
* `...process.env` FIRST — without it the `claude` subprocess would lose
|
|
77
|
+
* `PATH`/`HOME`/`ANTHROPIC_API_KEY` and die. G3 credential model: like the
|
|
78
|
+
* OpenCode path (which hands `opencode` the non-secret dummy key), the SDK
|
|
79
|
+
* client is given the same `anthropicDummyKey` as `ANTHROPIC_API_KEY`; the
|
|
80
|
+
* real key is injected by the sandbox firewall on egress to
|
|
81
|
+
* api.anthropic.com, so NO raw key ever enters the in-process client. See
|
|
82
|
+
* the G3/credentials axis in ali-929-harness-comparison.md.
|
|
83
|
+
* 6. The resumable agent session id is assigned LAZILY by the SDK at
|
|
84
|
+
* `system/init` — it does not exist before the first `runPrompt`. So
|
|
85
|
+
* `start()` returns the RESUME id if given, else `''`; the real id first
|
|
86
|
+
* reaches the bridge via the `ready` event. OpenCode creates its session
|
|
87
|
+
* eagerly in `start()`. This is the one place the seam's "start returns the
|
|
88
|
+
* session id" shape fits OpenCode better than the SDK — the run still
|
|
89
|
+
* completes identically (the sink records the id from `ready`); see the
|
|
90
|
+
* comparison doc's swappability verdict.
|
|
91
|
+
* 7. Provider-qualified model strings (e.g. `anthropic/claude-opus-4-8`) are
|
|
92
|
+
* reduced to the bare model id the SDK expects (leading `anthropic/` is
|
|
93
|
+
* stripped; other providers pass through unchanged and would be the SDK's to
|
|
94
|
+
* reject).
|
|
95
|
+
*/
|
|
96
|
+
// The package name lives in a variable so TypeScript treats the dynamic import as
|
|
97
|
+
// `any` (no static module resolution / ts2307 at app-build time) and no bundler
|
|
98
|
+
// traces it — this keeps the SDK genuinely optional, loaded only when the
|
|
99
|
+
// `claude-agent-sdk` harness is actually selected.
|
|
100
|
+
const CLAUDE_AGENT_SDK_SPECIFIER = '@anthropic-ai/claude-agent-sdk';
|
|
101
|
+
async function loadClaudeAgentSdk() {
|
|
102
|
+
try {
|
|
103
|
+
const specifier = CLAUDE_AGENT_SDK_SPECIFIER;
|
|
104
|
+
const mod = (await import(specifier));
|
|
105
|
+
if (!mod || typeof mod.query !== 'function') {
|
|
106
|
+
throw new Error('module did not export a query() function');
|
|
107
|
+
}
|
|
108
|
+
return mod;
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
112
|
+
throw new Error(`Claude Agent SDK unavailable (${detail}). Install it before the live run: \`bun install --cwd packages/cli\` (declares @anthropic-ai/claude-agent-sdk).`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// -- helpers -----------------------------------------------------------------
|
|
116
|
+
function asRecord(value) {
|
|
117
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
118
|
+
? value
|
|
119
|
+
: {};
|
|
120
|
+
}
|
|
121
|
+
/** Reduce a provider-qualified model ("anthropic/claude-opus-4-8") to the bare id
|
|
122
|
+
* the Agent SDK expects; other providers pass through for the SDK to judge. */
|
|
123
|
+
function toSdkModel(model) {
|
|
124
|
+
if (!model)
|
|
125
|
+
return undefined;
|
|
126
|
+
return model.startsWith('anthropic/') ? model.slice('anthropic/'.length) : model;
|
|
127
|
+
}
|
|
128
|
+
/** Map the seam's structural MCP descriptors to the SDK's config record. */
|
|
129
|
+
function toSdkMcpServers(servers) {
|
|
130
|
+
if (!servers || servers.length === 0)
|
|
131
|
+
return undefined;
|
|
132
|
+
const out = {};
|
|
133
|
+
for (const s of servers) {
|
|
134
|
+
out[s.name] = {
|
|
135
|
+
type: s.transport ?? (s.url ? 'http' : 'stdio'),
|
|
136
|
+
command: s.command,
|
|
137
|
+
args: s.args,
|
|
138
|
+
url: s.url,
|
|
139
|
+
env: s.env,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/** Flatten an SDK message's content blocks (array or bare string) to blocks. */
|
|
145
|
+
function contentBlocks(message) {
|
|
146
|
+
const content = message?.content;
|
|
147
|
+
if (Array.isArray(content))
|
|
148
|
+
return content;
|
|
149
|
+
if (typeof content === 'string')
|
|
150
|
+
return [{ type: 'text', text: content }];
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
/** Coerce a tool_result block's `content` to a string output. */
|
|
154
|
+
function toolResultOutput(content) {
|
|
155
|
+
if (typeof content === 'string')
|
|
156
|
+
return content;
|
|
157
|
+
if (Array.isArray(content)) {
|
|
158
|
+
return content
|
|
159
|
+
.map(part => {
|
|
160
|
+
const rec = asRecord(part);
|
|
161
|
+
return typeof rec.text === 'string' ? rec.text : '';
|
|
162
|
+
})
|
|
163
|
+
.join('');
|
|
164
|
+
}
|
|
165
|
+
return '';
|
|
166
|
+
}
|
|
167
|
+
// -- The driver --------------------------------------------------------------
|
|
168
|
+
export function createClaudeAgentHarness(options = {}) {
|
|
169
|
+
const load = options.loadModule ?? loadClaudeAgentSdk;
|
|
170
|
+
const permissionMode = options.permissionMode ?? 'bypassPermissions';
|
|
171
|
+
const anthropicDummyKey = options.anthropicDummyKey;
|
|
172
|
+
let sdk = null;
|
|
173
|
+
let startModel;
|
|
174
|
+
let startWorkspaceDir;
|
|
175
|
+
let startMcpServers;
|
|
176
|
+
// The resumable session id: seeded from start()'s resume option, then updated
|
|
177
|
+
// to whatever the SDK assigns at each system/init so subsequent prompts
|
|
178
|
+
// continue the same conversation (parity with OpenCode session reuse).
|
|
179
|
+
let currentSessionId = null;
|
|
180
|
+
let readyEmitted = false;
|
|
181
|
+
// Set true by the public stop(); makes the resulting terminal a cancellation.
|
|
182
|
+
let stopRequested = false;
|
|
183
|
+
// The in-flight query's handles, so stop() can interrupt/abort it.
|
|
184
|
+
let activeQuery = null;
|
|
185
|
+
let activeAbort = null;
|
|
186
|
+
async function ensureSdk() {
|
|
187
|
+
if (!sdk)
|
|
188
|
+
sdk = await load();
|
|
189
|
+
return sdk;
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
name: 'claude-agent-sdk',
|
|
193
|
+
async start(opts) {
|
|
194
|
+
startModel = opts.model;
|
|
195
|
+
startWorkspaceDir = opts.workspaceDir;
|
|
196
|
+
startMcpServers = opts.mcpServers;
|
|
197
|
+
// Load eagerly so a missing SDK fails at start() (like OpenCode validating
|
|
198
|
+
// its session), not mid-stream. The session id is assigned lazily by the
|
|
199
|
+
// SDK at init — see leak #6 — so we can only echo a resume id here.
|
|
200
|
+
await ensureSdk();
|
|
201
|
+
currentSessionId = opts.resumeAgentSessionId ?? null;
|
|
202
|
+
return { agentSessionId: opts.resumeAgentSessionId ?? '' };
|
|
203
|
+
},
|
|
204
|
+
async *runPrompt(prompt, signal) {
|
|
205
|
+
const mod = await ensureSdk();
|
|
206
|
+
// Per-prompt state.
|
|
207
|
+
stopRequested = false;
|
|
208
|
+
const abortController = new AbortController();
|
|
209
|
+
activeAbort = abortController;
|
|
210
|
+
// Bridge the caller's signal into the SDK's abort controller.
|
|
211
|
+
const onAbort = () => abortController.abort();
|
|
212
|
+
if (signal.aborted)
|
|
213
|
+
abortController.abort();
|
|
214
|
+
else
|
|
215
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
216
|
+
// Per-message accumulated text for coalesced token snapshots.
|
|
217
|
+
const messageText = new Map();
|
|
218
|
+
// The id of the assistant message currently streaming (from the partial
|
|
219
|
+
// `message_start` event), so text deltas coalesce under the SAME id the
|
|
220
|
+
// final assistant snapshot uses — not a synthetic partial key.
|
|
221
|
+
let partialMessageId = null;
|
|
222
|
+
// Tool calls already forwarded, so a re-observed block isn't double-emitted.
|
|
223
|
+
const emittedToolCalls = new Set();
|
|
224
|
+
const emittedToolResults = new Set();
|
|
225
|
+
const author = prompt.author;
|
|
226
|
+
const queryOptions = {
|
|
227
|
+
model: toSdkModel(startModel),
|
|
228
|
+
cwd: startWorkspaceDir,
|
|
229
|
+
resume: currentSessionId ?? undefined,
|
|
230
|
+
abortController,
|
|
231
|
+
permissionMode,
|
|
232
|
+
// The SDK requires this be true whenever permissionMode is
|
|
233
|
+
// 'bypassPermissions' (safety gate). Leave unset for any other mode.
|
|
234
|
+
allowDangerouslySkipPermissions: permissionMode === 'bypassPermissions' ? true : undefined,
|
|
235
|
+
includePartialMessages: true,
|
|
236
|
+
mcpServers: toSdkMcpServers(startMcpServers),
|
|
237
|
+
// `env` REPLACES the subprocess environment entirely — spread process.env
|
|
238
|
+
// FIRST so PATH/HOME/ANTHROPIC_API_KEY survive; without it the subprocess
|
|
239
|
+
// dies. G3: hand the SDK client the non-secret dummy key (the firewall
|
|
240
|
+
// brokers the real key on egress). Git author identity plumbed here too
|
|
241
|
+
// (leak #5) since the SDK has no first-class author param.
|
|
242
|
+
env: {
|
|
243
|
+
...process.env,
|
|
244
|
+
...(anthropicDummyKey ? { ANTHROPIC_API_KEY: anthropicDummyKey } : {}),
|
|
245
|
+
GIT_AUTHOR_NAME: author.name,
|
|
246
|
+
GIT_AUTHOR_EMAIL: author.email,
|
|
247
|
+
GIT_COMMITTER_NAME: author.name,
|
|
248
|
+
GIT_COMMITTER_EMAIL: author.email,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
let q;
|
|
252
|
+
try {
|
|
253
|
+
q = mod.query({ prompt: prompt.content, options: queryOptions });
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
activeQuery = null;
|
|
257
|
+
activeAbort = null;
|
|
258
|
+
signal.removeEventListener('abort', onAbort);
|
|
259
|
+
yield errorEvent(prompt.messageId, error);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
activeQuery = q;
|
|
263
|
+
let terminalYielded = false;
|
|
264
|
+
try {
|
|
265
|
+
for await (const msg of q) {
|
|
266
|
+
if (msg.type === 'system' && msg.subtype === 'init') {
|
|
267
|
+
if (typeof msg.session_id === 'string')
|
|
268
|
+
currentSessionId = msg.session_id;
|
|
269
|
+
if (!readyEmitted) {
|
|
270
|
+
readyEmitted = true;
|
|
271
|
+
yield {
|
|
272
|
+
kind: 'ready',
|
|
273
|
+
messageId: prompt.messageId,
|
|
274
|
+
payload: { agentSessionId: currentSessionId ?? '' },
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
// compact_boundary and any other system subtype: no mapping (leak #2).
|
|
280
|
+
if (msg.type === 'system')
|
|
281
|
+
continue;
|
|
282
|
+
if (msg.type === 'stream_event') {
|
|
283
|
+
const ev = msg.event ?? {};
|
|
284
|
+
// Track the streaming message id so deltas coalesce under the same key
|
|
285
|
+
// the assistant snapshot will use.
|
|
286
|
+
if (ev.type === 'message_start' && typeof ev.message?.id === 'string') {
|
|
287
|
+
partialMessageId = ev.message.id;
|
|
288
|
+
}
|
|
289
|
+
// Accumulate text deltas into the running per-message snapshot so the
|
|
290
|
+
// sink coalesces token growth (leak: partials consumed, not 1:1).
|
|
291
|
+
if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') {
|
|
292
|
+
const msgId = partialMessageId ?? (typeof msg.message?.id === 'string' ? msg.message.id : '_partial');
|
|
293
|
+
const next = (messageText.get(msgId) ?? '') + (ev.delta.text ?? '');
|
|
294
|
+
messageText.set(msgId, next);
|
|
295
|
+
if (next)
|
|
296
|
+
yield { kind: 'token', messageId: msgId, payload: { text: next } };
|
|
297
|
+
}
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (msg.type === 'assistant') {
|
|
301
|
+
const msgId = typeof msg.message?.id === 'string' ? msg.message.id : '';
|
|
302
|
+
// Accumulate all text first so the coalesced token snapshot for the
|
|
303
|
+
// turn is emitted BEFORE any tool_call — matching the OpenCode driver's
|
|
304
|
+
// token-then-tool ordering (thinking/redacted_thinking dropped, leak #1).
|
|
305
|
+
let combined = '';
|
|
306
|
+
const toolUseBlocks = [];
|
|
307
|
+
for (const block of contentBlocks(msg.message)) {
|
|
308
|
+
if (block.type === 'text') {
|
|
309
|
+
combined += typeof block.text === 'string' ? block.text : '';
|
|
310
|
+
}
|
|
311
|
+
else if (block.type === 'tool_use') {
|
|
312
|
+
toolUseBlocks.push(block);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Emit the authoritative full-text snapshot for the turn (coalesces
|
|
316
|
+
// with any partial snapshots the sink already buffered).
|
|
317
|
+
if (combined && msgId) {
|
|
318
|
+
const prev = messageText.get(msgId) ?? '';
|
|
319
|
+
if (combined.length >= prev.length) {
|
|
320
|
+
messageText.set(msgId, combined);
|
|
321
|
+
yield { kind: 'token', messageId: msgId, payload: { text: combined } };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
for (const block of toolUseBlocks) {
|
|
325
|
+
const callId = typeof block.id === 'string' ? block.id : '';
|
|
326
|
+
if (!callId || emittedToolCalls.has(callId))
|
|
327
|
+
continue;
|
|
328
|
+
emittedToolCalls.add(callId);
|
|
329
|
+
yield {
|
|
330
|
+
kind: 'tool_call',
|
|
331
|
+
messageId: msgId || prompt.messageId,
|
|
332
|
+
payload: {
|
|
333
|
+
tool: typeof block.name === 'string' ? block.name : '',
|
|
334
|
+
args: asRecord(block.input),
|
|
335
|
+
callId,
|
|
336
|
+
status: 'running',
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
// NB: `msg.message.usage` (BetaUsage) is ALWAYS populated on real
|
|
341
|
+
// assistant turns, but we deliberately do NOT emit a per-turn
|
|
342
|
+
// step_finish — the driver coalesces to ONE terminal step_finish at
|
|
343
|
+
// the `result` message so both harnesses normalize to the same
|
|
344
|
+
// granularity at the seam (leak #3).
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (msg.type === 'user') {
|
|
348
|
+
for (const block of contentBlocks(msg.message)) {
|
|
349
|
+
if (block.type !== 'tool_result')
|
|
350
|
+
continue;
|
|
351
|
+
const callId = typeof block.tool_use_id === 'string' ? block.tool_use_id : '';
|
|
352
|
+
if (!callId || emittedToolResults.has(callId))
|
|
353
|
+
continue;
|
|
354
|
+
emittedToolResults.add(callId);
|
|
355
|
+
yield {
|
|
356
|
+
kind: 'tool_result',
|
|
357
|
+
messageId: prompt.messageId,
|
|
358
|
+
payload: {
|
|
359
|
+
callId,
|
|
360
|
+
status: block.is_error === true ? 'error' : 'completed',
|
|
361
|
+
output: toolResultOutput(block.content),
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (msg.type === 'result') {
|
|
368
|
+
const success = msg.subtype === 'success' && msg.is_error !== true;
|
|
369
|
+
if (success) {
|
|
370
|
+
// Final step_finish carries the total cost + usage (leak #3).
|
|
371
|
+
yield {
|
|
372
|
+
kind: 'step_finish',
|
|
373
|
+
messageId: prompt.messageId,
|
|
374
|
+
payload: { cost: msg.total_cost_usd ?? null, tokens: msg.usage ?? null, reason: 'result' },
|
|
375
|
+
};
|
|
376
|
+
terminalYielded = true;
|
|
377
|
+
yield {
|
|
378
|
+
kind: 'execution_complete',
|
|
379
|
+
messageId: prompt.messageId,
|
|
380
|
+
critical: true,
|
|
381
|
+
payload: stopRequested ? { success: false, aborted: true } : { success: true },
|
|
382
|
+
};
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// error_max_turns / error_during_execution → critical error terminal.
|
|
386
|
+
terminalYielded = true;
|
|
387
|
+
yield {
|
|
388
|
+
kind: 'error',
|
|
389
|
+
messageId: prompt.messageId,
|
|
390
|
+
critical: true,
|
|
391
|
+
payload: { error: describeResultError(msg) },
|
|
392
|
+
};
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
// Unknown message type: no mapping — skip (documented boundary).
|
|
396
|
+
}
|
|
397
|
+
// Stream ended without a result message (server/loop closed early).
|
|
398
|
+
if (!terminalYielded) {
|
|
399
|
+
if (stopRequested || signal.aborted) {
|
|
400
|
+
yield {
|
|
401
|
+
kind: 'execution_complete',
|
|
402
|
+
messageId: prompt.messageId,
|
|
403
|
+
critical: true,
|
|
404
|
+
payload: { success: false, aborted: true },
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
yield {
|
|
409
|
+
kind: 'execution_complete',
|
|
410
|
+
messageId: prompt.messageId,
|
|
411
|
+
critical: true,
|
|
412
|
+
payload: { success: true },
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
// An abort (caller signal or stop()) resolves as a cancellation, never a
|
|
419
|
+
// plain failure (parity with the OpenCode driver's P2-2 rule).
|
|
420
|
+
if (stopRequested || signal.aborted || isAbortError(error)) {
|
|
421
|
+
if (!terminalYielded) {
|
|
422
|
+
yield {
|
|
423
|
+
kind: 'execution_complete',
|
|
424
|
+
messageId: prompt.messageId,
|
|
425
|
+
critical: true,
|
|
426
|
+
payload: { success: false, aborted: true },
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
else if (!terminalYielded) {
|
|
431
|
+
yield errorEvent(prompt.messageId, error);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
finally {
|
|
435
|
+
signal.removeEventListener('abort', onAbort);
|
|
436
|
+
activeQuery = null;
|
|
437
|
+
activeAbort = null;
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
async stop() {
|
|
441
|
+
// Cancellation is via `abortController.abort()` — that works in ALL modes
|
|
442
|
+
// and is the REAL mechanism here. `query().interrupt()` is a control request
|
|
443
|
+
// the SDK only honors in streaming-INPUT mode; this driver uses string-prompt
|
|
444
|
+
// mode, so interrupt() is a no-op harmless best-effort at most. We call it
|
|
445
|
+
// first (in case a future streaming-input path is added) but the abort below
|
|
446
|
+
// is what actually cancels the in-flight query.
|
|
447
|
+
stopRequested = true;
|
|
448
|
+
const q = activeQuery;
|
|
449
|
+
if (q && typeof q.interrupt === 'function') {
|
|
450
|
+
try {
|
|
451
|
+
await q.interrupt();
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// best-effort — the abort below is the real cancellation
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
activeAbort?.abort();
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
// controller already aborted / gone
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
async shutdown() {
|
|
465
|
+
currentSessionId = null;
|
|
466
|
+
readyEmitted = false;
|
|
467
|
+
stopRequested = false;
|
|
468
|
+
activeQuery = null;
|
|
469
|
+
activeAbort = null;
|
|
470
|
+
sdk = null;
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
/** A terminal `error` HarnessEvent from a thrown/rejected query. */
|
|
475
|
+
function errorEvent(messageId, error) {
|
|
476
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
477
|
+
return { kind: 'error', messageId, critical: true, payload: { error: message } };
|
|
478
|
+
}
|
|
479
|
+
function isAbortError(error) {
|
|
480
|
+
return error instanceof Error && (error.name === 'AbortError' || error.name === 'DOMException');
|
|
481
|
+
}
|
|
482
|
+
/** Human-readable reason for a non-success result message. */
|
|
483
|
+
function describeResultError(msg) {
|
|
484
|
+
if (msg.subtype === 'error_max_turns')
|
|
485
|
+
return 'agent stopped: max turns reached';
|
|
486
|
+
if (msg.subtype === 'error_max_budget_usd')
|
|
487
|
+
return 'agent stopped: max budget reached';
|
|
488
|
+
if (msg.subtype === 'error_during_execution')
|
|
489
|
+
return 'agent error during execution';
|
|
490
|
+
if (typeof msg.result === 'string' && msg.result)
|
|
491
|
+
return msg.result;
|
|
492
|
+
return 'agent error';
|
|
493
|
+
}
|