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,440 @@
1
+ /**
2
+ * Orizu-owned sandbox provider seam (ALI-973 / WS-F, per ADR-003).
3
+ *
4
+ * ADR-003 requires every provider call (GitHub, Daytona) to sit behind an
5
+ * Orizu-owned interface so the durable contracts never couple to whichever SDK
6
+ * wins the quarter. This module defines `SandboxProvider` / `SandboxSession` and
7
+ * ships two implementations:
8
+ *
9
+ * - `createDaytonaProvider(config)` — a THIN adapter over the Daytona
10
+ * TypeScript SDK (`@daytonaio/sdk`). The SDK is imported LAZILY (dynamic
11
+ * import through a non-literal specifier) so the CLI works without it
12
+ * installed, the app build never resolves it, and tests never load it.
13
+ * - `createLocalSimProvider(config)` — a temp-dir + real-`git`-subprocess
14
+ * simulation used by the unit tests AND by the no-credentials local-sim
15
+ * rehearsal. It is a genuine rehearsal (real clone/commit/push against a
16
+ * local bare repo), not a mock.
17
+ *
18
+ * SDK-vs-docs note (verified against daytona.io/docs, 2026-07-02): the real
19
+ * `git.clone` signature is
20
+ * clone(url, path, branch?, commitId?, username?, password?, insecureSkipTls?)
21
+ * i.e. a `commitId` argument sits BETWEEN `branch` and `username` — the ALI-973
22
+ * brief documented `clone(url, path, branch, username, password)`. The adapter
23
+ * passes `undefined` for `commitId` so the Orizu seam keeps its clean per-call
24
+ * credential shape. `git.push(path, username?, password?)` matches the brief.
25
+ */
26
+
27
+ import { spawnSync } from 'child_process'
28
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
29
+ import { tmpdir } from 'os'
30
+ import { basename, dirname, join } from 'path'
31
+
32
+ export interface ExecResult {
33
+ stdout: string
34
+ exitCode: number
35
+ /** Captured standard error, when the provider exposes it — surfaced so a
36
+ * failed git clone/push does not vanish behind a masked exit code. */
37
+ stderr?: string
38
+ }
39
+
40
+ export interface GitCloneParams {
41
+ url: string
42
+ path: string
43
+ branch?: string
44
+ username?: string
45
+ password?: string
46
+ }
47
+
48
+ export interface GitPushParams {
49
+ path: string
50
+ username?: string
51
+ password?: string
52
+ }
53
+
54
+ /**
55
+ * A single request transform a provider firewall applies to matching egress
56
+ * (the credential-brokering seam: inject a header at the proxy so the raw
57
+ * secret never enters the sandbox). Structurally identical to @vercel/sandbox's
58
+ * `NetworkPolicyRule` so a Vercel adapter can pass it straight through, but
59
+ * declared here so the Orizu seam owns the shape (ADR-003).
60
+ */
61
+ export interface SandboxEgressRule {
62
+ transform?: { headers?: Record<string, string> }[]
63
+ }
64
+
65
+ /**
66
+ * Provider-neutral egress/network policy, wired to the sandbox firewall AT
67
+ * CREATE. G5 (ALI-1006) owns the allowlist CONTENT; this slice only plumbs the
68
+ * option so the orchestrator can pass a policy (incl. a credential-injection
69
+ * transform for a model endpoint). `'allow-all'` is the provider default when
70
+ * omitted. Structurally compatible with @vercel/sandbox's `NetworkPolicy`.
71
+ */
72
+ export type SandboxEgressPolicy =
73
+ | 'allow-all'
74
+ | 'deny-all'
75
+ | {
76
+ allow?: string[] | Record<string, SandboxEgressRule[]>
77
+ subnets?: { allow?: string[]; deny?: string[] }
78
+ }
79
+
80
+ export interface SandboxCreateOpts {
81
+ language?: string
82
+ snapshot?: string
83
+ envVars?: Record<string, string>
84
+ labels?: Record<string, string>
85
+ /** Sandbox auto-terminate timeout (ms). Honored where the provider supports a
86
+ * configurable session length (Vercel); ignored by providers that do not. */
87
+ timeoutMs?: number
88
+ /** Provider runtime image hint (e.g. Vercel 'node24'); provider-specific. */
89
+ runtime?: string
90
+ /**
91
+ * Provider custom-image ref (e.g. Vercel VCR 'orizu-hosted-runtime:<tag>').
92
+ * When set, the provider creates the sandbox from this pre-baked image instead
93
+ * of the base runtime (ALI-1017). Provider-specific — Vercel honors it; other
94
+ * providers ignore it. `runtime` is still the base-runtime hint when no image
95
+ * is given.
96
+ */
97
+ image?: string
98
+ /** vCPUs to allocate (memory scales with vCPUs on Vercel: 2048 MiB each). */
99
+ vcpus?: number
100
+ /** Ports to expose from the sandbox (provider-specific; up to 4 on Vercel). */
101
+ ports?: number[]
102
+ /** Egress/firewall policy applied at create (G5 supplies content). */
103
+ egressPolicy?: SandboxEgressPolicy
104
+ }
105
+
106
+ /**
107
+ * A live sandbox. Per-call git credentials mirror Daytona's contract (and
108
+ * OpenInspect's credential-helper shape): the token is handed to each operation,
109
+ * never written once per session.
110
+ */
111
+ export interface SandboxSession {
112
+ readonly id: string
113
+ gitClone(params: GitCloneParams): Promise<void>
114
+ gitPush(params: GitPushParams): Promise<void>
115
+ exec(command: string, opts?: { cwd?: string }): Promise<ExecResult>
116
+ readFile(path: string): Promise<string>
117
+ writeFile(path: string, content: string): Promise<void>
118
+ fileExists(path: string): Promise<boolean>
119
+ destroy(): Promise<void>
120
+ /**
121
+ * Extend the sandbox's auto-terminate timeout by `durationMs` (keepalive).
122
+ * OPTIONAL: only providers with a configurable session length implement it
123
+ * (Vercel). Host-side token rotation uses it to keep a long session alive
124
+ * beyond the provider's default. Absent on local-sim / Daytona.
125
+ */
126
+ extendTimeout?(durationMs: number): Promise<void>
127
+ /**
128
+ * Snapshot the running sandbox's filesystem state and RETURN the snapshot id
129
+ * (ALI-1017). A new sandbox can then boot from it via `createSandbox({ snapshot })`
130
+ * — the zero-Docker "prebaked runtime" path (parallel to a VCR `image`).
131
+ * OPTIONAL: only providers with a snapshot API implement it (Vercel); absent on
132
+ * local-sim / Daytona. NOTE: the provider may STOP this sandbox as part of the
133
+ * snapshot (Vercel does), so treat the session as spent afterwards.
134
+ *
135
+ * `expiration` is an optional TTL (ms) for the snapshot; 0 = never expire.
136
+ */
137
+ snapshot?(opts?: { expiration?: number }): Promise<string>
138
+ }
139
+
140
+ export type SandboxProviderKind = 'daytona' | 'local-sim' | 'vercel'
141
+
142
+ export interface SandboxProvider {
143
+ readonly kind: SandboxProviderKind
144
+ createSandbox(opts?: SandboxCreateOpts): Promise<SandboxSession>
145
+ }
146
+
147
+ // -- Daytona adapter (lazy SDK) ---------------------------------------------
148
+
149
+ export interface DaytonaProviderConfig {
150
+ apiKey: string
151
+ apiUrl?: string
152
+ target?: string
153
+ }
154
+
155
+ // Minimal structural view of the SDK surface the adapter touches. Kept local so
156
+ // the seam type-checks and the app build resolves without the package present.
157
+ interface DaytonaSdkSandbox {
158
+ id: string
159
+ git: {
160
+ clone(
161
+ url: string,
162
+ path: string,
163
+ branch?: string,
164
+ commitId?: string,
165
+ username?: string,
166
+ password?: string,
167
+ insecureSkipTls?: boolean
168
+ ): Promise<void>
169
+ push(path: string, username?: string, password?: string): Promise<void>
170
+ }
171
+ process: {
172
+ executeCommand(
173
+ command: string,
174
+ cwd?: string,
175
+ env?: Record<string, string>,
176
+ timeout?: number
177
+ ): Promise<{ exitCode?: number; result?: string; stderr?: string; artifacts?: { stdout?: string; stderr?: string } }>
178
+ }
179
+ fs: {
180
+ downloadFile(remotePath: string): Promise<Uint8Array | Buffer | string>
181
+ uploadFile(file: Uint8Array, remotePath: string): Promise<void>
182
+ getFileDetails(path: string): Promise<unknown>
183
+ }
184
+ delete(timeout?: number): Promise<void>
185
+ }
186
+
187
+ interface DaytonaSdkClient {
188
+ create(params?: Record<string, unknown>, options?: { timeout: number }): Promise<DaytonaSdkSandbox>
189
+ }
190
+
191
+ export interface DaytonaSdkModule {
192
+ Daytona: new (config: { apiKey: string; apiUrl?: string; target?: string }) => DaytonaSdkClient
193
+ }
194
+
195
+ /** Test seam: inject a fake SDK module so the adapter's request mapping can be
196
+ * exercised without the real package installed or a network. */
197
+ export interface DaytonaProviderDeps {
198
+ loadModule?: () => Promise<DaytonaSdkModule>
199
+ }
200
+
201
+ // The package name lives in a variable so TypeScript treats the dynamic import
202
+ // as `any` (no static module resolution / no ts2307 at app build time) and so
203
+ // no bundler traces it. This is what keeps the SDK genuinely optional.
204
+ //
205
+ // PIN CONTRACT: `wrapDaytonaSandbox` hard-codes the POSITIONAL git.clone
206
+ // signature (url, path, branch, commitId, username, password) verified against
207
+ // @daytonaio/sdk@0.193.0. package.json pins that version EXACTLY (no caret) and
208
+ // packages/cli/bun.lock records it, so a minor bump cannot silently shift the
209
+ // argument order out from under this adapter. Re-verify the signature before
210
+ // changing the pin.
211
+ //
212
+ // TRANSITIVE-DEPENDENCY NOTE: @daytonaio/sdk itself drags in a substantial
213
+ // tree — @aws-sdk/client-s3, the OpenTelemetry SDK/exporters, and friends —
214
+ // into packages/cli's dependency graph. `bun install --cwd packages/cli`
215
+ // resolves and downloads all of it even though this file only ever reaches
216
+ // the package via the lazy `import(specifier)` above. That's judged
217
+ // acceptable because: (1) this is a founder-only, not end-user-shipped,
218
+ // script — packages/cli's app build and published `dist/` never bundle or
219
+ // execute this path; (2) the lazy import means the SDK (and its transitive
220
+ // tree) is never loaded into the running process unless `runDaytonaSlice`
221
+ // actually selects the `daytona` provider; (3) `bun install`'s resolve-time
222
+ // cost is a one-time developer/CI tax on this package, not a runtime or
223
+ // shipped-artifact cost. Re-evaluate if @daytonaio/sdk ever becomes a
224
+ // dependency of a package that IS shipped to end users.
225
+ const DAYTONA_SDK_SPECIFIER = '@daytonaio/sdk'
226
+
227
+ async function loadDaytonaModule(): Promise<DaytonaSdkModule> {
228
+ try {
229
+ const specifier: string = DAYTONA_SDK_SPECIFIER
230
+ const mod = (await import(specifier)) as unknown as DaytonaSdkModule
231
+ if (!mod || typeof mod.Daytona !== 'function') {
232
+ throw new Error('module did not export a Daytona class')
233
+ }
234
+ return mod
235
+ } catch (error) {
236
+ const detail = error instanceof Error ? error.message : String(error)
237
+ throw new Error(
238
+ `Daytona SDK unavailable (${detail}). Install it before the live run: \`bun install --cwd packages/cli\` (declares @daytonaio/sdk).`
239
+ )
240
+ }
241
+ }
242
+
243
+ function wrapDaytonaSandbox(sandbox: DaytonaSdkSandbox): SandboxSession {
244
+ return {
245
+ id: sandbox.id,
246
+ async gitClone(params) {
247
+ // commitId (4th arg) is undefined — clean per-call credential shape.
248
+ await sandbox.git.clone(
249
+ params.url,
250
+ params.path,
251
+ params.branch,
252
+ undefined,
253
+ params.username,
254
+ params.password
255
+ )
256
+ },
257
+ async gitPush(params) {
258
+ await sandbox.git.push(params.path, params.username, params.password)
259
+ },
260
+ async exec(command, opts) {
261
+ const res = await sandbox.process.executeCommand(command, opts?.cwd)
262
+ const stdout =
263
+ typeof res.result === 'string' ? res.result : res.artifacts?.stdout ?? ''
264
+ const stderr = res.stderr ?? res.artifacts?.stderr
265
+ // A missing exitCode is treated as a FAILURE (1), never masked to success:
266
+ // a live clone/push that returns no code must not read as green.
267
+ const exitCode = typeof res.exitCode === 'number' ? res.exitCode : 1
268
+ return { stdout, exitCode, stderr }
269
+ },
270
+ async readFile(path) {
271
+ const data = await sandbox.fs.downloadFile(path)
272
+ if (typeof data === 'string') return data
273
+ return Buffer.from(data).toString('utf8')
274
+ },
275
+ async writeFile(path, content) {
276
+ await sandbox.fs.uploadFile(Buffer.from(content, 'utf8'), path)
277
+ },
278
+ async fileExists(path) {
279
+ try {
280
+ await sandbox.fs.getFileDetails(path)
281
+ return true
282
+ } catch {
283
+ return false
284
+ }
285
+ },
286
+ async destroy() {
287
+ await sandbox.delete()
288
+ },
289
+ }
290
+ }
291
+
292
+ export function createDaytonaProvider(
293
+ config: DaytonaProviderConfig,
294
+ deps: DaytonaProviderDeps = {}
295
+ ): SandboxProvider {
296
+ const load = deps.loadModule ?? loadDaytonaModule
297
+ let clientPromise: Promise<DaytonaSdkClient> | null = null
298
+ const client = (): Promise<DaytonaSdkClient> => {
299
+ if (!clientPromise) {
300
+ clientPromise = (async () => {
301
+ const mod = await load()
302
+ return new mod.Daytona({
303
+ apiKey: config.apiKey,
304
+ apiUrl: config.apiUrl,
305
+ target: config.target,
306
+ })
307
+ })()
308
+ }
309
+ return clientPromise
310
+ }
311
+
312
+ return {
313
+ kind: 'daytona',
314
+ async createSandbox(opts) {
315
+ const daytona = await client()
316
+ const params: Record<string, unknown> = {}
317
+ if (opts?.language) params.language = opts.language
318
+ if (opts?.snapshot) params.snapshot = opts.snapshot
319
+ if (opts?.envVars) params.envVars = opts.envVars
320
+ if (opts?.labels) params.labels = opts.labels
321
+ // ORPHAN-SANDBOX WINDOW: `daytona.create(...)` is a single SDK call that
322
+ // provisions the remote sandbox AND resolves with its handle as one unit.
323
+ // If it throws AFTER the remote sandbox exists but BEFORE this awaits
324
+ // returns (e.g. the create succeeded server-side but the response never
325
+ // reached us), the caller (runDaytonaSlice) never receives a
326
+ // SandboxSession and has no id to destroy — the sandbox is orphaned on
327
+ // Daytona's side with nothing in our process able to name it. This is
328
+ // inherent to the SDK's create-returns-handle shape, not fixable by
329
+ // catching here (there is no id to catch). `--keep`/manual cleanup in
330
+ // the Daytona dashboard is the backstop. A create-then-fetch-id two-phase
331
+ // API (create returns an id immediately, a separate call waits for
332
+ // readiness) would close this window if Daytona ever ships one.
333
+ const sandbox = await daytona.create(Object.keys(params).length ? params : undefined)
334
+ return wrapDaytonaSandbox(sandbox)
335
+ },
336
+ }
337
+ }
338
+
339
+ // -- Local-sim provider (real git subprocess) --------------------------------
340
+
341
+ export interface LocalSimProviderConfig {
342
+ /** Parent directory for per-sandbox temp dirs (defaults to the OS tmpdir). */
343
+ rootDir?: string
344
+ /** Skip filesystem teardown on destroy() (used by --keep for debugging). */
345
+ keepOnDestroy?: boolean
346
+ }
347
+
348
+ interface ProcResult {
349
+ stdout: string
350
+ stderr: string
351
+ exitCode: number
352
+ }
353
+
354
+ function runIn(bin: string, args: string[], cwd: string, home: string): ProcResult {
355
+ // Strip git's repo-context env (set by git when this code runs inside a
356
+ // hook, e.g. pre-push running `bun test`) so sim git commands operate on
357
+ // the sandbox cwd — not whatever repo GIT_DIR points at. See
358
+ // daytona-slice-rehearsal.ts for the core.bare=true corruption this caused.
359
+ const env: NodeJS.ProcessEnv = {
360
+ ...process.env,
361
+ HOME: home,
362
+ GIT_TERMINAL_PROMPT: '0',
363
+ GIT_CONFIG_NOSYSTEM: '1',
364
+ }
365
+ delete env.GIT_DIR
366
+ delete env.GIT_WORK_TREE
367
+ delete env.GIT_INDEX_FILE
368
+ delete env.GIT_OBJECT_DIRECTORY
369
+ delete env.GIT_COMMON_DIR
370
+ const res = spawnSync(bin, args, { cwd, encoding: 'utf8', env })
371
+ return { stdout: res.stdout ?? '', stderr: res.stderr ?? '', exitCode: res.status ?? 1 }
372
+ }
373
+
374
+ function makeLocalSimSession(id: string, root: string, keepOnDestroy: boolean): SandboxSession {
375
+ // A self-contained git identity so commits work regardless of host config.
376
+ writeFileSync(
377
+ join(root, '.gitconfig'),
378
+ '[user]\n\temail = orizu-slice@local.sim\n\tname = Orizu Slice\n' +
379
+ '[init]\n\tdefaultBranch = main\n[safe]\n\tdirectory = *\n'
380
+ )
381
+ const inRoot = (p: string): string => join(root, p)
382
+
383
+ return {
384
+ id,
385
+ async gitClone(params) {
386
+ const args = ['clone']
387
+ if (params.branch) args.push('--branch', params.branch)
388
+ // Clean per-call shape: the clone URL is the bare repo path; the
389
+ // per-call username/password are honored by the transport but never
390
+ // persisted into the cloned repo's config (mirrors the contract we
391
+ // require of Daytona). Nothing to sanitize because nothing is embedded.
392
+ args.push(params.url, params.path)
393
+ const r = runIn('git', args, root, root)
394
+ if (r.exitCode !== 0) {
395
+ throw new Error(`git clone failed: ${r.stderr.trim() || `exit ${r.exitCode}`}`)
396
+ }
397
+ },
398
+ async gitPush(params) {
399
+ const r = runIn('git', ['-C', params.path, 'push'], root, root)
400
+ if (r.exitCode !== 0) {
401
+ throw new Error(`git push failed: ${r.stderr.trim() || `exit ${r.exitCode}`}`)
402
+ }
403
+ },
404
+ async exec(command, opts) {
405
+ const cwd = opts?.cwd ? inRoot(opts.cwd) : root
406
+ const r = runIn('bash', ['-c', command], cwd, root)
407
+ return { stdout: r.stdout, exitCode: r.exitCode, stderr: r.stderr }
408
+ },
409
+ async readFile(path) {
410
+ return readFileSync(inRoot(path), 'utf8')
411
+ },
412
+ async writeFile(path, content) {
413
+ const abs = inRoot(path)
414
+ mkdirSync(dirname(abs), { recursive: true })
415
+ writeFileSync(abs, content)
416
+ },
417
+ async fileExists(path) {
418
+ return existsSync(inRoot(path))
419
+ },
420
+ async destroy() {
421
+ if (!keepOnDestroy) {
422
+ rmSync(root, { recursive: true, force: true })
423
+ }
424
+ },
425
+ }
426
+ }
427
+
428
+ export function createLocalSimProvider(config: LocalSimProviderConfig = {}): SandboxProvider {
429
+ let counter = 0
430
+ return {
431
+ kind: 'local-sim',
432
+ async createSandbox() {
433
+ counter += 1
434
+ const parent = config.rootDir ?? tmpdir()
435
+ mkdirSync(parent, { recursive: true })
436
+ const root = mkdtempSync(join(parent, 'orizu-sim-'))
437
+ return makeLocalSimSession(`local-sim-${counter}-${basename(root)}`, root, Boolean(config.keepOnDestroy))
438
+ },
439
+ }
440
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Secret redaction for hosted-sandbox bootstrap (G3 / ALI-1004).
3
+ *
4
+ * The bootstrap orchestrator records lifecycle events to the RunAPI and may
5
+ * capture hook / install output. None of that may ever carry the Orizu bearer,
6
+ * a minted repo token, or any token-shaped value. `redactSecrets` scrubs a
7
+ * value two ways:
8
+ *
9
+ * 1. exact-match on caller-supplied secrets (the bearer, any token the
10
+ * orchestrator itself minted) — these are known verbatim, so replace them
11
+ * wherever they appear, even mid-string;
12
+ * 2. shape-match on the well-known credential prefixes GitHub, Orizu, and the
13
+ * model providers emit (ghs_/ghp_/gho_/ghu_/ghr_/github_pat_ installation+PAT
14
+ * tokens, Orizu's own `orizu_pat_`/`orizu_agent_` tokens, Anthropic
15
+ * `sk-ant-…` and OpenAI `sk-proj-…`/legacy `sk-…` model keys, and a generic
16
+ * `Bearer <blob>` header) — a defense-in-depth net for tokens the
17
+ * orchestrator never held a copy of (e.g. a model key echoed by a customer
18
+ * setup hook or an OpenCode error message).
19
+ *
20
+ * SHAPES ARE DEFENSE IN DEPTH, NOT THE GUARANTEE. The guaranteed scrub is the
21
+ * exact-match pass (1): any secret that will be present in the sandbox MUST be
22
+ * handed to the caller's secret list (the RunEventSink's `redactSecretsList`)
23
+ * so it is stripped verbatim wherever it appears, even mid-string and even when
24
+ * its shape is unknown. The shape patterns below only catch credentials no one
25
+ * declared; do not rely on them as the primary control.
26
+ *
27
+ * The function is pure and recursive over JSON-ish structures so an entire
28
+ * event payload can be passed through before it is sent to the RunAPI.
29
+ */
30
+
31
+ export const REDACTION_PLACEHOLDER = '[redacted]'
32
+
33
+ // Token SHAPES, not values. Ordered longest-prefix-first so `github_pat_` wins
34
+ // over a hypothetical shorter alias. Each matches the prefix plus its base62/_-
35
+ // body. `Bearer <token>` is caught separately so an Authorization header value
36
+ // is scrubbed even when the token body is short.
37
+ const TOKEN_SHAPE_PATTERNS: readonly RegExp[] = [
38
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
39
+ /\bgh[posur]_[A-Za-z0-9_]{20,}/g,
40
+ // Orizu-issued credentials: `orizu_pat_` (personal access tokens, see
41
+ // lib/personal-access-tokens.ts — prefix + base64url body) and the future
42
+ // `orizu_agent_` session-token prefix. base64url alphabet is [A-Za-z0-9_-].
43
+ /\borizu_(agent|pat)_[A-Za-z0-9_-]{10,}/g,
44
+ // Model-provider API keys the agent runs with. Specific prefixes first so the
45
+ // bare `sk-` fallback below cannot partially shadow them:
46
+ // Anthropic `sk-ant-…` (real keys look like `sk-ant-api03-<blob>`);
47
+ // OpenAI `sk-proj-…` (project-scoped keys) and legacy bare `sk-<blob>`.
48
+ // The bare fallback demands 20+ base62 chars after `sk-` so it cannot fire on
49
+ // the hyphenated `sk-ant-`/`sk-proj-` prefixes (both break the run at the
50
+ // first `-`) or on short `sk-` substrings — tuned to avoid a false-positive
51
+ // massacre while still netting an undeclared legacy OpenAI key.
52
+ /\bsk-ant-[A-Za-z0-9_-]{10,}/g,
53
+ /\bsk-proj-[A-Za-z0-9_-]{10,}/g,
54
+ /\bsk-[A-Za-z0-9]{20,}\b/g,
55
+ /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/g,
56
+ ]
57
+
58
+ function escapeRegExp(value: string): string {
59
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
60
+ }
61
+
62
+ /**
63
+ * Redact a scalar string: exact secrets first, then token shapes.
64
+ */
65
+ function redactString(input: string, secrets: readonly string[]): string {
66
+ let out = input
67
+ for (const secret of secrets) {
68
+ if (secret.length === 0) continue
69
+ out = out.replace(new RegExp(escapeRegExp(secret), 'g'), REDACTION_PLACEHOLDER)
70
+ }
71
+ for (const pattern of TOKEN_SHAPE_PATTERNS) {
72
+ out = out.replace(pattern, match =>
73
+ match.toLowerCase().startsWith('bearer') ? `Bearer ${REDACTION_PLACEHOLDER}` : REDACTION_PLACEHOLDER
74
+ )
75
+ }
76
+ return out
77
+ }
78
+
79
+ export interface RedactOptions {
80
+ /** Verbatim secret values to strip wherever they appear (bearer, tokens). */
81
+ secrets?: readonly string[]
82
+ }
83
+
84
+ /**
85
+ * Deep-redact a JSON-ish value. Strings are scrubbed; arrays/objects are walked;
86
+ * everything else is returned as-is. Object KEYS are never treated as secrets
87
+ * (a key literally named after a token is not the token), but their VALUES are.
88
+ */
89
+ export function redactSecrets<T>(value: T, options: RedactOptions = {}): T {
90
+ const secrets = (options.secrets ?? []).filter(s => typeof s === 'string' && s.length > 0)
91
+ return redactValue(value, secrets) as T
92
+ }
93
+
94
+ function redactValue(value: unknown, secrets: readonly string[]): unknown {
95
+ if (typeof value === 'string') {
96
+ return redactString(value, secrets)
97
+ }
98
+ if (Array.isArray(value)) {
99
+ return value.map(item => redactValue(item, secrets))
100
+ }
101
+ if (value && typeof value === 'object') {
102
+ const out: Record<string, unknown> = {}
103
+ for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
104
+ out[key] = redactValue(inner, secrets)
105
+ }
106
+ return out
107
+ }
108
+ return value
109
+ }
110
+
111
+ /**
112
+ * True when a string still contains any of the supplied secrets or a
113
+ * token-shaped substring — the assertion tests use it to prove an event payload
114
+ * left the orchestrator clean.
115
+ */
116
+ export function containsSecret(input: string, secrets: readonly string[]): boolean {
117
+ for (const secret of secrets) {
118
+ if (secret.length > 0 && input.includes(secret)) return true
119
+ }
120
+ return TOKEN_SHAPE_PATTERNS.some(pattern => {
121
+ pattern.lastIndex = 0
122
+ return pattern.test(input)
123
+ })
124
+ }