orizu 0.4.1 → 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 +271 -16
  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 +855 -237
  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 +39 -2
  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 +272 -16
  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 +987 -296
  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 +70 -2
  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 +3 -1
  76. package/vendor/skills/orizu-cli/references/cli-reference.md +3 -3
@@ -0,0 +1,365 @@
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
+ import { sweepForTokenResidue } from './daytona-slice.js';
26
+ import { redactSecrets } from './secret-redaction.js';
27
+ import { BEARER_BASENAME, BOOT_CONTEXT_BASENAME, DEFAULT_CACHE_REFRESH_BUFFER_MS, HELPER_SCRIPT_BASENAME, PREBAKED_MARKER_PATH, REPO_CRED_CACHE_BASENAME, SETUP_HOOK_RELATIVE_PATH, parsePrebakedMarker, renderCredentialHelperScript, serializeBootContext, } from './hosted-runtime-assets.js';
28
+ function newEventId() {
29
+ const cryptoObj = globalThis.crypto;
30
+ if (cryptoObj?.randomUUID)
31
+ return cryptoObj.randomUUID();
32
+ return `evt-${Date.now()}-${Math.random().toString(36).slice(2)}`;
33
+ }
34
+ export function createBootstrapRunEventSink(options) {
35
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
36
+ const now = options.now ?? (() => Date.now());
37
+ const base = options.apiBaseUrl.replace(/\/$/, '');
38
+ const secretList = [options.bearer, ...(options.redactSecretsList ?? [])];
39
+ const recorded = [];
40
+ let sequence = options.startSequence ?? 1;
41
+ return {
42
+ recorded,
43
+ async append(eventType, payload = {}) {
44
+ const redactedPayload = redactSecrets(payload, { secrets: secretList });
45
+ const event = {
46
+ eventId: newEventId(),
47
+ sequence: sequence,
48
+ eventType,
49
+ payload: redactedPayload,
50
+ };
51
+ const response = await fetchImpl(`${base}/api/cli/workbench-runs/${encodeURIComponent(options.runId)}/events`, {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${options.bearer}` },
54
+ body: JSON.stringify({
55
+ eventId: event.eventId,
56
+ sequence: event.sequence,
57
+ eventType: event.eventType,
58
+ occurredAt: new Date(now()).toISOString(),
59
+ payload: redactedPayload,
60
+ }),
61
+ });
62
+ if (!response.ok) {
63
+ // Best-effort: the event log must not sink the whole boot. Surface the
64
+ // status but never the body (could echo internals); never a token. The
65
+ // sequence number is NOT consumed on failure (P3d) — a failed POST must
66
+ // not leave a server-visible gap in the run's sequence.
67
+ throw new Error(`run-event append failed (${response.status}) for ${eventType}`);
68
+ }
69
+ sequence += 1;
70
+ recorded.push(event);
71
+ return event;
72
+ },
73
+ };
74
+ }
75
+ const DEFAULT_WORKSPACE_DIR = 'repo';
76
+ const DEFAULT_TOKEN_PURPOSES = { primary: 'write', fallback: 'read' };
77
+ const DEFAULT_AGENT_TOKEN_PURPOSES = { primary: 'session_write', fallback: 'session_read' };
78
+ const DEFAULT_CLI_INSTALL = (version) => `bun add -g orizu@${version} >/dev/null 2>&1 || npm install -g orizu@${version} >/dev/null 2>&1`;
79
+ // Values we interpolate into shell commands must contain nothing a shell could
80
+ // treat specially. Allow the characters legitimately used by branch names, run
81
+ // ids, paths, and clone URLs — reject everything else rather than trust it.
82
+ const SAFE_SHELL_VALUE = /^[A-Za-z0-9._/:@-]+$/;
83
+ function assertSafeShellValue(name, value) {
84
+ if (!SAFE_SHELL_VALUE.test(value)) {
85
+ throw new Error(`unsafe characters in ${name} — refusing to interpolate into a shell command`);
86
+ }
87
+ }
88
+ /** Single-quote a value for safe embedding inside a shell command. */
89
+ function shellSingleQuote(value) {
90
+ return `'${value.replace(/'/g, `'\\''`)}'`;
91
+ }
92
+ /** Resolve the sandbox absolute root so helper paths are cwd-independent. */
93
+ async function resolveRoot(session) {
94
+ const res = await session.exec('pwd');
95
+ const root = res.stdout.trim();
96
+ if (!root)
97
+ throw new Error('could not resolve sandbox root (pwd returned empty)');
98
+ return root;
99
+ }
100
+ /**
101
+ * The credential helper runs under a JS runtime (node >=18 for global fetch, or
102
+ * bun). If NEITHER is on PATH, the whole credential path is dead — abort before
103
+ * the clone with a message that names the missing runtime.
104
+ */
105
+ async function assertJsRuntimeAvailable(session) {
106
+ const res = await session.exec('if command -v node >/dev/null 2>&1 || command -v bun >/dev/null 2>&1; then echo ok; else echo missing; fi');
107
+ if (res.stdout.trim() !== 'ok') {
108
+ throw new Error('no JS runtime on PATH: the git credential helper requires node (>=18) or bun, neither found');
109
+ }
110
+ }
111
+ /**
112
+ * Decide whether the sandbox is running the PRE-BAKED runtime image (ALI-1017).
113
+ * Prefers the explicit boot-context flag (the host KNOWS what image it passed —
114
+ * testable, no filesystem dependency); falls back to a belt read of the
115
+ * `/opt/orizu/prebaked.json` marker (validated via `parsePrebakedMarker`, so a
116
+ * stray same-named file cannot trigger a skip). Either signal → pre-baked.
117
+ */
118
+ async function detectPrebaked(session, flag) {
119
+ if (flag)
120
+ return true;
121
+ try {
122
+ const raw = await session.readFile(PREBAKED_MARKER_PATH);
123
+ return parsePrebakedMarker(raw) !== null;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ /** Detect a JS runtime on the sandbox PATH for the credential helper. */
130
+ async function resolveHelperRuntime(session) {
131
+ const res = await session.exec('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');
132
+ const runtime = res.stdout.trim();
133
+ return runtime === 'bun' ? 'bun' : 'node';
134
+ }
135
+ function credentialHelperValue(runtime, scriptAbs, contextAbs) {
136
+ // `!` makes git run the value as a shell command with the action appended.
137
+ // Only PATHS live here — never the bearer (G3).
138
+ return `!${runtime} ${scriptAbs} ${contextAbs}`;
139
+ }
140
+ export function computeRuntimePaths(root, runId, workspaceDir) {
141
+ const runDirRel = `.orizu-run/${runId}`;
142
+ const runDirAbs = `${root}/${runDirRel}`;
143
+ return {
144
+ root,
145
+ runDirRel,
146
+ runDirAbs,
147
+ repoDirRel: workspaceDir,
148
+ helperScriptAbs: `${runDirAbs}/${HELPER_SCRIPT_BASENAME}`,
149
+ bootContextAbs: `${runDirAbs}/${BOOT_CONTEXT_BASENAME}`,
150
+ bearerFileAbs: `${runDirAbs}/${BEARER_BASENAME}`,
151
+ cacheFileAbs: `${runDirAbs}/${REPO_CRED_CACHE_BASENAME}`,
152
+ };
153
+ }
154
+ export async function bootstrapHostedSandbox(opts) {
155
+ const { session } = opts;
156
+ const workspaceDir = opts.workspaceDir ?? DEFAULT_WORKSPACE_DIR;
157
+ const host = opts.host ?? 'github.com';
158
+ const tokenPurposes = opts.tokenPurposes ??
159
+ (opts.bearerKind === 'agent' ? { ...DEFAULT_AGENT_TOKEN_PURPOSES } : { ...DEFAULT_TOKEN_PURPOSES });
160
+ const cloneUrl = opts.resolveCloneUrl(opts.repoFullName);
161
+ const runDirRel = `.orizu-run/${opts.runId}`;
162
+ // P3a — refuse shell metacharacters in any value interpolated into a command,
163
+ // BEFORE we touch the sandbox (so a tainted value never reaches a shell).
164
+ assertSafeShellValue('runId', opts.runId);
165
+ assertSafeShellValue('runDirRel', runDirRel);
166
+ assertSafeShellValue('sessionBranch', opts.sessionBranch);
167
+ assertSafeShellValue('workspaceDir', workspaceDir);
168
+ assertSafeShellValue('cloneUrl', cloneUrl);
169
+ const sink = opts.sink ??
170
+ createBootstrapRunEventSink({
171
+ apiBaseUrl: opts.apiBaseUrl,
172
+ runId: opts.runId,
173
+ bearer: opts.bearer,
174
+ fetchImpl: opts.fetchImpl,
175
+ now: opts.now,
176
+ });
177
+ const steps = [];
178
+ const record = (key, ok, detail) => {
179
+ steps.push({ key, ok, detail });
180
+ };
181
+ const root = await resolveRoot(session);
182
+ const paths = computeRuntimePaths(root, opts.runId, workspaceDir);
183
+ const runtime = await resolveHelperRuntime(session);
184
+ const helperValue = credentialHelperValue(runtime, paths.helperScriptAbs, paths.bootContextAbs);
185
+ let currentStep = 'sandbox_provisioned';
186
+ try {
187
+ // 0 — Preflight: the credential helper needs a JS runtime; abort before the
188
+ // clone (with a recorded bootstrap_failed) if neither node nor bun is present.
189
+ currentStep = 'runtime_preflight';
190
+ await assertJsRuntimeAvailable(session);
191
+ // Pre-baked detection (ALI-1017): the boot-context flag (preferred) or the
192
+ // filesystem marker. Decided ONCE here; step 4 uses it to skip the CLI install.
193
+ const prebaked = await detectPrebaked(session, opts.prebaked);
194
+ // 1 — Inject boot context. Run dir is 0700; bearer + context are 0600.
195
+ currentStep = 'inject_context';
196
+ await session.exec(`mkdir -p ${paths.runDirRel} && chmod 700 ${paths.runDirRel}`);
197
+ const bootContext = {
198
+ apiBaseUrl: opts.apiBaseUrl.replace(/\/$/, ''),
199
+ workspaceId: opts.workspaceId,
200
+ sessionId: opts.sessionId,
201
+ runId: opts.runId,
202
+ sessionBranch: opts.sessionBranch,
203
+ repoFullName: opts.repoFullName,
204
+ host,
205
+ bearerFile: paths.bearerFileAbs,
206
+ cacheFile: paths.cacheFileAbs,
207
+ tokenPurposes,
208
+ insecureHttpHosts: opts.insecureHttpHosts,
209
+ cacheBufferMs: opts.cacheBufferMs ?? DEFAULT_CACHE_REFRESH_BUFFER_MS,
210
+ };
211
+ await session.writeFile(`${paths.runDirRel}/${HELPER_SCRIPT_BASENAME}`, renderCredentialHelperScript());
212
+ await session.writeFile(`${paths.runDirRel}/${BOOT_CONTEXT_BASENAME}`, serializeBootContext(bootContext));
213
+ // P3b — the bearer is the one true secret on disk. Create it ATOMICALLY at
214
+ // 0600 (umask 077 + redirect) so it never exists world-readable, even for an
215
+ // instant — the SandboxSession.writeFile seam cannot express file perms.
216
+ await session.exec(`umask 077 && printf '%s\\n' ${shellSingleQuote(opts.bearer)} > ${paths.runDirRel}/${BEARER_BASENAME}`);
217
+ // The boot context holds only paths (no secret), but keep it 0600 too.
218
+ await session.exec(`chmod 600 ${paths.runDirRel}/${BOOT_CONTEXT_BASENAME}`);
219
+ await sink.append('sandbox_provisioned', {
220
+ sandboxId: session.id,
221
+ runtime,
222
+ sessionBranch: opts.sessionBranch,
223
+ repo: opts.repoFullName,
224
+ });
225
+ // 2/3 — Clone the session branch VIA the credential helper. The helper is
226
+ // supplied ephemerally with `-c` (persisted nowhere); no token in the URL.
227
+ currentStep = 'repo_cloned';
228
+ const cloneCommand = `git clone --depth 1 --branch ${opts.sessionBranch} ` +
229
+ `-c credential.helper=${shellSingleQuote(helperValue)} -c credential.useHttpPath=true ` +
230
+ `${cloneUrl} ${workspaceDir}`;
231
+ const clone = await session.exec(cloneCommand);
232
+ if (clone.exitCode !== 0) {
233
+ const detail = (clone.stderr && clone.stderr.trim()) || clone.stdout.trim() || `exit ${clone.exitCode}`;
234
+ throw new Error(`git clone failed: ${detail}`);
235
+ }
236
+ // Persist the helper repo-LOCAL (never --global) for subsequent fetch/push.
237
+ await session.exec(`git -C ${workspaceDir} config credential.helper ${shellSingleQuote(helperValue)}`);
238
+ await session.exec(`git -C ${workspaceDir} config credential.useHttpPath true`);
239
+ record('repo_cloned', true, `cloned ${opts.sessionBranch}`);
240
+ await sink.append('repo_cloned', { branch: opts.sessionBranch, workspaceDir });
241
+ // 4 — Provision the Orizu CLI (non-fatal, recorded — OpenInspect setup.sh
242
+ // discipline). PRE-BAKED (ALI-1017): the image already ships `orizu` on PATH,
243
+ // so SKIP the install (npm is blocked under G5 default-deny egress) and record
244
+ // `cli_prebaked` instead. FROM-SCRATCH (local-sim / fallback): install the
245
+ // published `orizu` npm package as before. assertJsRuntimeAvailable already
246
+ // ran in the preflight above, so the pre-baked node/bun is proven present.
247
+ currentStep = 'cli_installed';
248
+ if (prebaked) {
249
+ record('cli_prebaked', true, 'orizu CLI pre-baked in the runtime image');
250
+ await sink.append('cli_prebaked', { detail: 'orizu CLI pre-baked in the runtime image' });
251
+ }
252
+ else {
253
+ const installCommand = opts.cliInstallCommand ?? DEFAULT_CLI_INSTALL(opts.cliVersion ?? 'latest');
254
+ const install = await session.exec(installCommand, { cwd: workspaceDir });
255
+ const installOk = install.exitCode === 0;
256
+ record('cli_installed', installOk, installOk ? 'orizu CLI installed' : `install exit ${install.exitCode}`);
257
+ await sink.append('cli_installed', {
258
+ ok: installOk,
259
+ exitCode: install.exitCode,
260
+ outputTail: tail(install.stdout, install.stderr),
261
+ });
262
+ }
263
+ // 5 — Customer setup hook (fresh-boot only, non-fatal, output captured).
264
+ // P3-a: for enforced-egress providers the host DEFERS this hook to the loop,
265
+ // which runs it AFTER the egress canary proves the firewall is live — so the
266
+ // hook's network access can never precede detection of a silent firewall
267
+ // failure. Here we only record the deferral (the loop emits the real result).
268
+ currentStep = 'setup_hook';
269
+ if (opts.deferSetupHook) {
270
+ record('setup_hook_deferred', true, 'deferred to loop (runs after egress canary)');
271
+ await sink.append('setup_hook_deferred', { reason: 'egress canary must pass before customer setup.sh runs' });
272
+ return { ok: true, steps, events: sink.recorded, paths, sweep: null, failure: null };
273
+ }
274
+ const hookRel = `${workspaceDir}/${SETUP_HOOK_RELATIVE_PATH}`;
275
+ if (await session.fileExists(hookRel)) {
276
+ const hook = await session.exec(`bash ${SETUP_HOOK_RELATIVE_PATH}`, { cwd: workspaceDir });
277
+ const hookOk = hook.exitCode === 0;
278
+ record('setup_hook_completed', hookOk, hookOk ? 'setup.sh ok' : `setup.sh exit ${hook.exitCode}`);
279
+ await sink.append('setup_hook_completed', {
280
+ ok: hookOk,
281
+ exitCode: hook.exitCode,
282
+ outputTail: tail(hook.stdout, hook.stderr),
283
+ });
284
+ }
285
+ else {
286
+ record('setup_hook_skipped', true, 'no .orizu/setup.sh');
287
+ await sink.append('setup_hook_skipped', { reason: 'no .orizu/setup.sh' });
288
+ }
289
+ return { ok: true, steps, events: sink.recorded, paths, sweep: null, failure: null };
290
+ }
291
+ catch (error) {
292
+ const message = error instanceof Error ? error.message : String(error);
293
+ record(currentStep, false, message);
294
+ // Record the failure (redacted), then tear down + prove no residue remains.
295
+ await safeAppend(sink, 'bootstrap_failed', { step: currentStep, error: message });
296
+ const sweep = await teardownHostedSandbox({ session, paths, bearer: opts.bearer, workspaceDir });
297
+ return { ok: false, steps, events: sink.recorded, paths, sweep, failure: { step: currentStep, error: message } };
298
+ }
299
+ }
300
+ /** Never let a failure-path event append mask the original error. */
301
+ async function safeAppend(sink, eventType, payload) {
302
+ try {
303
+ await sink.append(eventType, payload);
304
+ }
305
+ catch {
306
+ // The run-event log is best-effort on the failure path; teardown proceeds.
307
+ }
308
+ }
309
+ function tail(stdout, stderr) {
310
+ const combined = [stdout, stderr ?? ''].join('\n');
311
+ return combined.split('\n').slice(-20).join('\n').trim();
312
+ }
313
+ /**
314
+ * Tear the run-scoped credential material down and PROVE no token residue
315
+ * remains (G3, extends ALI-973's `sweepForTokenResidue`). Removes the run dir
316
+ * (bearer + boot context + minted-token cache), unsets the repo-local credential
317
+ * helper, then sweeps .git/config, the git credential cache, env, argv, and
318
+ * ~/.git-credentials for the bearer, any extra tokens, and the token markers.
319
+ */
320
+ export async function teardownHostedSandbox(opts) {
321
+ const { session, paths } = opts;
322
+ const workspaceDir = opts.workspaceDir ?? paths.repoDirRel;
323
+ // Unset the repo-local helper (no secret in it, but leave nothing behind),
324
+ // then remove the whole run dir (bearer, boot context, minted-token cache).
325
+ await session.exec(`git -C ${workspaceDir} config --unset-all credential.helper 2>/dev/null || true`);
326
+ await session.exec(`rm -rf ${paths.runDirRel}`);
327
+ const findings = await sweepHostedResidue(session, {
328
+ bearer: opts.bearer,
329
+ paths,
330
+ workspaceDir,
331
+ extraTokens: opts.extraTokens,
332
+ });
333
+ return { clean: findings.length === 0, findings };
334
+ }
335
+ /**
336
+ * Extend the ALI-973 sweep with hosted-specific probes: the run dir (bearer +
337
+ * cache) must be gone, and the standard leak surfaces (.git/config, ~/.git-
338
+ * credentials, credential cache, env, argv) must be free of the bearer.
339
+ */
340
+ export async function sweepHostedResidue(session, opts) {
341
+ const tokens = [opts.bearer, ...(opts.extraTokens ?? [])].filter(t => t.length > 0);
342
+ const probes = [
343
+ { location: `${opts.workspaceDir}/.git/config`, command: `cat ${opts.workspaceDir}/.git/config 2>/dev/null || true` },
344
+ {
345
+ location: `${opts.workspaceDir}/.git/logs (reflog)`,
346
+ command: `find ${opts.workspaceDir}/.git/logs -type f -exec cat {} + 2>/dev/null || true`,
347
+ },
348
+ { location: 'run dir (bearer + minted-token cache)', command: `find ${opts.paths.runDirRel} -type f -exec cat {} + 2>/dev/null || true` },
349
+ { location: '~/.git-credentials', command: 'cat "$HOME/.git-credentials" 2>/dev/null || true' },
350
+ { location: '~/.gitconfig (global)', command: 'cat "$HOME/.gitconfig" 2>/dev/null || true' },
351
+ {
352
+ location: 'git credential cache/store',
353
+ command: 'cat "$HOME/.cache/git/credential/"* "${XDG_CACHE_HOME:-$HOME/.cache}/git/credential/"* 2>/dev/null || true',
354
+ },
355
+ { location: 'shell history', command: 'cat "$HOME/.bash_history" "$HOME/.zsh_history" 2>/dev/null || true' },
356
+ { location: 'process listing / argv', command: 'ps -eo args 2>/dev/null || ps aux 2>/dev/null || true' },
357
+ { location: 'process env', command: 'env' },
358
+ ];
359
+ return sweepForTokenResidue(session, {
360
+ tokens,
361
+ repoPath: opts.workspaceDir,
362
+ markers: ['x-access-token'],
363
+ probes,
364
+ });
365
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Standalone fake Orizu control-plane broker for the ALI-925 rehearsal + tests.
3
+ *
4
+ * This runs as its OWN process (spawned via `bun <this file>`), NOT in-process,
5
+ * for a specific reason: the local-sim `SandboxSession.exec` uses a blocking
6
+ * `spawnSync`, and the in-sandbox credential helper it runs makes a real HTTP
7
+ * call back to this broker. An in-process server can't answer that call while
8
+ * the test thread is blocked in `spawnSync` — so the broker must be a separate
9
+ * process with its own event loop.
10
+ *
11
+ * It implements exactly the two contracts the bootstrap depends on:
12
+ * - POST /api/cli/workspaces/:id/repo-token → {token, expiresAt, repo, mintId}
13
+ * - POST /api/cli/workbench-runs/:id/events → 201 (rejects reserved run_* 400)
14
+ * and it journals every accepted event / issued token to newline-delimited JSON
15
+ * files so the parent test can read them back. Config comes from env (below).
16
+ *
17
+ * Not shipped to end users — a test/rehearsal fixture only.
18
+ */
19
+ import { appendFileSync } from 'fs';
20
+ const RESERVED_EVENT_TYPES = new Set(['run_started', 'run_completed', 'run_failed', 'run_cancelled']);
21
+ // Mirror the PRODUCTION route (app/api/cli/workspaces/[id]/repo-token/route.ts):
22
+ // it accepts 'read' | 'write' (human) and 'session_read' | 'session_write'
23
+ // (agent, ALI-928 — flipped in lockstep with the route). Unknown purpose → 400;
24
+ // a known purpose the caller is not authorized for → 403.
25
+ const VALID_PURPOSES = new Set(['read', 'write', 'session_read', 'session_write']);
26
+ // Purposes that require write access (subject to the denyWrite simulation).
27
+ const WRITE_PURPOSES = new Set(['write', 'session_write']);
28
+ function bun() {
29
+ const b = globalThis.Bun;
30
+ if (!b?.serve)
31
+ throw new Error('hosted-broker-server requires bun');
32
+ return b;
33
+ }
34
+ export function readBrokerConfigFromEnv(env) {
35
+ return {
36
+ bearer: env.HB_BEARER ?? '',
37
+ repo: env.HB_REPO ?? 'acme/repo',
38
+ eventsFile: env.HB_EVENTS_FILE,
39
+ tokensFile: env.HB_TOKENS_FILE,
40
+ repoTokenStatus: env.HB_REPO_TOKEN_STATUS ? Number.parseInt(env.HB_REPO_TOKEN_STATUS, 10) : undefined,
41
+ eventsStatus: env.HB_EVENTS_STATUS ? Number.parseInt(env.HB_EVENTS_STATUS, 10) : undefined,
42
+ denyWrite: env.HB_DENY_WRITE === '1',
43
+ port: env.HB_PORT ? Number.parseInt(env.HB_PORT, 10) : 0,
44
+ fullRunApi: env.HB_FULL_RUN_API === '1',
45
+ sessionId: env.HB_SESSION_ID,
46
+ sessionBranch: env.HB_SESSION_BRANCH,
47
+ };
48
+ }
49
+ export function startBrokerServer(config) {
50
+ let mints = 0;
51
+ let agentTokens = 0;
52
+ let sessions = 0;
53
+ let runs = 0;
54
+ // In-memory RunAPI state (E2E mode): events + status per run, terminal record.
55
+ const runEvents = new Map();
56
+ const runStatus = new Map();
57
+ // E2E: the CURRENT valid agent token — the most recently issued by the
58
+ // agent-token endpoint. A rotation (a fresh mint) invalidates the previous
59
+ // token, so agent-authenticated WRITES (run start / events / terminal PATCH)
60
+ // must present the current value; a rotated-out token is 401 (an honest
61
+ // "old token invalidated at the broker"). Reads stay permissive so a fresh
62
+ // human can still tail the run.
63
+ let currentAgentToken = null;
64
+ const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
65
+ const authOk = (header) => config.fullRunApi ? header.startsWith('Bearer ') && header.length > 'Bearer '.length : header === `Bearer ${config.bearer}`;
66
+ // Agent-write auth (E2E only): once an agent token has been issued, an
67
+ // agent-authenticated write MUST carry the current one.
68
+ const agentWriteOk = (header) => currentAgentToken === null || header === `Bearer ${currentAgentToken}`;
69
+ const recordEvent = (runId, event) => {
70
+ const list = runEvents.get(runId) ?? [];
71
+ list.push(event);
72
+ runEvents.set(runId, list);
73
+ if (config.eventsFile) {
74
+ appendFileSync(config.eventsFile, `${JSON.stringify({ runId, ...event })}\n`);
75
+ }
76
+ };
77
+ return bun().serve({
78
+ port: config.port ?? 0,
79
+ hostname: '127.0.0.1',
80
+ async fetch(request) {
81
+ const url = new URL(request.url);
82
+ const method = request.method.toUpperCase();
83
+ if (!authOk(request.headers.get('authorization') ?? '')) {
84
+ return json({ error: 'Unauthorized' }, 401);
85
+ }
86
+ const body = method === 'POST' || method === 'PATCH'
87
+ ? (await request.json().catch(() => ({})))
88
+ : {};
89
+ // -- Full RunAPI + session + agent-token (E2E mode only) --------------
90
+ if (config.fullRunApi) {
91
+ const sessionsMatch = url.pathname.match(/^\/api\/cli\/workspaces\/([^/]+)\/sessions$/);
92
+ if (method === 'POST' && sessionsMatch) {
93
+ sessions += 1;
94
+ const id = config.sessionId ?? `sess-e2e-${sessions}`;
95
+ const repoBranch = config.sessionBranch ?? `orizu/session-${id}`;
96
+ return json({ session: { id, workspaceId: sessionsMatch[1], repoBranch, status: 'active' } }, 201);
97
+ }
98
+ const agentTokenMatch = url.pathname.match(/^\/api\/cli\/sessions\/([^/]+)\/agent-token$/);
99
+ if (method === 'POST' && agentTokenMatch) {
100
+ agentTokens += 1;
101
+ const issued = `orizu_agent_e2e_${agentTokens}_${Math.random().toString(36).slice(2)}`;
102
+ // A fresh mint rotates the current token: the previous one is now stale
103
+ // for agent-authenticated writes.
104
+ currentAgentToken = issued;
105
+ return json({
106
+ token: issued,
107
+ agentUserId: 'agent-user-e2e',
108
+ sessionId: agentTokenMatch[1],
109
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
110
+ }, 201);
111
+ }
112
+ if (method === 'POST' && url.pathname === '/api/cli/workbench-runs') {
113
+ if (!agentWriteOk(request.headers.get('authorization') ?? '')) {
114
+ return json({ error: 'stale agent token' }, 401);
115
+ }
116
+ runs += 1;
117
+ const runId = `run-e2e-${runs}`;
118
+ runStatus.set(runId, 'running');
119
+ recordEvent(runId, { eventId: 'run_started', sequence: 1, eventType: 'run_started', payload: {} });
120
+ return json({ run: { id: runId, status: 'running' } }, 201);
121
+ }
122
+ const runEventsGet = url.pathname.match(/^\/api\/cli\/workbench-runs\/([^/]+)\/events$/);
123
+ if (method === 'GET' && runEventsGet) {
124
+ const runId = runEventsGet[1];
125
+ const after = Number.parseInt(url.searchParams.get('after') ?? '0', 10) || 0;
126
+ const limit = Number.parseInt(url.searchParams.get('limit') ?? '500', 10) || 500;
127
+ const all = runEvents.get(runId) ?? [];
128
+ const page = all.filter(e => e.sequence > after).slice(0, limit);
129
+ const cursor = page.length > 0 ? page[page.length - 1].sequence : after;
130
+ return json({ events: page, cursor, runStatus: runStatus.get(runId) ?? 'running' });
131
+ }
132
+ const runDetail = url.pathname.match(/^\/api\/cli\/workbench-runs\/([^/]+)$/);
133
+ if (method === 'GET' && runDetail) {
134
+ return json({ run: { id: runDetail[1], status: runStatus.get(runDetail[1]) ?? 'running' } });
135
+ }
136
+ if (method === 'PATCH' && runDetail) {
137
+ if (!agentWriteOk(request.headers.get('authorization') ?? '')) {
138
+ return json({ error: 'stale agent token' }, 401);
139
+ }
140
+ const runId = runDetail[1];
141
+ const status = String(body.status ?? '');
142
+ if (!['succeeded', 'failed', 'cancelled'].includes(status)) {
143
+ return json({ error: 'invalid terminal status' }, 400);
144
+ }
145
+ const existing = runStatus.get(runId);
146
+ if (existing && ['succeeded', 'failed', 'cancelled'].includes(existing)) {
147
+ // Already terminal — Orizu records win (concurrent finisher).
148
+ return json({ error: `Run already ${existing}` }, 409);
149
+ }
150
+ runStatus.set(runId, status);
151
+ return json({ run: { id: runId, status } });
152
+ }
153
+ }
154
+ const tokenMatch = url.pathname.match(/^\/api\/cli\/workspaces\/([^/]+)\/repo-token$/);
155
+ if (method === 'POST' && tokenMatch) {
156
+ if (config.repoTokenStatus && config.repoTokenStatus >= 400) {
157
+ return json({ error: 'forced broker failure' }, config.repoTokenStatus);
158
+ }
159
+ const purpose = String(body.purpose ?? '');
160
+ // Unknown purpose → 400 with the production route's exact error body.
161
+ if (!VALID_PURPOSES.has(purpose)) {
162
+ return json({ error: 'purpose must be "read", "write", "session_read", or "session_write"' }, 400);
163
+ }
164
+ // Simulate a read-only caller: known purpose, but unauthorized to write.
165
+ if (config.denyWrite && WRITE_PURPOSES.has(purpose)) {
166
+ return json({ error: 'Access denied' }, 403);
167
+ }
168
+ mints += 1;
169
+ const mintId = `mint-${mints}`;
170
+ const token = `ghs_sim_${purpose}_${mintId}_${Math.random().toString(36).slice(2)}`;
171
+ if (config.tokensFile)
172
+ appendFileSync(config.tokensFile, `${token}\n`);
173
+ return json({ token, expiresAt: new Date(Date.now() + 3600_000).toISOString(), repo: config.repo, mintId });
174
+ }
175
+ const eventsMatch = url.pathname.match(/^\/api\/cli\/workbench-runs\/([^/]+)\/events$/);
176
+ if (method === 'POST' && eventsMatch) {
177
+ // E2E: appending events is an agent-authenticated write — a rotated-out
178
+ // token is rejected so the honest rotation test can prove re-resolve.
179
+ if (config.fullRunApi && !agentWriteOk(request.headers.get('authorization') ?? '')) {
180
+ return json({ error: 'stale agent token' }, 401);
181
+ }
182
+ if (config.eventsStatus && config.eventsStatus >= 400) {
183
+ return json({ error: 'forced events failure' }, config.eventsStatus);
184
+ }
185
+ const eventType = String(body.eventType ?? '');
186
+ const sequence = body.sequence;
187
+ const eventId = String(body.eventId ?? '');
188
+ if (!eventId)
189
+ return json({ error: 'eventId is required' }, 400);
190
+ if (!Number.isInteger(sequence) || Number(sequence) <= 0) {
191
+ return json({ error: 'sequence must be a positive integer' }, 400);
192
+ }
193
+ if (!eventType)
194
+ return json({ error: 'eventType is required' }, 400);
195
+ if (RESERVED_EVENT_TYPES.has(eventType))
196
+ return json({ error: `${eventType} is a lifecycle event` }, 400);
197
+ const runId = eventsMatch[1];
198
+ const seq = Number(sequence);
199
+ const existingForRun = runEvents.get(runId) ?? [];
200
+ // Idempotent replay: same eventId → 201 without re-recording. Same
201
+ // sequence, different eventId → 409 (mirrors the ingest RPC contract).
202
+ if (existingForRun.some(e => e.eventId === eventId)) {
203
+ return json({ eventId, id: `stored-${eventId}`, inserted: false }, 201);
204
+ }
205
+ if (existingForRun.some(e => e.sequence === seq)) {
206
+ return json({ error: 'Workbench event sequence already exists for a different event_id' }, 409);
207
+ }
208
+ recordEvent(runId, { eventId, sequence: seq, eventType, payload: body.payload });
209
+ return json({ eventId, id: `stored-${eventId}` }, 201);
210
+ }
211
+ return json({ error: `unexpected ${method} ${url.pathname}` }, 404);
212
+ },
213
+ });
214
+ }
215
+ // When executed directly (`bun hosted-broker-server.ts`), start the server and
216
+ // print `LISTENING <port>` so the spawning parent can resolve the port.
217
+ if (import.meta.main) {
218
+ const server = startBrokerServer(readBrokerConfigFromEnv(process.env));
219
+ process.stdout.write(`LISTENING ${server.port}\n`);
220
+ }