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.
Files changed (76) hide show
  1. package/README.md +22 -13
  2. package/dist/artifact-archive.js +152 -0
  3. package/dist/connectors-cli.js +80 -0
  4. package/dist/credentials.js +4 -1
  5. package/dist/daytona-slice-rehearsal.js +255 -0
  6. package/dist/daytona-slice.js +441 -0
  7. package/dist/egress-allowlist-cli.js +132 -0
  8. package/dist/egress-policy.js +159 -0
  9. package/dist/git-credential.js +131 -0
  10. package/dist/github-setup.js +226 -0
  11. package/dist/help.js +276 -21
  12. package/dist/hosted-bootstrap-rehearsal.js +235 -0
  13. package/dist/hosted-bootstrap.js +365 -0
  14. package/dist/hosted-broker-server.js +220 -0
  15. package/dist/hosted-git-http-server.js +136 -0
  16. package/dist/hosted-harness-claude.js +493 -0
  17. package/dist/hosted-harness-opencode.js +729 -0
  18. package/dist/hosted-harness.js +60 -0
  19. package/dist/hosted-loop.js +477 -0
  20. package/dist/hosted-run-event-sink.js +499 -0
  21. package/dist/hosted-runtime-assets.js +216 -0
  22. package/dist/hosted-session-cli.js +642 -0
  23. package/dist/index.js +945 -258
  24. package/dist/kill-switch-cli.js +89 -0
  25. package/dist/manifests-cli.js +143 -0
  26. package/dist/preview-runtime.js +4 -1
  27. package/dist/sandbox-provider.js +238 -0
  28. package/dist/secret-redaction.js +112 -0
  29. package/dist/session-timeline.js +103 -0
  30. package/dist/setup-prompt.js +4 -2
  31. package/dist/skill-installer.js +9 -1
  32. package/dist/smoke.js +250 -0
  33. package/dist/task-assignment-manifest.js +96 -0
  34. package/dist/vercel-sandbox-provider.js +260 -0
  35. package/dist/workbench-cli.js +493 -0
  36. package/dist/workspace-sync.js +924 -0
  37. package/dist/workspace.js +868 -57
  38. package/package.json +4 -1
  39. package/src/artifact-archive.ts +182 -0
  40. package/src/connectors-cli.ts +115 -0
  41. package/src/credentials.ts +6 -2
  42. package/src/daytona-slice-rehearsal.ts +320 -0
  43. package/src/daytona-slice.ts +598 -0
  44. package/src/egress-allowlist-cli.ts +147 -0
  45. package/src/egress-policy.ts +188 -0
  46. package/src/git-credential.ts +155 -0
  47. package/src/github-setup.ts +366 -0
  48. package/src/help.ts +277 -21
  49. package/src/hosted-bootstrap-rehearsal.ts +312 -0
  50. package/src/hosted-bootstrap.ts +571 -0
  51. package/src/hosted-broker-server.ts +278 -0
  52. package/src/hosted-git-http-server.ts +164 -0
  53. package/src/hosted-harness-claude.ts +621 -0
  54. package/src/hosted-harness-opencode.ts +823 -0
  55. package/src/hosted-harness.ts +124 -0
  56. package/src/hosted-loop.ts +680 -0
  57. package/src/hosted-run-event-sink.ts +632 -0
  58. package/src/hosted-runtime-assets.ts +272 -0
  59. package/src/hosted-session-cli.ts +844 -0
  60. package/src/index.ts +1102 -317
  61. package/src/kill-switch-cli.ts +113 -0
  62. package/src/manifests-cli.ts +168 -0
  63. package/src/preview-runtime.ts +14 -9
  64. package/src/sandbox-provider.ts +440 -0
  65. package/src/secret-redaction.ts +124 -0
  66. package/src/session-timeline.ts +152 -0
  67. package/src/setup-prompt.ts +4 -2
  68. package/src/skill-installer.ts +13 -1
  69. package/src/smoke.ts +342 -0
  70. package/src/task-assignment-manifest.ts +164 -0
  71. package/src/vercel-sandbox-provider.ts +388 -0
  72. package/src/workbench-cli.ts +701 -0
  73. package/src/workspace-sync.ts +1148 -0
  74. package/src/workspace.ts +1051 -64
  75. package/vendor/skills/orizu-cli/SKILL.md +8 -4
  76. package/vendor/skills/orizu-cli/references/cli-reference.md +12 -4
