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,571 @@
1
+ /**
2
+ * Host-driven hosted-sandbox bootstrap (ALI-925 / P3.3, per ADR-005 §8).
3
+ *
4
+ * Given a live `SandboxSession` (from the Orizu `SandboxProvider` seam — works
5
+ * with BOTH the Daytona adapter and local-sim), an Orizu API base URL, a bearer,
6
+ * and the workspace/session/run ids + session branch, `bootstrapHostedSandbox`
7
+ * performs the in-sandbox boot flow forked in DESIGN from OpenInspect's
8
+ * `entrypoint.py` supervisor:
9
+ *
10
+ * 1. inject boot context (env/config) as run-scoped files — the bearer only in
11
+ * a 0600 file reaped at teardown, never in git config or the clone URL (G3);
12
+ * 2. install the git credential helper (self-contained script asset) and scope
13
+ * it to the repo dir ONLY (never --global);
14
+ * 3. clone the session branch via the helper (no token in URL / .git/config);
15
+ * 4. install the Orizu CLI (non-fatal, recorded);
16
+ * 5. run the customer `.orizu/setup.sh` hook if present (fresh-boot, non-fatal);
17
+ * 6. record lifecycle events to the RunAPI with client-assigned sequences
18
+ * (generic event types only — never the reserved run_* lifecycle types);
19
+ * 7. on failure, record `bootstrap_failed`, tear down, and sweep for residue.
20
+ *
21
+ * P3.4/P3.5 (OpenCode driver, event bridge, `orizu session start --hosted`)
22
+ * build ON TOP of this — `bootstrapHostedSandbox` is the single writer for the
23
+ * run at this stage and returns the run cursor so a follow-on can continue it.
24
+ */
25
+
26
+ import type { SandboxSession } from './sandbox-provider.js'
27
+ import { sweepForTokenResidue, type HygieneFinding } from './daytona-slice.js'
28
+ import { redactSecrets } from './secret-redaction.js'
29
+ import {
30
+ BEARER_BASENAME,
31
+ BOOT_CONTEXT_BASENAME,
32
+ DEFAULT_CACHE_REFRESH_BUFFER_MS,
33
+ HELPER_SCRIPT_BASENAME,
34
+ PREBAKED_MARKER_PATH,
35
+ REPO_CRED_CACHE_BASENAME,
36
+ SETUP_HOOK_RELATIVE_PATH,
37
+ parsePrebakedMarker,
38
+ renderCredentialHelperScript,
39
+ serializeBootContext,
40
+ type HostedBootContext,
41
+ } from './hosted-runtime-assets.js'
42
+
43
+ export type HostedFetch = (url: string, init?: RequestInit) => Promise<Response>
44
+
45
+ // -- Run-event sink (client-sequenced, redacted) -----------------------------
46
+
47
+ export interface AppendedRunEvent {
48
+ eventId: string
49
+ sequence: number
50
+ eventType: string
51
+ /** Already redacted — safe to inspect / serialize in tests. */
52
+ payload: Record<string, unknown>
53
+ }
54
+
55
+ /**
56
+ * The bootstrap orchestrator is the single writer for the run at this stage, so
57
+ * it owns a local monotonic sequence (per §4b). Every payload is redacted with
58
+ * the bearer before it leaves the process — a run-event body must never carry
59
+ * the bearer or a minted token. Lifecycle run_* types are rejected server-side;
60
+ * the orchestrator only emits generic types.
61
+ */
62
+ export interface BootstrapRunEventSink {
63
+ append(eventType: string, payload?: Record<string, unknown>): Promise<AppendedRunEvent>
64
+ readonly recorded: readonly AppendedRunEvent[]
65
+ }
66
+
67
+ export interface CreateRunEventSinkOptions {
68
+ apiBaseUrl: string
69
+ runId: string
70
+ bearer: string
71
+ fetchImpl?: HostedFetch
72
+ /** Extra verbatim secrets to redact from payloads (e.g. minted tokens). */
73
+ redactSecretsList?: readonly string[]
74
+ now?: () => number
75
+ /** Sequence to start at (defaults to 1). */
76
+ startSequence?: number
77
+ }
78
+
79
+ function newEventId(): string {
80
+ const cryptoObj = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
81
+ if (cryptoObj?.randomUUID) return cryptoObj.randomUUID()
82
+ return `evt-${Date.now()}-${Math.random().toString(36).slice(2)}`
83
+ }
84
+
85
+ export function createBootstrapRunEventSink(options: CreateRunEventSinkOptions): BootstrapRunEventSink {
86
+ const fetchImpl = options.fetchImpl ?? (globalThis.fetch as HostedFetch)
87
+ const now = options.now ?? (() => Date.now())
88
+ const base = options.apiBaseUrl.replace(/\/$/, '')
89
+ const secretList = [options.bearer, ...(options.redactSecretsList ?? [])]
90
+ const recorded: AppendedRunEvent[] = []
91
+ let sequence = options.startSequence ?? 1
92
+
93
+ return {
94
+ recorded,
95
+ async append(eventType, payload = {}) {
96
+ const redactedPayload = redactSecrets(payload, { secrets: secretList })
97
+ const event: AppendedRunEvent = {
98
+ eventId: newEventId(),
99
+ sequence: sequence,
100
+ eventType,
101
+ payload: redactedPayload,
102
+ }
103
+ const response = await fetchImpl(
104
+ `${base}/api/cli/workbench-runs/${encodeURIComponent(options.runId)}/events`,
105
+ {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${options.bearer}` },
108
+ body: JSON.stringify({
109
+ eventId: event.eventId,
110
+ sequence: event.sequence,
111
+ eventType: event.eventType,
112
+ occurredAt: new Date(now()).toISOString(),
113
+ payload: redactedPayload,
114
+ }),
115
+ }
116
+ )
117
+ if (!response.ok) {
118
+ // Best-effort: the event log must not sink the whole boot. Surface the
119
+ // status but never the body (could echo internals); never a token. The
120
+ // sequence number is NOT consumed on failure (P3d) — a failed POST must
121
+ // not leave a server-visible gap in the run's sequence.
122
+ throw new Error(`run-event append failed (${response.status}) for ${eventType}`)
123
+ }
124
+ sequence += 1
125
+ recorded.push(event)
126
+ return event
127
+ },
128
+ }
129
+ }
130
+
131
+ // -- Bootstrap orchestration -------------------------------------------------
132
+
133
+ export interface HostedBootstrapOptions {
134
+ session: SandboxSession
135
+ apiBaseUrl: string
136
+ /** Opaque bearer — a PAT in rehearsal, a G2 session token in production. */
137
+ bearer: string
138
+ workspaceId: string
139
+ sessionId: string
140
+ runId: string
141
+ sessionBranch: string
142
+ repoFullName: string
143
+ /** Map the broker repo full-name → a clone URL the provider understands.
144
+ * Live: `https://github.com/<repo>.git`. Local-sim: the bare repo path. */
145
+ resolveCloneUrl: (repo: string) => string
146
+ /** Directory (relative to the sandbox root) the session branch clones into. */
147
+ workspaceDir?: string
148
+ /** VCS host the credential helper serves (default github.com). */
149
+ host?: string
150
+ /**
151
+ * Broker purpose vocabulary the helper mints with. When omitted, the default
152
+ * is chosen from `bearerKind` (below): a human bearer mints `write`/`read`, an
153
+ * agent bearer mints `session_write`/`session_read`. Injectable so a caller can
154
+ * override without editing the credential-helper script.
155
+ */
156
+ tokenPurposes?: { primary: string; fallback: string }
157
+ /**
158
+ * Kind of bearer in the 0600 file — the orchestrator KNOWS what it minted, so
159
+ * the default broker purposes are chosen from this EXPLICIT signal, never by
160
+ * sniffing the token. 'agent' → session_write/session_read; 'human' (default)
161
+ * → write/read. Ignored when `tokenPurposes` is passed explicitly.
162
+ */
163
+ bearerKind?: 'agent' | 'human'
164
+ /**
165
+ * Hosts the credential helper may serve over plain HTTP (loopback rehearsal
166
+ * only). Production omits this → the helper stays https-only.
167
+ */
168
+ insecureHttpHosts?: readonly string[]
169
+ /** Helper cache refresh buffer (ms) — overridable so tests can force refresh. */
170
+ cacheBufferMs?: number
171
+ /**
172
+ * Defer the customer `.orizu/setup.sh` hook to the in-sandbox loop (P3-a). When
173
+ * true, bootstrap does NOT run the hook; it records `setup_hook_deferred` and
174
+ * the loop runs it AFTER its egress canary passes. The host sets this for
175
+ * enforced-egress providers so the hook's network access cannot precede the
176
+ * canary's detection of a silent firewall failure. Default false (run inline).
177
+ */
178
+ deferSetupHook?: boolean
179
+ /** Published CLI version to install in-sandbox (default 'latest'). */
180
+ cliVersion?: string
181
+ /** Override the CLI-install shell command (tests inject a deterministic one). */
182
+ cliInstallCommand?: string
183
+ /**
184
+ * Pre-baked runtime (ALI-1017): the image already ships the Orizu CLI on PATH,
185
+ * so the CLI-install step is SKIPPED (it would fail under G5 default-deny egress
186
+ * anyway) and a `cli_prebaked` event is recorded instead of `cli_installed`.
187
+ * The host sets this together with the sandbox `image` so the two never
188
+ * disagree. When unset, bootstrap ALSO belt-checks the `/opt/orizu/prebaked.json`
189
+ * marker on the sandbox filesystem; either signal skips the install.
190
+ */
191
+ prebaked?: boolean
192
+ /** Inject a RunAPI sink (tests/production); default builds one over apiBaseUrl. */
193
+ sink?: BootstrapRunEventSink
194
+ fetchImpl?: HostedFetch
195
+ now?: () => number
196
+ }
197
+
198
+ export interface HostedBootstrapStep {
199
+ key: string
200
+ ok: boolean
201
+ detail: string
202
+ }
203
+
204
+ export interface HostedBootstrapResult {
205
+ ok: boolean
206
+ steps: HostedBootstrapStep[]
207
+ events: readonly AppendedRunEvent[]
208
+ paths: HostedRuntimePaths
209
+ /** Set on the failure path: the residue sweep result after teardown. */
210
+ sweep: { clean: boolean; findings: HygieneFinding[] } | null
211
+ failure: { step: string; error: string } | null
212
+ }
213
+
214
+ export interface HostedRuntimePaths {
215
+ root: string
216
+ runDirRel: string
217
+ runDirAbs: string
218
+ repoDirRel: string
219
+ helperScriptAbs: string
220
+ bootContextAbs: string
221
+ bearerFileAbs: string
222
+ cacheFileAbs: string
223
+ }
224
+
225
+ const DEFAULT_WORKSPACE_DIR = 'repo'
226
+ const DEFAULT_TOKEN_PURPOSES = { primary: 'write', fallback: 'read' } as const
227
+ const DEFAULT_AGENT_TOKEN_PURPOSES = { primary: 'session_write', fallback: 'session_read' } as const
228
+ const DEFAULT_CLI_INSTALL = (version: string): string =>
229
+ `bun add -g orizu@${version} >/dev/null 2>&1 || npm install -g orizu@${version} >/dev/null 2>&1`
230
+
231
+ // Values we interpolate into shell commands must contain nothing a shell could
232
+ // treat specially. Allow the characters legitimately used by branch names, run
233
+ // ids, paths, and clone URLs — reject everything else rather than trust it.
234
+ const SAFE_SHELL_VALUE = /^[A-Za-z0-9._/:@-]+$/
235
+ function assertSafeShellValue(name: string, value: string): void {
236
+ if (!SAFE_SHELL_VALUE.test(value)) {
237
+ throw new Error(`unsafe characters in ${name} — refusing to interpolate into a shell command`)
238
+ }
239
+ }
240
+
241
+ /** Single-quote a value for safe embedding inside a shell command. */
242
+ function shellSingleQuote(value: string): string {
243
+ return `'${value.replace(/'/g, `'\\''`)}'`
244
+ }
245
+
246
+ /** Resolve the sandbox absolute root so helper paths are cwd-independent. */
247
+ async function resolveRoot(session: SandboxSession): Promise<string> {
248
+ const res = await session.exec('pwd')
249
+ const root = res.stdout.trim()
250
+ if (!root) throw new Error('could not resolve sandbox root (pwd returned empty)')
251
+ return root
252
+ }
253
+
254
+ /**
255
+ * The credential helper runs under a JS runtime (node >=18 for global fetch, or
256
+ * bun). If NEITHER is on PATH, the whole credential path is dead — abort before
257
+ * the clone with a message that names the missing runtime.
258
+ */
259
+ async function assertJsRuntimeAvailable(session: SandboxSession): Promise<void> {
260
+ const res = await session.exec(
261
+ 'if command -v node >/dev/null 2>&1 || command -v bun >/dev/null 2>&1; then echo ok; else echo missing; fi'
262
+ )
263
+ if (res.stdout.trim() !== 'ok') {
264
+ throw new Error('no JS runtime on PATH: the git credential helper requires node (>=18) or bun, neither found')
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Decide whether the sandbox is running the PRE-BAKED runtime image (ALI-1017).
270
+ * Prefers the explicit boot-context flag (the host KNOWS what image it passed —
271
+ * testable, no filesystem dependency); falls back to a belt read of the
272
+ * `/opt/orizu/prebaked.json` marker (validated via `parsePrebakedMarker`, so a
273
+ * stray same-named file cannot trigger a skip). Either signal → pre-baked.
274
+ */
275
+ async function detectPrebaked(session: SandboxSession, flag: boolean | undefined): Promise<boolean> {
276
+ if (flag) return true
277
+ try {
278
+ const raw = await session.readFile(PREBAKED_MARKER_PATH)
279
+ return parsePrebakedMarker(raw) !== null
280
+ } catch {
281
+ return false
282
+ }
283
+ }
284
+
285
+ /** Detect a JS runtime on the sandbox PATH for the credential helper. */
286
+ async function resolveHelperRuntime(session: SandboxSession): Promise<string> {
287
+ const res = await session.exec(
288
+ 'if command -v node >/dev/null 2>&1; then echo node; elif command -v bun >/dev/null 2>&1; then echo bun; else echo node; fi'
289
+ )
290
+ const runtime = res.stdout.trim()
291
+ return runtime === 'bun' ? 'bun' : 'node'
292
+ }
293
+
294
+ function credentialHelperValue(runtime: string, scriptAbs: string, contextAbs: string): string {
295
+ // `!` makes git run the value as a shell command with the action appended.
296
+ // Only PATHS live here — never the bearer (G3).
297
+ return `!${runtime} ${scriptAbs} ${contextAbs}`
298
+ }
299
+
300
+ export function computeRuntimePaths(root: string, runId: string, workspaceDir: string): HostedRuntimePaths {
301
+ const runDirRel = `.orizu-run/${runId}`
302
+ const runDirAbs = `${root}/${runDirRel}`
303
+ return {
304
+ root,
305
+ runDirRel,
306
+ runDirAbs,
307
+ repoDirRel: workspaceDir,
308
+ helperScriptAbs: `${runDirAbs}/${HELPER_SCRIPT_BASENAME}`,
309
+ bootContextAbs: `${runDirAbs}/${BOOT_CONTEXT_BASENAME}`,
310
+ bearerFileAbs: `${runDirAbs}/${BEARER_BASENAME}`,
311
+ cacheFileAbs: `${runDirAbs}/${REPO_CRED_CACHE_BASENAME}`,
312
+ }
313
+ }
314
+
315
+ export async function bootstrapHostedSandbox(opts: HostedBootstrapOptions): Promise<HostedBootstrapResult> {
316
+ const { session } = opts
317
+ const workspaceDir = opts.workspaceDir ?? DEFAULT_WORKSPACE_DIR
318
+ const host = opts.host ?? 'github.com'
319
+ const tokenPurposes =
320
+ opts.tokenPurposes ??
321
+ (opts.bearerKind === 'agent' ? { ...DEFAULT_AGENT_TOKEN_PURPOSES } : { ...DEFAULT_TOKEN_PURPOSES })
322
+ const cloneUrl = opts.resolveCloneUrl(opts.repoFullName)
323
+ const runDirRel = `.orizu-run/${opts.runId}`
324
+
325
+ // P3a — refuse shell metacharacters in any value interpolated into a command,
326
+ // BEFORE we touch the sandbox (so a tainted value never reaches a shell).
327
+ assertSafeShellValue('runId', opts.runId)
328
+ assertSafeShellValue('runDirRel', runDirRel)
329
+ assertSafeShellValue('sessionBranch', opts.sessionBranch)
330
+ assertSafeShellValue('workspaceDir', workspaceDir)
331
+ assertSafeShellValue('cloneUrl', cloneUrl)
332
+
333
+ const sink =
334
+ opts.sink ??
335
+ createBootstrapRunEventSink({
336
+ apiBaseUrl: opts.apiBaseUrl,
337
+ runId: opts.runId,
338
+ bearer: opts.bearer,
339
+ fetchImpl: opts.fetchImpl,
340
+ now: opts.now,
341
+ })
342
+
343
+ const steps: HostedBootstrapStep[] = []
344
+ const record = (key: string, ok: boolean, detail: string): void => {
345
+ steps.push({ key, ok, detail })
346
+ }
347
+
348
+ const root = await resolveRoot(session)
349
+ const paths = computeRuntimePaths(root, opts.runId, workspaceDir)
350
+ const runtime = await resolveHelperRuntime(session)
351
+ const helperValue = credentialHelperValue(runtime, paths.helperScriptAbs, paths.bootContextAbs)
352
+
353
+ let currentStep = 'sandbox_provisioned'
354
+ try {
355
+ // 0 — Preflight: the credential helper needs a JS runtime; abort before the
356
+ // clone (with a recorded bootstrap_failed) if neither node nor bun is present.
357
+ currentStep = 'runtime_preflight'
358
+ await assertJsRuntimeAvailable(session)
359
+
360
+ // Pre-baked detection (ALI-1017): the boot-context flag (preferred) or the
361
+ // filesystem marker. Decided ONCE here; step 4 uses it to skip the CLI install.
362
+ const prebaked = await detectPrebaked(session, opts.prebaked)
363
+
364
+ // 1 — Inject boot context. Run dir is 0700; bearer + context are 0600.
365
+ currentStep = 'inject_context'
366
+ await session.exec(`mkdir -p ${paths.runDirRel} && chmod 700 ${paths.runDirRel}`)
367
+ const bootContext: HostedBootContext = {
368
+ apiBaseUrl: opts.apiBaseUrl.replace(/\/$/, ''),
369
+ workspaceId: opts.workspaceId,
370
+ sessionId: opts.sessionId,
371
+ runId: opts.runId,
372
+ sessionBranch: opts.sessionBranch,
373
+ repoFullName: opts.repoFullName,
374
+ host,
375
+ bearerFile: paths.bearerFileAbs,
376
+ cacheFile: paths.cacheFileAbs,
377
+ tokenPurposes,
378
+ insecureHttpHosts: opts.insecureHttpHosts,
379
+ cacheBufferMs: opts.cacheBufferMs ?? DEFAULT_CACHE_REFRESH_BUFFER_MS,
380
+ }
381
+ await session.writeFile(`${paths.runDirRel}/${HELPER_SCRIPT_BASENAME}`, renderCredentialHelperScript())
382
+ await session.writeFile(`${paths.runDirRel}/${BOOT_CONTEXT_BASENAME}`, serializeBootContext(bootContext))
383
+ // P3b — the bearer is the one true secret on disk. Create it ATOMICALLY at
384
+ // 0600 (umask 077 + redirect) so it never exists world-readable, even for an
385
+ // instant — the SandboxSession.writeFile seam cannot express file perms.
386
+ await session.exec(
387
+ `umask 077 && printf '%s\\n' ${shellSingleQuote(opts.bearer)} > ${paths.runDirRel}/${BEARER_BASENAME}`
388
+ )
389
+ // The boot context holds only paths (no secret), but keep it 0600 too.
390
+ await session.exec(`chmod 600 ${paths.runDirRel}/${BOOT_CONTEXT_BASENAME}`)
391
+ await sink.append('sandbox_provisioned', {
392
+ sandboxId: session.id,
393
+ runtime,
394
+ sessionBranch: opts.sessionBranch,
395
+ repo: opts.repoFullName,
396
+ })
397
+
398
+ // 2/3 — Clone the session branch VIA the credential helper. The helper is
399
+ // supplied ephemerally with `-c` (persisted nowhere); no token in the URL.
400
+ currentStep = 'repo_cloned'
401
+ const cloneCommand =
402
+ `git clone --depth 1 --branch ${opts.sessionBranch} ` +
403
+ `-c credential.helper=${shellSingleQuote(helperValue)} -c credential.useHttpPath=true ` +
404
+ `${cloneUrl} ${workspaceDir}`
405
+ const clone = await session.exec(cloneCommand)
406
+ if (clone.exitCode !== 0) {
407
+ const detail = (clone.stderr && clone.stderr.trim()) || clone.stdout.trim() || `exit ${clone.exitCode}`
408
+ throw new Error(`git clone failed: ${detail}`)
409
+ }
410
+ // Persist the helper repo-LOCAL (never --global) for subsequent fetch/push.
411
+ await session.exec(`git -C ${workspaceDir} config credential.helper ${shellSingleQuote(helperValue)}`)
412
+ await session.exec(`git -C ${workspaceDir} config credential.useHttpPath true`)
413
+ record('repo_cloned', true, `cloned ${opts.sessionBranch}`)
414
+ await sink.append('repo_cloned', { branch: opts.sessionBranch, workspaceDir })
415
+
416
+ // 4 — Provision the Orizu CLI (non-fatal, recorded — OpenInspect setup.sh
417
+ // discipline). PRE-BAKED (ALI-1017): the image already ships `orizu` on PATH,
418
+ // so SKIP the install (npm is blocked under G5 default-deny egress) and record
419
+ // `cli_prebaked` instead. FROM-SCRATCH (local-sim / fallback): install the
420
+ // published `orizu` npm package as before. assertJsRuntimeAvailable already
421
+ // ran in the preflight above, so the pre-baked node/bun is proven present.
422
+ currentStep = 'cli_installed'
423
+ if (prebaked) {
424
+ record('cli_prebaked', true, 'orizu CLI pre-baked in the runtime image')
425
+ await sink.append('cli_prebaked', { detail: 'orizu CLI pre-baked in the runtime image' })
426
+ } else {
427
+ const installCommand = opts.cliInstallCommand ?? DEFAULT_CLI_INSTALL(opts.cliVersion ?? 'latest')
428
+ const install = await session.exec(installCommand, { cwd: workspaceDir })
429
+ const installOk = install.exitCode === 0
430
+ record('cli_installed', installOk, installOk ? 'orizu CLI installed' : `install exit ${install.exitCode}`)
431
+ await sink.append('cli_installed', {
432
+ ok: installOk,
433
+ exitCode: install.exitCode,
434
+ outputTail: tail(install.stdout, install.stderr),
435
+ })
436
+ }
437
+
438
+ // 5 — Customer setup hook (fresh-boot only, non-fatal, output captured).
439
+ // P3-a: for enforced-egress providers the host DEFERS this hook to the loop,
440
+ // which runs it AFTER the egress canary proves the firewall is live — so the
441
+ // hook's network access can never precede detection of a silent firewall
442
+ // failure. Here we only record the deferral (the loop emits the real result).
443
+ currentStep = 'setup_hook'
444
+ if (opts.deferSetupHook) {
445
+ record('setup_hook_deferred', true, 'deferred to loop (runs after egress canary)')
446
+ await sink.append('setup_hook_deferred', { reason: 'egress canary must pass before customer setup.sh runs' })
447
+ return { ok: true, steps, events: sink.recorded, paths, sweep: null, failure: null }
448
+ }
449
+ const hookRel = `${workspaceDir}/${SETUP_HOOK_RELATIVE_PATH}`
450
+ if (await session.fileExists(hookRel)) {
451
+ const hook = await session.exec(`bash ${SETUP_HOOK_RELATIVE_PATH}`, { cwd: workspaceDir })
452
+ const hookOk = hook.exitCode === 0
453
+ record('setup_hook_completed', hookOk, hookOk ? 'setup.sh ok' : `setup.sh exit ${hook.exitCode}`)
454
+ await sink.append('setup_hook_completed', {
455
+ ok: hookOk,
456
+ exitCode: hook.exitCode,
457
+ outputTail: tail(hook.stdout, hook.stderr),
458
+ })
459
+ } else {
460
+ record('setup_hook_skipped', true, 'no .orizu/setup.sh')
461
+ await sink.append('setup_hook_skipped', { reason: 'no .orizu/setup.sh' })
462
+ }
463
+
464
+ return { ok: true, steps, events: sink.recorded, paths, sweep: null, failure: null }
465
+ } catch (error) {
466
+ const message = error instanceof Error ? error.message : String(error)
467
+ record(currentStep, false, message)
468
+ // Record the failure (redacted), then tear down + prove no residue remains.
469
+ await safeAppend(sink, 'bootstrap_failed', { step: currentStep, error: message })
470
+ const sweep = await teardownHostedSandbox({ session, paths, bearer: opts.bearer, workspaceDir })
471
+ return { ok: false, steps, events: sink.recorded, paths, sweep, failure: { step: currentStep, error: message } }
472
+ }
473
+ }
474
+
475
+ /** Never let a failure-path event append mask the original error. */
476
+ async function safeAppend(
477
+ sink: BootstrapRunEventSink,
478
+ eventType: string,
479
+ payload: Record<string, unknown>
480
+ ): Promise<void> {
481
+ try {
482
+ await sink.append(eventType, payload)
483
+ } catch {
484
+ // The run-event log is best-effort on the failure path; teardown proceeds.
485
+ }
486
+ }
487
+
488
+ function tail(stdout: string, stderr?: string): string {
489
+ const combined = [stdout, stderr ?? ''].join('\n')
490
+ return combined.split('\n').slice(-20).join('\n').trim()
491
+ }
492
+
493
+ // -- Teardown + residue sweep (G3) -------------------------------------------
494
+
495
+ export interface TeardownOptions {
496
+ session: SandboxSession
497
+ paths: HostedRuntimePaths
498
+ bearer: string
499
+ workspaceDir?: string
500
+ /** Extra token values to sweep for (e.g. a minted repo token a test planted). */
501
+ extraTokens?: readonly string[]
502
+ }
503
+
504
+ /**
505
+ * Tear the run-scoped credential material down and PROVE no token residue
506
+ * remains (G3, extends ALI-973's `sweepForTokenResidue`). Removes the run dir
507
+ * (bearer + boot context + minted-token cache), unsets the repo-local credential
508
+ * helper, then sweeps .git/config, the git credential cache, env, argv, and
509
+ * ~/.git-credentials for the bearer, any extra tokens, and the token markers.
510
+ */
511
+ export async function teardownHostedSandbox(
512
+ opts: TeardownOptions
513
+ ): Promise<{ clean: boolean; findings: HygieneFinding[] }> {
514
+ const { session, paths } = opts
515
+ const workspaceDir = opts.workspaceDir ?? paths.repoDirRel
516
+ // Unset the repo-local helper (no secret in it, but leave nothing behind),
517
+ // then remove the whole run dir (bearer, boot context, minted-token cache).
518
+ await session.exec(`git -C ${workspaceDir} config --unset-all credential.helper 2>/dev/null || true`)
519
+ await session.exec(`rm -rf ${paths.runDirRel}`)
520
+
521
+ const findings = await sweepHostedResidue(session, {
522
+ bearer: opts.bearer,
523
+ paths,
524
+ workspaceDir,
525
+ extraTokens: opts.extraTokens,
526
+ })
527
+ return { clean: findings.length === 0, findings }
528
+ }
529
+
530
+ export interface HostedSweepOptions {
531
+ bearer: string
532
+ paths: HostedRuntimePaths
533
+ workspaceDir: string
534
+ extraTokens?: readonly string[]
535
+ }
536
+
537
+ /**
538
+ * Extend the ALI-973 sweep with hosted-specific probes: the run dir (bearer +
539
+ * cache) must be gone, and the standard leak surfaces (.git/config, ~/.git-
540
+ * credentials, credential cache, env, argv) must be free of the bearer.
541
+ */
542
+ export async function sweepHostedResidue(
543
+ session: SandboxSession,
544
+ opts: HostedSweepOptions
545
+ ): Promise<HygieneFinding[]> {
546
+ const tokens = [opts.bearer, ...(opts.extraTokens ?? [])].filter(t => t.length > 0)
547
+ const probes = [
548
+ { location: `${opts.workspaceDir}/.git/config`, command: `cat ${opts.workspaceDir}/.git/config 2>/dev/null || true` },
549
+ {
550
+ location: `${opts.workspaceDir}/.git/logs (reflog)`,
551
+ command: `find ${opts.workspaceDir}/.git/logs -type f -exec cat {} + 2>/dev/null || true`,
552
+ },
553
+ { location: 'run dir (bearer + minted-token cache)', command: `find ${opts.paths.runDirRel} -type f -exec cat {} + 2>/dev/null || true` },
554
+ { location: '~/.git-credentials', command: 'cat "$HOME/.git-credentials" 2>/dev/null || true' },
555
+ { location: '~/.gitconfig (global)', command: 'cat "$HOME/.gitconfig" 2>/dev/null || true' },
556
+ {
557
+ location: 'git credential cache/store',
558
+ command:
559
+ 'cat "$HOME/.cache/git/credential/"* "${XDG_CACHE_HOME:-$HOME/.cache}/git/credential/"* 2>/dev/null || true',
560
+ },
561
+ { location: 'shell history', command: 'cat "$HOME/.bash_history" "$HOME/.zsh_history" 2>/dev/null || true' },
562
+ { location: 'process listing / argv', command: 'ps -eo args 2>/dev/null || ps aux 2>/dev/null || true' },
563
+ { location: 'process env', command: 'env' },
564
+ ]
565
+ return sweepForTokenResidue(session, {
566
+ tokens,
567
+ repoPath: opts.workspaceDir,
568
+ markers: ['x-access-token'],
569
+ probes,
570
+ })
571
+ }