@@ -0,0 +1,680 @@
1
+ /**
2
+ * In-sandbox hosted agent loop (ALI-928 / P3.5, per ADR-005).
3
+ *
4
+ * Runs INSIDE the sandbox (installed by the bootstrap CLI step, launched
5
+ * DETACHED by `orizu session start --hosted`). It composes the ALI-926 pieces:
6
+ *
7
+ * 1. resolve the loop boot context (a run-scoped JSON file written by the host
8
+ * orchestrator) + read the agent bearer from its 0600 file (G3);
9
+ * 2. install `opencode` pinned to OPENCODE_PINNED_VERSION (non-fatal-RECORDED;
10
+ * if it is unavailable the spawn/connect below fails and the run finishes
11
+ * 'failed' cleanly — the run never dangles);
12
+ * 3. build a RESUME-AWARE RunEventSink (continues the server's sequence after
13
+ * the run-start + bootstrap events — so a reconnect loses nothing);
14
+ * 4. provision the `AgentHarness` SELECTED by `context.harness` (default
15
+ * 'opencode': install+spawn `opencode serve` then connect the OpenCode
16
+ * driver; 'claude-agent-sdk': construct the in-process Claude-Agent-SDK
17
+ * driver — no server to install or spawn), then drive the SINGLE task prompt
18
+ * (v0: no queue) through `drainHarnessToSink`;
19
+ * 5. the terminal event decides the run's final status via the sink's PATCH.
20
+ *
21
+ * SWAPPABILITY (ALI-929 / P3.6): harness SELECTION is the ONLY thing that differs
22
+ * between the two drivers — `start()` → `runPrompt()` → `drainHarnessToSink()` →
23
+ * `shutdown()` is byte-identical for both, and the sink, event vocabulary, and
24
+ * terminal-PATCH flow are unchanged. That is the proof: a second harness drops in
25
+ * behind the seam with no change to any consumer beyond the selector.
26
+ *
27
+ * The redaction list carries the agent bearer (added automatically by the sink)
28
+ * plus any model key present in the process env (defense in depth for the G3
29
+ * per-exec fallback — with firewall brokering the real key never reaches here).
30
+ *
31
+ * TESTABILITY: `runHostedLoop` takes injected `fetchImpl`, `spawnOpenCode`,
32
+ * `installOpenCode`, and `createHarness`, so the whole loop runs in-process
33
+ * against a fake broker + fake OpenCode server with no real binary.
34
+ */
35
+
36
+ import { spawnSync, spawn as nodeSpawn } from 'child_process'
37
+ import { existsSync, readFileSync } from 'fs'
38
+ import { join } from 'path'
39
+
40
+ import type { AgentHarness, HarnessEvent, HarnessPrompt } from './hosted-harness.js'
41
+ import {
42
+ PREBAKED_MARKER_PATH,
43
+ SETUP_HOOK_RELATIVE_PATH,
44
+ parsePrebakedMarker,
45
+ } from './hosted-runtime-assets.js'
46
+ import {
47
+ OPENCODE_PINNED_VERSION,
48
+ createOpenCodeHarness,
49
+ spawnOpenCode,
50
+ type SpawnOpenCodeOptions,
51
+ type SpawnedOpenCode,
52
+ } from './hosted-harness-opencode.js'
53
+ import { createClaudeAgentHarness } from './hosted-harness-claude.js'
54
+ import {
55
+ drainHarnessToSink,
56
+ resumeRunEventSink,
57
+ type HostedFetch,
58
+ type TerminalStatus,
59
+ } from './hosted-run-event-sink.js'
60
+
61
+ /**
62
+ * The run-scoped context the host orchestrator writes for the loop. Paths are
63
+ * ABSOLUTE (or sandbox-root-relative) so the loop resolves them regardless of
64
+ * cwd. NO secret lives here — the bearer is read from `bearerFile` (0600).
65
+ */
66
+ export interface HostedLoopContext {
67
+ apiBaseUrl: string
68
+ runId: string
69
+ /** 0600 file holding the agent bearer (never inlined into this context). */
70
+ bearerFile: string
71
+ /** File holding the single task prompt (v0: one prompt, no queue). */
72
+ taskFile: string
73
+ /** Directory the agent operates in (the cloned session-branch workspace). */
74
+ workspaceDir: string
75
+ /** Provider-qualified model, e.g. "anthropic/claude-opus-4-8". */
76
+ model: string
77
+ reasoningEffort?: string
78
+ /** Idempotency / correlation key for the prompt. */
79
+ messageId: string
80
+ /** Git identity for commit attribution during the prompt. */
81
+ author: { name: string; email: string }
82
+ /**
83
+ * Which `AgentHarness` drives the loop (ALI-929 swappability seam). Default
84
+ * 'opencode'. 'claude-agent-sdk' selects the in-process Claude-Agent-SDK driver
85
+ * — no `opencode` install/spawn happens on that path.
86
+ */
87
+ harness?: 'opencode' | 'claude-agent-sdk'
88
+ opencodePort?: number
89
+ opencodePinnedVersion?: string
90
+ /**
91
+ * Pre-baked runtime (ALI-1017): the image already ships the pinned `opencode`
92
+ * on PATH, so the loop SKIPS the `opencode-ai` install (npm is blocked under G5
93
+ * default-deny egress) and spawns `opencode` directly, recording an
94
+ * `opencode_prebaked` artifact instead of `opencode_install`. The host sets this
95
+ * together with the sandbox `image` so the two never disagree. When unset, the
96
+ * loop ALSO belt-checks the `/opt/orizu/prebaked.json` marker; either signal
97
+ * skips the install. Ignored on the claude-agent-sdk path (no server to install).
98
+ */
99
+ prebaked?: boolean
100
+ /**
101
+ * Non-secret placeholder key OpenCode is given so it FORMS model requests; the
102
+ * sandbox firewall's request transform overrides the real auth header at the
103
+ * proxy (model-key brokering, G3). Never a real key.
104
+ */
105
+ anthropicDummyKey?: string
106
+ /** OpenCode session id to resume on reconnect. */
107
+ resumeAgentSessionId?: string
108
+ /**
109
+ * Startup egress-canary probe host (G5 / ALI-1006). When SET, the loop runs a
110
+ * POSITIVE-CONTROL canary before any agent work, probing BOTH this known
111
+ * NON-allowlisted (denied) host AND a known-allowed host (the Orizu API base,
112
+ * derived from `apiBaseUrl`). The run proceeds ONLY IF the allowed host is
113
+ * reachable AND the denied host is blocked (`egress_blocked` proof). Any other
114
+ * outcome FAILS the run closed (`egress_allowed`): the denied host reachable
115
+ * means the firewall did not take; the allowed host ALSO unreachable means the
116
+ * network is broken and we cannot distinguish policy-enforcement from a total
117
+ * outage — either way we must not proceed to real customer data. The host sets
118
+ * this only for providers that actually enforce egress (Vercel); it is UNSET
119
+ * for local-sim / no-policy runs, so the canary is skipped there. Default
120
+ * denied probe target: `example.com`.
121
+ */
122
+ egressCanaryHost?: string
123
+ /**
124
+ * When true, run the customer `.orizu/setup.sh` hook HERE (in the loop), AFTER
125
+ * the egress canary has proven the firewall is live and BEFORE harness work.
126
+ * The host sets this for enforced-egress providers so bootstrap DEFERS the hook
127
+ * (which would otherwise get network access before the canary could detect a
128
+ * silent firewall failure). UNSET for local-sim / no-policy runs, where
129
+ * bootstrap runs the hook inline as before. See docs sandbox-egress-policy §4.
130
+ */
131
+ runSetupHook?: boolean
132
+ }
133
+
134
+ export interface InstallResult {
135
+ ok: boolean
136
+ detail: string
137
+ }
138
+
139
+ export interface RunHostedLoopOptions {
140
+ context: HostedLoopContext
141
+ /**
142
+ * Agent bearer, as a FIXED string (tests / in-process launcher) OR omitted in
143
+ * favor of `bearerProvider`. In production the loop reads the rotated 0600
144
+ * bearer file per request via a provider (see `bearerProvider`), so a
145
+ * host-side rotation is picked up without restarting the loop.
146
+ */
147
+ bearer?: string
148
+ /** Per-request bearer resolver (default: read `context.bearerFile`, trimmed).
149
+ * Passing a provider is what lets rotation reach the event sink. */
150
+ bearerProvider?: () => string
151
+ /** The single task prompt (already read from the task file by the caller). */
152
+ taskPrompt: string
153
+ fetchImpl?: HostedFetch
154
+ /** Build the harness for a spawned OpenCode base URL (default: OpenCode driver). */
155
+ createHarness?: (baseUrl: string) => AgentHarness
156
+ /** Build the in-process Claude-Agent-SDK harness (default: real SDK loader).
157
+ * Injectable so the swap test drives a fake `query()` with no real SDK. */
158
+ createClaudeHarness?: () => AgentHarness
159
+ spawnOpenCode?: (opts: SpawnOpenCodeOptions) => SpawnedOpenCode
160
+ installOpenCode?: () => Promise<InstallResult>
161
+ now?: () => number
162
+ signal?: AbortSignal
163
+ /** Extra verbatim secrets to redact (the bearer is added by the sink itself). */
164
+ redactSecretsList?: readonly string[]
165
+ /** Egress-canary probe (default: a bounded `fetch` to https://<host>/).
166
+ * Injectable so tests exercise both the blocked and reachable branches with
167
+ * no real network. Called once per host (allowed + denied) by the canary. */
168
+ probeEgress?: (host: string) => Promise<EgressProbeResult>
169
+ /** Run the deferred customer setup hook (default: `bash .orizu/setup.sh` in the
170
+ * workspace, non-fatal). Injectable so tests exercise the deferred-hook path
171
+ * with no real filesystem or child process. */
172
+ runSetupHook?: (input: { workspaceDir: string }) => Promise<SetupHookOutcome> | SetupHookOutcome
173
+ }
174
+
175
+ export interface HostedLoopResult {
176
+ status: TerminalStatus
177
+ agentSessionId: string | null
178
+ installOk: boolean
179
+ error: string | null
180
+ }
181
+
182
+ /** Default egress-canary probe host (a known non-allowlisted destination). */
183
+ export const DEFAULT_EGRESS_CANARY_HOST = 'example.com'
184
+ /** Bound (ms) on the canary probe — a live firewall resets/times out fast; a
185
+ * reachable host answers well within this. */
186
+ const EGRESS_CANARY_TIMEOUT_MS = 5000
187
+
188
+ export interface EgressProbeResult {
189
+ /** True when the denied host was reachable (policy did NOT take → fail closed). */
190
+ reachable: boolean
191
+ /** Short human/audit detail (HTTP status on reach, error class on block). */
192
+ detail: string
193
+ }
194
+
195
+ /**
196
+ * Default probe: attempt a bounded HTTPS GET to `host`. A live default-deny
197
+ * firewall makes this THROW for a DENIED host (connection reset / timeout / DNS
198
+ * failure) → `reachable: false`. Any response at all — even an error status —
199
+ * proves the host was reachable → `reachable: true`. Used for BOTH the allowed
200
+ * (positive-control) and denied probes.
201
+ */
202
+ async function defaultProbeEgress(host: string): Promise<EgressProbeResult> {
203
+ const controller = new AbortController()
204
+ const timer = setTimeout(() => controller.abort(), EGRESS_CANARY_TIMEOUT_MS)
205
+ try {
206
+ const response = await fetch(`https://${host}/`, {
207
+ method: 'GET',
208
+ redirect: 'manual',
209
+ signal: controller.signal,
210
+ })
211
+ return { reachable: true, detail: `reachable (HTTP ${response.status})` }
212
+ } catch (error) {
213
+ return { reachable: false, detail: `blocked (${error instanceof Error ? error.name : 'error'})` }
214
+ } finally {
215
+ clearTimeout(timer)
216
+ }
217
+ }
218
+
219
+ /** The two hosts the positive-control canary probes. */
220
+ export interface EgressCanaryTargets {
221
+ /** A known-ALLOWED host (the Orizu API base) that MUST be reachable — the
222
+ * positive control that distinguishes "policy blocking" from "network dead". */
223
+ allowedHost: string
224
+ /** A known-DENIED, non-allowlisted host that MUST be blocked (`example.com`). */
225
+ deniedHost: string
226
+ }
227
+
228
+ export interface EgressCanaryDecision {
229
+ proceed: boolean
230
+ /** Null on proceed; a human/audit reason for the fail-closed decision. */
231
+ reason: string | null
232
+ allowed: EgressProbeResult
233
+ denied: EgressProbeResult
234
+ }
235
+
236
+ /**
237
+ * Run the startup egress canary as a POSITIVE CONTROL and emit the proof event
238
+ * through the (single-writer) sink. Probes BOTH a known-allowed host and the
239
+ * known-denied host, then decides:
240
+ *
241
+ * - allowed REACHABLE and denied BLOCKED → the firewall is live AND the network
242
+ * works → emit `egress_blocked` and PROCEED (the ONLY healthy outcome);
243
+ * - denied REACHABLE → egress is NOT enforced → emit `egress_allowed`, FAIL
244
+ * CLOSED;
245
+ * - allowed ALSO UNREACHABLE (denied blocked but the positive control failed) →
246
+ * we cannot tell policy-blocking from a total network outage → emit
247
+ * `egress_allowed`, FAIL CLOSED.
248
+ *
249
+ * The `egress_blocked` kind therefore means EXACTLY "proceeded, positive control
250
+ * passed"; `egress_allowed` means "failed closed" (the payload `result` field
251
+ * distinguishes an unexpectedly-reachable denied host from an unreachable
252
+ * positive control). Old behavior — treating ANY denied-host throw (incl. a
253
+ * timeout under a total outage) as "blocked → proceed" — failed OPEN; this now
254
+ * fails closed on that ambiguity.
255
+ */
256
+ export async function runEgressCanary(
257
+ targets: EgressCanaryTargets,
258
+ sink: { append: (event: HarnessEvent) => Promise<void> },
259
+ probe: (host: string) => Promise<EgressProbeResult>,
260
+ now: () => number = () => Date.now()
261
+ ): Promise<EgressCanaryDecision> {
262
+ const [allowed, denied] = await Promise.all([probe(targets.allowedHost), probe(targets.deniedHost)])
263
+ const checkedAt = new Date(now()).toISOString()
264
+ const positiveControl = {
265
+ host: targets.allowedHost,
266
+ reachable: allowed.reachable,
267
+ detail: allowed.detail,
268
+ }
269
+
270
+ // FAIL CLOSED: a denied host answered — the policy is not enforcing egress.
271
+ if (denied.reachable) {
272
+ await sink.append({
273
+ kind: 'egress_allowed',
274
+ critical: true,
275
+ payload: {
276
+ host: targets.deniedHost,
277
+ result: 'unexpectedly_reachable',
278
+ detail: denied.detail,
279
+ positiveControl,
280
+ checkedAt,
281
+ },
282
+ })
283
+ return {
284
+ proceed: false,
285
+ reason: `denied host ${targets.deniedHost} was reachable (${denied.detail}) — egress is not enforced`,
286
+ allowed,
287
+ denied,
288
+ }
289
+ }
290
+
291
+ // FAIL CLOSED: the denied host was blocked, but the positive control ALSO did
292
+ // not answer — the environment/network is broken and we cannot distinguish a
293
+ // policy block from a total outage. This is the case the old code got wrong.
294
+ if (!allowed.reachable) {
295
+ await sink.append({
296
+ kind: 'egress_allowed',
297
+ critical: true,
298
+ payload: {
299
+ host: targets.deniedHost,
300
+ result: 'positive_control_unreachable',
301
+ detail: denied.detail,
302
+ positiveControl,
303
+ checkedAt,
304
+ },
305
+ })
306
+ return {
307
+ proceed: false,
308
+ reason: `positive control ${targets.allowedHost} was unreachable (${allowed.detail}) — cannot distinguish egress enforcement from a network outage`,
309
+ allowed,
310
+ denied,
311
+ }
312
+ }
313
+
314
+ // HEALTHY: allowed reachable AND denied blocked → the firewall is live.
315
+ await sink.append({
316
+ kind: 'egress_blocked',
317
+ critical: true,
318
+ payload: {
319
+ host: targets.deniedHost,
320
+ result: 'blocked',
321
+ detail: denied.detail,
322
+ positiveControl,
323
+ checkedAt,
324
+ },
325
+ })
326
+ return { proceed: true, reason: null, allowed, denied }
327
+ }
328
+
329
+ /**
330
+ * Derive the known-ALLOWED positive-control host from the run's Orizu API base —
331
+ * always allowlisted (it is the agent's control-plane path) and ours, so it is
332
+ * the safest "must be reachable" signal. Falls back to a lenient host parse if
333
+ * the base is not a well-formed URL.
334
+ */
335
+ export function egressCanaryAllowedHost(apiBaseUrl: string): string {
336
+ try {
337
+ return new URL(apiBaseUrl).hostname
338
+ } catch {
339
+ return apiBaseUrl.replace(/^[a-z]+:\/\//i, '').split('/')[0].split(':')[0]
340
+ }
341
+ }
342
+
343
+ /** Outcome of the deferred customer setup hook run inside the loop. */
344
+ export interface SetupHookOutcome {
345
+ /** True when a `.orizu/setup.sh` existed and was executed. */
346
+ ran: boolean
347
+ /** True when the hook was absent OR exited 0 (non-fatal either way). */
348
+ ok: boolean
349
+ detail: string
350
+ }
351
+
352
+ /**
353
+ * Default deferred-setup-hook runner (in-sandbox): run `bash .orizu/setup.sh` in
354
+ * the workspace if present. Non-fatal — a missing hook or a non-zero exit is
355
+ * recorded and the loop proceeds, matching the bootstrap's inline behavior.
356
+ */
357
+ function defaultRunSetupHook({ workspaceDir }: { workspaceDir: string }): SetupHookOutcome {
358
+ const hookPath = join(workspaceDir, SETUP_HOOK_RELATIVE_PATH)
359
+ if (!existsSync(hookPath)) return { ran: false, ok: true, detail: 'no .orizu/setup.sh' }
360
+ const res = spawnSync('bash', [SETUP_HOOK_RELATIVE_PATH], { cwd: workspaceDir, encoding: 'utf8' })
361
+ const ok = (res.status ?? 1) === 0
362
+ return { ran: true, ok, detail: ok ? 'setup.sh ok' : `setup.sh exit ${res.status ?? 'unknown'}` }
363
+ }
364
+
365
+ /** Model-key env vars whose value must be scrubbed from every run event. */
366
+ const MODEL_KEY_ENV_VARS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY']
367
+
368
+ function redactionListFromEnv(): string[] {
369
+ const secrets: string[] = []
370
+ for (const key of MODEL_KEY_ENV_VARS) {
371
+ const value = process.env[key]
372
+ if (value) secrets.push(value)
373
+ }
374
+ return secrets
375
+ }
376
+
377
+ /**
378
+ * Decide whether the loop is running the PRE-BAKED runtime image (ALI-1017).
379
+ * Prefers the explicit boot-context flag (the host KNOWS the image it passed);
380
+ * falls back to a belt read of the `/opt/orizu/prebaked.json` marker (validated
381
+ * via `parsePrebakedMarker`, so a stray file cannot trigger a skip). Either → true.
382
+ */
383
+ function detectLoopPrebaked(flag: boolean | undefined): boolean {
384
+ if (flag) return true
385
+ try {
386
+ return parsePrebakedMarker(readFileSync(PREBAKED_MARKER_PATH, 'utf8')) !== null
387
+ } catch {
388
+ return false
389
+ }
390
+ }
391
+
392
+ /** Best-effort global install of the pinned opencode. Non-fatal: a failure is
393
+ * recorded and the run finishes cleanly when the subsequent connect fails. */
394
+ function installOpenCodePinned(version: string): InstallResult {
395
+ const command =
396
+ `bun add -g opencode-ai@${version} >/dev/null 2>&1 || ` +
397
+ `npm install -g opencode-ai@${version} >/dev/null 2>&1`
398
+ const res = spawnSync('bash', ['-c', command], { encoding: 'utf8' })
399
+ const ok = (res.status ?? 1) === 0
400
+ return { ok, detail: ok ? `installed opencode-ai@${version}` : `install exit ${res.status ?? 'unknown'}` }
401
+ }
402
+
403
+ /** Node child_process spawner for `opencode serve` (the sandbox runtime is
404
+ * node24 — Bun.spawn is not guaranteed present, so spawnOpenCode is given this
405
+ * runtime-agnostic spawner). */
406
+ function nodeChildSpawner(
407
+ cmd: string[],
408
+ opts: { cwd?: string; env: Record<string, string> }
409
+ ): { kill: () => void } {
410
+ const child = nodeSpawn(cmd[0], cmd.slice(1), {
411
+ cwd: opts.cwd,
412
+ env: { ...process.env, ...opts.env },
413
+ stdio: 'ignore',
414
+ })
415
+ return { kill: () => child.kill() }
416
+ }
417
+
418
+ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedLoopResult> {
419
+ const { context, taskPrompt } = opts
420
+ const fetchImpl = opts.fetchImpl ?? (globalThis.fetch as HostedFetch)
421
+ const signal = opts.signal ?? new AbortController().signal
422
+ const pinnedVersion = context.opencodePinnedVersion ?? OPENCODE_PINNED_VERSION
423
+
424
+ // Resolve the bearer per request. Precedence: an explicit provider, then a
425
+ // fixed string, else read the rotated 0600 bearer file every time (production
426
+ // + rotation path — a host-side rotation overwrites this file and the sink's
427
+ // next resolve picks it up).
428
+ const fixedBearer = opts.bearer
429
+ const bearerProvider: () => string =
430
+ opts.bearerProvider ??
431
+ (fixedBearer !== undefined
432
+ ? (): string => fixedBearer
433
+ : (): string => readFileSync(context.bearerFile, 'utf8').trim())
434
+
435
+ // Build the resume-aware sink FIRST: even if the opencode install/spawn fails,
436
+ // we can record a terminal transition so the run is never left non-terminal.
437
+ const sink = await resumeRunEventSink({
438
+ apiBaseUrl: context.apiBaseUrl,
439
+ runId: context.runId,
440
+ bearer: bearerProvider,
441
+ fetchImpl,
442
+ now: opts.now,
443
+ redactSecretsList: [...(opts.redactSecretsList ?? []), ...redactionListFromEnv()],
444
+ })
445
+
446
+ // G5 startup canary (fail-closed, POSITIVE CONTROL): when the host applied an
447
+ // enforced egress policy it sets `egressCanaryHost`; probe BOTH the denied host
448
+ // AND a known-allowed host (the Orizu API base) BEFORE any agent work. Proceed
449
+ // ONLY IF the allowed host is reachable and the denied host is blocked; any
450
+ // other outcome (denied reachable → firewall not enforcing; allowed ALSO
451
+ // unreachable → network broken / indistinguishable from a block) emits
452
+ // `egress_allowed` and finishes the run FAILED rather than touch real customer
453
+ // data. A canary whose own event append fails is likewise fail-closed.
454
+ if (context.egressCanaryHost) {
455
+ const probe = opts.probeEgress ?? defaultProbeEgress
456
+ const targets: EgressCanaryTargets = {
457
+ allowedHost: egressCanaryAllowedHost(context.apiBaseUrl),
458
+ deniedHost: context.egressCanaryHost,
459
+ }
460
+ let canary: EgressCanaryDecision
461
+ try {
462
+ canary = await runEgressCanary(targets, sink, probe, opts.now)
463
+ } catch (error) {
464
+ // The canary's own event append failed — treat as fail-closed: we cannot
465
+ // prove the firewall is live, so do not proceed.
466
+ const message = error instanceof Error ? error.message : String(error)
467
+ if (!sink.sealed) {
468
+ try {
469
+ await sink.finish('failed', { summary: { error: `egress canary error: ${message}` } })
470
+ } catch {
471
+ // best-effort terminal
472
+ }
473
+ }
474
+ return { status: 'failed', agentSessionId: null, installOk: false, error: `egress canary error: ${message}` }
475
+ }
476
+ if (!canary.proceed) {
477
+ const error = `egress canary FAILED: ${canary.reason} — refusing to proceed`
478
+ if (!sink.sealed) {
479
+ try {
480
+ await sink.finish('failed', {
481
+ summary: {
482
+ error,
483
+ egressCanary: {
484
+ deniedHost: targets.deniedHost,
485
+ deniedReachable: canary.denied.reachable,
486
+ deniedDetail: canary.denied.detail,
487
+ allowedHost: targets.allowedHost,
488
+ allowedReachable: canary.allowed.reachable,
489
+ allowedDetail: canary.allowed.detail,
490
+ },
491
+ },
492
+ })
493
+ } catch {
494
+ // best-effort terminal
495
+ }
496
+ }
497
+ return { status: 'failed', agentSessionId: null, installOk: false, error }
498
+ }
499
+ }
500
+
501
+ // P3-a: run the DEFERRED customer setup hook now — AFTER the canary proved the
502
+ // firewall is live, BEFORE any harness/network work. bootstrap deferred it (for
503
+ // enforced-egress providers) precisely so its network access could not precede
504
+ // the canary. Non-fatal, single-writer (this loop sink owns the run now).
505
+ if (context.runSetupHook) {
506
+ try {
507
+ const runHook = opts.runSetupHook ?? defaultRunSetupHook
508
+ const outcome = await runHook({ workspaceDir: context.workspaceDir })
509
+ await sink.append({
510
+ kind: 'artifact',
511
+ payload: {
512
+ step: outcome.ran ? 'setup_hook_completed' : 'setup_hook_skipped',
513
+ ok: outcome.ok,
514
+ detail: outcome.detail,
515
+ deferred: true,
516
+ },
517
+ })
518
+ } catch (error) {
519
+ // The hook run/record is best-effort — never block the run on it.
520
+ const message = error instanceof Error ? error.message : String(error)
521
+ if (!sink.sealed) {
522
+ try {
523
+ await sink.append({
524
+ kind: 'artifact',
525
+ payload: { step: 'setup_hook_error', ok: false, detail: message, deferred: true },
526
+ })
527
+ } catch {
528
+ // best-effort
529
+ }
530
+ }
531
+ }
532
+ }
533
+
534
+ let spawned: SpawnedOpenCode | null = null
535
+ let agentSessionId: string | null = null
536
+ let installOk = false
537
+ const harnessKind = context.harness ?? 'opencode'
538
+ try {
539
+ // --- Harness PROVISIONING: the ONLY thing that differs between the two
540
+ // drivers (ALI-929). Everything after `harness` is constructed is identical.
541
+ let harness: AgentHarness
542
+ if (harnessKind === 'claude-agent-sdk') {
543
+ // In-process agent loop: no server to install or spawn. Record an
544
+ // analogous setup artifact so the event stream shape is unchanged.
545
+ installOk = true
546
+ await sink.append({
547
+ kind: 'artifact',
548
+ payload: { step: 'harness_select', harness: harnessKind, inProcess: true },
549
+ })
550
+ // G3 parity with the OpenCode path: hand the in-process SDK client the SAME
551
+ // non-secret dummy key (firewall brokers the real key on egress).
552
+ harness = (opts.createClaudeHarness ??
553
+ ((): AgentHarness =>
554
+ createClaudeAgentHarness({ anthropicDummyKey: context.anthropicDummyKey })))()
555
+ } else {
556
+ // PRE-BAKED (ALI-1017): the pinned `opencode` is already on PATH, so SKIP the
557
+ // install (npm is blocked under G5 default-deny egress) and record
558
+ // `opencode_prebaked`. FROM-SCRATCH (local-sim / fallback): install as before.
559
+ const prebaked = detectLoopPrebaked(context.prebaked)
560
+ if (prebaked) {
561
+ installOk = true
562
+ await sink.append({
563
+ kind: 'artifact',
564
+ payload: { step: 'opencode_prebaked', ok: true, detail: `opencode-ai@${pinnedVersion} pre-baked in the runtime image` },
565
+ })
566
+ } else {
567
+ const install = opts.installOpenCode
568
+ ? await opts.installOpenCode()
569
+ : installOpenCodePinned(pinnedVersion)
570
+ installOk = install.ok
571
+ await sink.append({
572
+ kind: 'artifact',
573
+ payload: { step: 'opencode_install', ok: install.ok, detail: install.detail },
574
+ })
575
+ }
576
+
577
+ const spawnImpl = opts.spawnOpenCode ?? spawnOpenCode
578
+ spawned = spawnImpl({
579
+ model: context.model,
580
+ cwd: context.workspaceDir,
581
+ port: context.opencodePort,
582
+ env: context.anthropicDummyKey ? { ANTHROPIC_API_KEY: context.anthropicDummyKey } : undefined,
583
+ spawn: nodeChildSpawner,
584
+ })
585
+
586
+ harness = (opts.createHarness ?? ((baseUrl: string) => createOpenCodeHarness({ baseUrl })))(
587
+ spawned.baseUrl
588
+ )
589
+ }
590
+
591
+ const started = await harness.start({
592
+ workspaceDir: context.workspaceDir,
593
+ model: context.model,
594
+ reasoningEffort: context.reasoningEffort,
595
+ resumeAgentSessionId: context.resumeAgentSessionId,
596
+ })
597
+ agentSessionId = started.agentSessionId
598
+
599
+ const prompt: HarnessPrompt = {
600
+ runId: context.runId,
601
+ messageId: context.messageId,
602
+ content: taskPrompt,
603
+ author: context.author,
604
+ }
605
+ const status = await drainHarnessToSink(harness.runPrompt(prompt, signal), sink, {
606
+ summary: { agentSessionId },
607
+ })
608
+ await harness.shutdown()
609
+ return { status, agentSessionId, installOk, error: null }
610
+ } catch (error) {
611
+ const message = error instanceof Error ? error.message : String(error)
612
+ // Fail closed: never leave the run non-terminal. finish() is idempotent and
613
+ // no-ops when the drain already sealed the sink.
614
+ if (!sink.sealed) {
615
+ try {
616
+ await sink.finish('failed', { summary: { error: message } })
617
+ } catch {
618
+ // best-effort — the terminal write itself may be impossible (bearer gone)
619
+ }
620
+ }
621
+ return { status: 'failed', agentSessionId, installOk, error: message }
622
+ } finally {
623
+ if (spawned) {
624
+ try {
625
+ spawned.stop()
626
+ } catch {
627
+ // ignore teardown failure
628
+ }
629
+ }
630
+ }
631
+ }
632
+
633
+ // -- Thin CLI entry (`orizu internal hosted-loop --context <path>`) -----------
634
+
635
+ export interface HostedLoopCommandIo {
636
+ print: (line: string) => void
637
+ printErr?: (line: string) => void
638
+ json?: boolean
639
+ }
640
+
641
+ function argValue(args: readonly string[], flag: string): string | null {
642
+ const index = args.indexOf(flag)
643
+ if (index === -1 || index + 1 >= args.length) return null
644
+ const value = args[index + 1]
645
+ return value.startsWith('--') ? null : value
646
+ }
647
+
648
+ /**
649
+ * Read the loop context + bearer + task from disk and run the loop. Invoked
650
+ * in-sandbox as a hidden command; the run's terminal status is recorded
651
+ * server-side regardless of this process's exit code.
652
+ */
653
+ export async function hostedLoopCommand(
654
+ args: readonly string[],
655
+ io: HostedLoopCommandIo
656
+ ): Promise<number> {
657
+ const contextPath = argValue(args, '--context')
658
+ if (!contextPath) {
659
+ io.printErr?.('Usage: orizu internal hosted-loop --context <path>')
660
+ return 1
661
+ }
662
+ let context: HostedLoopContext
663
+ try {
664
+ context = JSON.parse(readFileSync(contextPath, 'utf8')) as HostedLoopContext
665
+ } catch (error) {
666
+ io.printErr?.(`unreadable loop context: ${error instanceof Error ? error.message : String(error)}`)
667
+ return 1
668
+ }
669
+ // Read the bearer per request (via the provider) so a host-side rotation that
670
+ // overwrites the 0600 file is picked up without restarting the loop.
671
+ const bearerProvider = (): string => readFileSync(context.bearerFile, 'utf8').trim()
672
+ const taskPrompt = readFileSync(context.taskFile, 'utf8')
673
+ const result = await runHostedLoop({ context, bearerProvider, taskPrompt })
674
+ io.print(
675
+ io.json
676
+ ? JSON.stringify({ status: result.status, agentSessionId: result.agentSessionId, error: result.error })
677
+ : `hosted-loop finished: ${result.status}${result.error ? ` (${result.error})` : ''}`
678
+ )
679
+ return result.error ? 1 : 0
680
+ }