orizu 0.4.1 → 0.5.1

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 +248 -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 +396 -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,844 @@
1
+ /**
2
+ * `orizu session start --hosted` — host-side driver for a CLI-triggered hosted
3
+ * agent session (ALI-928 / P3.5, per ADR-005). Also the thin dispatch home for
4
+ * the in-sandbox `orizu internal hosted-loop` (delegated to `hosted-loop.ts`).
5
+ *
6
+ * A human runs this with their NORMAL login. `startHostedSession` then:
7
+ * (a) opens a workspace session (its own session branch is cut server-side);
8
+ * (b) mints a session-scoped AGENT token (the sandbox's Orizu bearer);
9
+ * (c) starts a workbench run WITH THE AGENT BEARER (actor_type='agent');
10
+ * (d) creates the sandbox via a SandboxProvider (timeout from --duration,
11
+ * default 60m, cap 24h — never Vercel's 5m default);
12
+ * (e) bootstraps the sandbox (agent bearer in a 0600 file; broker purposes
13
+ * session_write/session_read chosen from bearerKind='agent');
14
+ * (f) launches the hosted-loop DETACHED so the CLI can exit;
15
+ * (g) READINESS-GATES on the loop's first event (bounded); on failure it
16
+ * destroys the sandbox + exits non-zero. Detached mode then prints when the
17
+ * sandbox self-terminates (it lives to --duration; session-end reaping is
18
+ * G6/ALI-1007). Attached (--tail) mode rotates + keeps the box alive +
19
+ * streams the tail, and REAPS the sandbox when the run terminates.
20
+ *
21
+ * TOKEN TTL vs LONG SESSIONS (decided): while attached (--tail), the CLI
22
+ * re-mints the agent token before expiry (default 55m) and ATOMICALLY overwrites
23
+ * the 0600 bearer file (temp + mv). Rotation keeps BOTH git ops and event
24
+ * recording alive: the in-sandbox credential helper reads the bearer per-op, AND
25
+ * the loop's RunEventSink resolves it per request (bearer PROVIDER + a one-shot
26
+ * re-resolve-and-retry on 401/403). Each rotation also extendTimeout()s the
27
+ * sandbox by the rotation interval (bounded by the 24h cap). A failed mint
28
+ * fast-retries (60s) and gives up loudly before expiry. If the host disconnects,
29
+ * tokens expire within the TTL (≤60m) and the loop finishes 'failed' cleanly; the
30
+ * sandbox timeout is independent (configurable to 24h). Documented in the
31
+ * secrets-policy doc §8 + the command help.
32
+ *
33
+ * Everything the command needs (provider, fetch, repo resolution, loop launch)
34
+ * is injectable, so the whole flow runs in-process against fakes in the
35
+ * local-sim end-to-end test.
36
+ */
37
+
38
+ import {
39
+ createLocalSimProvider,
40
+ type SandboxEgressPolicy,
41
+ type SandboxProvider,
42
+ type SandboxSession,
43
+ } from './sandbox-provider.js'
44
+ import {
45
+ bootstrapHostedSandbox,
46
+ createBootstrapRunEventSink,
47
+ type BootstrapRunEventSink,
48
+ type HostedFetch,
49
+ type HostedRuntimePaths,
50
+ } from './hosted-bootstrap.js'
51
+ import { resumeRunEventSink } from './hosted-run-event-sink.js'
52
+ import { BEARER_BASENAME } from './hosted-runtime-assets.js'
53
+ import { createVercelProvider } from './vercel-sandbox-provider.js'
54
+ import { buildEgressPolicy, DEFAULT_EGRESS_CANARY_HOST } from './egress-policy.js'
55
+ import { hostedLoopCommand, type HostedLoopContext } from './hosted-loop.js'
56
+ import { authedFetch } from './http.js'
57
+ import { resolveBaseUrl } from './http.js'
58
+ import { getWorkspaceRoot } from './workspace.js'
59
+ import { attachedWorkspaceId } from './workspace-sync.js'
60
+ import { tailWorkbenchRun } from './workbench-cli.js'
61
+
62
+ export const DEFAULT_HOSTED_MODEL = 'anthropic/claude-opus-4-8'
63
+ export const DEFAULT_DURATION_MINUTES = 60
64
+ export const MAX_DURATION_MINUTES = 24 * 60
65
+ export const DEFAULT_ROTATION_INTERVAL_MS = 55 * 60 * 1000
66
+ /** After a mint failure, retry the rotation on this short interval (not the full
67
+ * 55m) so a transient outage does not silently ride the token to expiry. */
68
+ export const ROTATION_FAST_RETRY_MS = 60 * 1000
69
+ export const DEFAULT_AGENT_TOKEN_TTL_MINUTES = 60
70
+ /** Readiness gate: how long to poll the run's events for the loop's first event
71
+ * before declaring the detached launch a failure. */
72
+ export const DEFAULT_READINESS_TIMEOUT_MS = 30 * 1000
73
+ export const DEFAULT_READINESS_POLL_MS = 500
74
+ /** Non-secret placeholder OpenCode uses to form requests; the firewall proxy
75
+ * overrides the real Anthropic header (model-key brokering). NEVER a real key. */
76
+ export const ANTHROPIC_DUMMY_KEY = 'sk-ant-orizu-proxy-broker-placeholder'
77
+
78
+ // -- Injectable HTTP -----------------------------------------------------------
79
+
80
+ /** Base-relative fetch (base already applied) — the host-side human fetcher. */
81
+ export type HostFetcher = (path: string, init?: RequestInit) => Promise<Response>
82
+
83
+ /** Build a base-relative fetcher that authenticates with a specific bearer
84
+ * (used for the AGENT-bearer run-start against the absolute API base). */
85
+ export function bearerFetcher(
86
+ apiBaseUrl: string,
87
+ bearer: string,
88
+ rawFetch: HostedFetch
89
+ ): HostFetcher {
90
+ const base = apiBaseUrl.replace(/\/$/, '')
91
+ return (path, init = {}) =>
92
+ rawFetch(`${base}${path}`, {
93
+ ...init,
94
+ headers: { ...(init.headers || {}), Authorization: `Bearer ${bearer}` },
95
+ })
96
+ }
97
+
98
+ async function readJson(response: Response): Promise<Record<string, unknown>> {
99
+ try {
100
+ return (await response.json()) as Record<string, unknown>
101
+ } catch {
102
+ return {}
103
+ }
104
+ }
105
+
106
+ async function postJson(
107
+ fetcher: HostFetcher,
108
+ path: string,
109
+ payload: Record<string, unknown>
110
+ ): Promise<Record<string, unknown>> {
111
+ const response = await fetcher(path, {
112
+ method: 'POST',
113
+ headers: { 'Content-Type': 'application/json' },
114
+ body: JSON.stringify(payload),
115
+ })
116
+ if (!response.ok) {
117
+ const body = await readJson(response)
118
+ const error = typeof body.error === 'string' ? body.error : `${response.status}`
119
+ throw new Error(`${path} failed (${response.status}): ${error}`)
120
+ }
121
+ return readJson(response)
122
+ }
123
+
124
+ function requireString(value: unknown, label: string): string {
125
+ if (typeof value !== 'string' || value.length === 0) {
126
+ throw new Error(`${label} missing from response`)
127
+ }
128
+ return value
129
+ }
130
+
131
+ // -- Loop launch seam ---------------------------------------------------------
132
+
133
+ export interface LaunchLoopParams {
134
+ session: SandboxSession
135
+ context: HostedLoopContext
136
+ /** Sandbox-relative + absolute paths for the run-scoped loop files. */
137
+ taskRel: string
138
+ contextRel: string
139
+ contextAbs: string
140
+ logRel: string
141
+ taskPrompt: string
142
+ /** Agent bearer, handed through so an injected in-process launcher can run
143
+ * the loop without re-reading the 0600 file. */
144
+ bearer: string
145
+ }
146
+
147
+ /** Default launch: write the task + loop-context files (0600), then start the
148
+ * loop DETACHED via nohup so the host CLI can exit. */
149
+ async function defaultLaunchLoop(p: LaunchLoopParams): Promise<void> {
150
+ await p.session.writeFile(p.taskRel, p.taskPrompt)
151
+ await p.session.writeFile(p.contextRel, JSON.stringify(p.context))
152
+ await p.session.exec(`chmod 600 ${p.contextRel} ${p.taskRel}`)
153
+ await p.session.exec(
154
+ `nohup orizu internal hosted-loop --context ${p.contextAbs} > ${p.logRel} 2>&1 &`
155
+ )
156
+ }
157
+
158
+ // -- start options / result ---------------------------------------------------
159
+
160
+ export interface RepoResolution {
161
+ repoFullName: string
162
+ cloneUrl: string
163
+ }
164
+
165
+ export interface StartHostedSessionOptions {
166
+ /** Host-side human fetcher (base-relative, authed with the human login). */
167
+ fetcher: HostFetcher
168
+ /** Absolute Orizu API base — baked into the sandbox boot context. */
169
+ apiBaseUrl: string
170
+ provider: SandboxProvider
171
+ workspaceId: string
172
+ task: string
173
+ projectSlug?: string | null
174
+ model?: string
175
+ reasoningEffort?: string
176
+ /** Sandbox session length (minutes); default 60, hard-capped at 24h. */
177
+ durationMinutes?: number
178
+ runtime?: string
179
+ /**
180
+ * Pre-baked custom VCR image ref (ALI-1017), e.g. 'orizu-hosted-runtime:v1'.
181
+ * When set, it is passed to `Sandbox.create({ image })` AND flips the `prebaked`
182
+ * flag on BOTH the bootstrap and the loop together — so the sandbox image and
183
+ * the install-skip decision can never disagree. When unset, the from-scratch
184
+ * install path runs (local-sim / base runtime). Default: env ORIZU_HOSTED_IMAGE.
185
+ */
186
+ hostedImage?: string
187
+ /**
188
+ * Pre-baked Vercel Sandbox SNAPSHOT id (ALI-1017, zero-Docker path). Parallel to
189
+ * `hostedImage`: when set it is passed to `Sandbox.create({ source:{ type:'snapshot' }})`
190
+ * AND flips the `prebaked` flag on bootstrap + loop together. Mutually exclusive
191
+ * with `hostedImage` — both set is a hard error. Default: env ORIZU_HOSTED_SNAPSHOT.
192
+ */
193
+ hostedSnapshot?: string
194
+ title?: string
195
+ /** Attach: rotate the bearer + stream the run tail until it terminates. */
196
+ tail?: boolean
197
+ /** Host-side model key; injected at the firewall proxy, never into the sandbox. */
198
+ modelApiKey?: string
199
+ /** VCS host the credential helper serves (default github.com; rehearsal only). */
200
+ host?: string
201
+ /** Loopback hosts the helper may serve over plain HTTP (rehearsal only). */
202
+ insecureHttpHosts?: readonly string[]
203
+ // -- injectables (production defaults provided) --
204
+ rawFetch?: HostedFetch
205
+ resolveRepo?: (workspaceId: string) => Promise<RepoResolution>
206
+ launchLoop?: (params: LaunchLoopParams) => Promise<void>
207
+ /** Build the sandbox egress policy from the resolved inputs. Default: G5
208
+ * `buildEgressPolicy` — DEFAULT-DENY base allowlist (Orizu API + model provider
209
+ * + git) with the model-key broker composed on, plus the per-team additive
210
+ * domains. Injectable for tests. */
211
+ buildEgressPolicy?: (input: { modelApiKey?: string; extraDomains: readonly string[] }) => SandboxEgressPolicy | undefined
212
+ /** Resolve the per-team ADDITIVE egress domains for this workspace's team
213
+ * (base hosts are code-owned and always present). Default: no extra domains —
214
+ * the production CLI injects a route-backed resolver. */
215
+ resolveExtraEgressDomains?: (workspaceId: string) => Promise<readonly string[]>
216
+ rotationIntervalMs?: number
217
+ agentTokenTtlMinutes?: number
218
+ now?: () => number
219
+ /** Readiness gate bound (ms); default 30s. */
220
+ readinessTimeoutMs?: number
221
+ /** Readiness poll interval (ms); default 500ms. */
222
+ readinessPollMs?: number
223
+ /** Test seam: override the readiness probe entirely (resolves ready/not). */
224
+ waitForReady?: (params: ReadinessParams) => Promise<boolean>
225
+ logLine?: (line: string) => void
226
+ onTailEvent?: (event: Record<string, unknown>) => void
227
+ /** Test hook: the live sandbox + its run-scoped paths, after a successful
228
+ * bootstrap (used by the e2e for residue-sweep + bearer-file inspection). */
229
+ onSandboxReady?: (info: { session: SandboxSession; paths: HostedRuntimePaths }) => void
230
+ /** Await the tail loop when attached (tests pass a bounded tailer). */
231
+ tailImpl?: (params: TailParams) => Promise<void>
232
+ }
233
+
234
+ export interface TailParams {
235
+ fetcher: HostFetcher
236
+ runId: string
237
+ onEvent?: (event: Record<string, unknown>) => void
238
+ }
239
+
240
+ export interface ReadinessParams {
241
+ fetcher: HostFetcher
242
+ runId: string
243
+ timeoutMs: number
244
+ pollMs: number
245
+ now: () => number
246
+ sleep: (ms: number) => Promise<void>
247
+ log: (line: string) => void
248
+ }
249
+
250
+ const TERMINAL_RUN_STATUSES: ReadonlySet<string> = new Set(['succeeded', 'failed', 'cancelled'])
251
+
252
+ /**
253
+ * The loop's FIRST server-visible event: either `agent_ready` (harness
254
+ * connected) or its startup `artifact` (the opencode_install record it appends
255
+ * before spawning). Presence of either proves the detached loop actually
256
+ * launched and is talking to the RunAPI.
257
+ */
258
+ function isLoopStartupEvent(event: Record<string, unknown>): boolean {
259
+ const type = event.eventType
260
+ if (type === 'agent_ready') return true
261
+ if (type === 'artifact') {
262
+ const payload = event.payload
263
+ if (payload && typeof payload === 'object') {
264
+ // The loop's first artifact — either the OpenCode install (from-scratch) or
265
+ // the opencode_prebaked record (pre-baked runtime, ALI-1017). Presence of
266
+ // either proves the detached loop launched and is talking to the RunAPI.
267
+ const step = (payload as Record<string, unknown>).step
268
+ if (step === 'opencode_install' || step === 'opencode_prebaked') return true
269
+ }
270
+ }
271
+ return false
272
+ }
273
+
274
+ /**
275
+ * Poll the run's events (bounded) for the loop's first event before we declare
276
+ * the session ready. Returns true once seen. Returns false if the run reaches a
277
+ * TERMINAL status first (the loop died before signaling) or the budget expires.
278
+ */
279
+ async function pollLoopReady(params: ReadinessParams): Promise<boolean> {
280
+ const deadline = params.now() + params.timeoutMs
281
+ for (;;) {
282
+ try {
283
+ const res = await params.fetcher(`/api/cli/workbench-runs/${encodeURIComponent(params.runId)}/events?after=0&limit=500`)
284
+ if (res.ok) {
285
+ const body = (await res.json().catch(() => ({}))) as { events?: unknown; runStatus?: unknown }
286
+ const events = Array.isArray(body.events) ? (body.events as Record<string, unknown>[]) : []
287
+ if (events.some(isLoopStartupEvent)) return true
288
+ const runStatus = typeof body.runStatus === 'string' ? body.runStatus : null
289
+ if (runStatus && TERMINAL_RUN_STATUSES.has(runStatus)) return false
290
+ }
291
+ } catch {
292
+ // transient — keep polling within the budget
293
+ }
294
+ if (params.now() >= deadline) return false
295
+ await params.sleep(params.pollMs)
296
+ }
297
+ }
298
+
299
+ export interface StartHostedSessionResult {
300
+ ok: boolean
301
+ sessionId: string
302
+ runId: string
303
+ sandboxId: string
304
+ repoFullName: string
305
+ sessionBranch: string
306
+ agentUserId: string | null
307
+ bootstrapOk: boolean
308
+ error: string | null
309
+ }
310
+
311
+ function clampDuration(minutes: number | undefined): number {
312
+ const base = typeof minutes === 'number' && Number.isFinite(minutes) && minutes > 0 ? minutes : DEFAULT_DURATION_MINUTES
313
+ return Math.min(Math.max(1, Math.floor(base)), MAX_DURATION_MINUTES)
314
+ }
315
+
316
+ /** Overwrite the 0600 bearer file ATOMICALLY: write a sibling temp file (created
317
+ * under umask 077 so it is 0600 from birth — never a world-readable window) then
318
+ * `mv` it into place. The rename is atomic on POSIX, so a concurrent reader (the
319
+ * in-sandbox credential helper / loop reading the bearer per-op) always sees
320
+ * either the whole old token or the whole new one, never a torn write. Matches
321
+ * the bootstrap's G3 discipline — never in argv-persisted config. Used by both
322
+ * the initial write path and rotation. */
323
+ async function writeBearerFile(session: SandboxSession, bearerRel: string, bearer: string): Promise<void> {
324
+ const quoted = `'${bearer.replace(/'/g, `'\\''`)}'`
325
+ const tmpRel = `${bearerRel}.tmp`
326
+ await session.exec(
327
+ `umask 077 && printf '%s\\n' ${quoted} > ${tmpRel} && mv -f ${tmpRel} ${bearerRel}`
328
+ )
329
+ }
330
+
331
+ /** Default repo resolution: mint a session_read token (human bearer) to learn
332
+ * the repo full name, build the GitHub clone URL, then revoke the mint. */
333
+ async function defaultResolveRepo(
334
+ fetcher: HostFetcher,
335
+ workspaceId: string,
336
+ sessionId: string
337
+ ): Promise<RepoResolution> {
338
+ const minted = await postJson(fetcher, `/api/cli/workspaces/${encodeURIComponent(workspaceId)}/repo-token`, {
339
+ purpose: 'session_read',
340
+ sessionId,
341
+ })
342
+ const repoFullName = requireString(minted.repo, 'repo')
343
+ const token = typeof minted.token === 'string' ? minted.token : null
344
+ const mintId = typeof minted.mintId === 'string' ? minted.mintId : null
345
+ if (token && mintId) {
346
+ try {
347
+ await fetcher(`/api/cli/workspaces/${encodeURIComponent(workspaceId)}/repo-token`, {
348
+ method: 'DELETE',
349
+ headers: { 'Content-Type': 'application/json' },
350
+ body: JSON.stringify({ mintId, token }),
351
+ })
352
+ } catch {
353
+ // best-effort revoke — the 60-min TTL is the backstop.
354
+ }
355
+ }
356
+ return { repoFullName, cloneUrl: `https://github.com/${repoFullName}.git` }
357
+ }
358
+
359
+ export async function startHostedSession(
360
+ opts: StartHostedSessionOptions
361
+ ): Promise<StartHostedSessionResult> {
362
+ const rawFetch = opts.rawFetch ?? (globalThis.fetch as HostedFetch)
363
+ const log = opts.logLine ?? ((): void => {})
364
+ const model = opts.model ?? DEFAULT_HOSTED_MODEL
365
+ // ALI-1017: a pre-baked custom image both selects the sandbox image AND flips
366
+ // the prebaked skip-install flag — derived ONCE so bootstrap, the loop, and the
367
+ // create call can never disagree. Env is the default (matches the provider's
368
+ // own ORIZU_HOSTED_IMAGE default) so the flag and the image stay in lockstep.
369
+ const hostedImage = opts.hostedImage ?? process.env.ORIZU_HOSTED_IMAGE
370
+ const hostedSnapshot = opts.hostedSnapshot ?? process.env.ORIZU_HOSTED_SNAPSHOT
371
+ // image and snapshot are two mutually-exclusive prebaked-runtime paths; a run
372
+ // must pick ONE. Fail loudly rather than silently preferring one.
373
+ if (hostedImage && hostedSnapshot) {
374
+ throw new Error(
375
+ 'hosted runtime: set EITHER a pre-baked image (--image / ORIZU_HOSTED_IMAGE) OR a snapshot ' +
376
+ '(--snapshot / ORIZU_HOSTED_SNAPSHOT), not both.'
377
+ )
378
+ }
379
+ // Either prebaked source ships the CLI + OpenCode, so the install-skip flag keys
380
+ // on both — derived ONCE so bootstrap, the loop, and the create call agree.
381
+ const prebakedImage = Boolean(hostedImage || hostedSnapshot)
382
+ // G5: DEFAULT-DENY egress with the code-owned base allowlist (Orizu API + model
383
+ // provider + git host), the model-key broker composed onto the model rule, and
384
+ // the resolved per-team additive domains. The Orizu host is derived from the
385
+ // API base URL so a self-hosted / staging base allowlists the right host.
386
+ const buildEgress =
387
+ opts.buildEgressPolicy ??
388
+ ((input: { modelApiKey?: string; extraDomains: readonly string[] }): SandboxEgressPolicy | undefined =>
389
+ buildEgressPolicy({
390
+ orizuBaseUrl: opts.apiBaseUrl,
391
+ extraDomains: input.extraDomains,
392
+ modelKeyBroker: input.modelApiKey ? { apiKey: input.modelApiKey } : undefined,
393
+ }))
394
+
395
+ // (a) open a workspace session (its own session branch is cut server-side).
396
+ const sessionResp = await postJson(
397
+ opts.fetcher,
398
+ `/api/cli/workspaces/${encodeURIComponent(opts.workspaceId)}/sessions`,
399
+ { repoBranch: true, projectSlug: opts.projectSlug ?? undefined, clientInfo: { source: 'orizu-cli-hosted' } }
400
+ )
401
+ const session = sessionResp.session as { id?: unknown; repoBranch?: unknown } | undefined
402
+ const sessionId = requireString(session?.id, 'session id')
403
+ const sessionBranch = requireString(session?.repoBranch, 'session repoBranch')
404
+ log(`session ${sessionId} on ${sessionBranch}`)
405
+
406
+ // (b) mint the session-scoped agent token (the sandbox's Orizu bearer).
407
+ const ttlMinutes = opts.agentTokenTtlMinutes ?? DEFAULT_AGENT_TOKEN_TTL_MINUTES
408
+ const tokenResp = await postJson(
409
+ opts.fetcher,
410
+ `/api/cli/sessions/${encodeURIComponent(sessionId)}/agent-token`,
411
+ { ttlMinutes }
412
+ )
413
+ let agentBearer = requireString(tokenResp.token, 'agent token')
414
+ const agentUserId = typeof tokenResp.agentUserId === 'string' ? tokenResp.agentUserId : null
415
+ const agentFetch = bearerFetcher(opts.apiBaseUrl, agentBearer, rawFetch)
416
+
417
+ // (c) start the run WITH THE AGENT BEARER (actor_type='agent', own session).
418
+ const runResp = await postJson(agentFetch, '/api/cli/workbench-runs', {
419
+ workspaceSessionId: sessionId,
420
+ title: opts.title ?? `Hosted session ${sessionId}`,
421
+ })
422
+ const run = runResp.run as { id?: unknown } | undefined
423
+ const runId = requireString(run?.id, 'run id')
424
+ log(`run ${runId} started`)
425
+
426
+ // (e-pre) resolve the repo + clone URL.
427
+ const repo = await (opts.resolveRepo
428
+ ? opts.resolveRepo(opts.workspaceId)
429
+ : defaultResolveRepo(opts.fetcher, opts.workspaceId, sessionId))
430
+
431
+ // (d) create the sandbox — timeout ALWAYS set from --duration.
432
+ // Resolve the per-team ADDITIVE egress domains (best-effort: a resolution
433
+ // failure falls back to the base allowlist only — never widens on error).
434
+ let extraEgressDomains: readonly string[] = []
435
+ if (opts.resolveExtraEgressDomains) {
436
+ try {
437
+ extraEgressDomains = await opts.resolveExtraEgressDomains(opts.workspaceId)
438
+ } catch (error) {
439
+ log(`egress allowlist resolve failed (using base allowlist only): ${error instanceof Error ? error.message : String(error)}`)
440
+ }
441
+ }
442
+ const egressPolicy = buildEgress({ modelApiKey: opts.modelApiKey, extraDomains: extraEgressDomains })
443
+ const sandbox = await opts.provider.createSandbox({
444
+ timeoutMs: clampDuration(opts.durationMinutes) * 60 * 1000,
445
+ runtime: opts.runtime,
446
+ // Exactly one of image/snapshot is set (guarded above); pass both through and
447
+ // let the provider apply the snapshot-wins precedence defensively.
448
+ image: hostedImage,
449
+ snapshot: hostedSnapshot,
450
+ egressPolicy,
451
+ })
452
+ // Arm the in-sandbox startup canary ONLY for providers that actually enforce
453
+ // egress at the firewall (Vercel). local-sim ignores the policy, so probing a
454
+ // denied host there would hit the real network and wrongly fail the run closed.
455
+ const egressCanaryHost = opts.provider.kind === 'vercel' ? DEFAULT_EGRESS_CANARY_HOST : undefined
456
+ log(`sandbox ${sandbox.id} (${clampDuration(opts.durationMinutes)}m)`)
457
+
458
+ const base: StartHostedSessionResult = {
459
+ ok: false,
460
+ sessionId,
461
+ runId,
462
+ sandboxId: sandbox.id,
463
+ repoFullName: repo.repoFullName,
464
+ sessionBranch,
465
+ agentUserId,
466
+ bootstrapOk: false,
467
+ error: null,
468
+ }
469
+
470
+ // (e) bootstrap — the run-start already consumed sequence 1, so the bootstrap
471
+ // sink starts at 2 (the loop later RESUMES from the true server cursor).
472
+ const bootstrapSink: BootstrapRunEventSink = createBootstrapRunEventSink({
473
+ apiBaseUrl: opts.apiBaseUrl,
474
+ runId,
475
+ bearer: agentBearer,
476
+ fetchImpl: rawFetch,
477
+ startSequence: 2,
478
+ })
479
+ const bootstrap = await bootstrapHostedSandbox({
480
+ session: sandbox,
481
+ apiBaseUrl: opts.apiBaseUrl,
482
+ bearer: agentBearer,
483
+ workspaceId: opts.workspaceId,
484
+ sessionId,
485
+ runId,
486
+ sessionBranch,
487
+ repoFullName: repo.repoFullName,
488
+ resolveCloneUrl: () => repo.cloneUrl,
489
+ bearerKind: 'agent',
490
+ sink: bootstrapSink,
491
+ fetchImpl: rawFetch,
492
+ host: opts.host,
493
+ insecureHttpHosts: opts.insecureHttpHosts,
494
+ // ALI-1017: skip the in-sandbox CLI install when the pre-baked image ships it.
495
+ prebaked: prebakedImage,
496
+ // P3-a: when egress is enforced (canary armed), DEFER the customer setup hook
497
+ // to the loop so it runs only AFTER the canary proves the firewall is live.
498
+ deferSetupHook: egressCanaryHost !== undefined,
499
+ })
500
+
501
+ if (!bootstrap.ok) {
502
+ // Finish the run 'failed' (bootstrap only records bootstrap_failed + tears
503
+ // down its run dir; it never PATCHes the run terminal), then drop the box.
504
+ const failSink = await resumeRunEventSink({ apiBaseUrl: opts.apiBaseUrl, runId, bearer: agentBearer, fetchImpl: rawFetch })
505
+ try {
506
+ await failSink.finish('failed', { summary: { error: bootstrap.failure?.error ?? 'bootstrap failed' } })
507
+ } catch {
508
+ // best-effort terminal
509
+ }
510
+ try {
511
+ await sandbox.destroy()
512
+ } catch {
513
+ // best-effort teardown
514
+ }
515
+ return { ...base, error: bootstrap.failure?.error ?? 'bootstrap failed' }
516
+ }
517
+ base.bootstrapOk = true
518
+ log(`bootstrap ok — cloned ${sessionBranch}`)
519
+
520
+ // (f) launch the hosted-loop DETACHED.
521
+ const paths = bootstrap.paths
522
+ opts.onSandboxReady?.({ session: sandbox, paths })
523
+ const bearerRel = `${paths.runDirRel}/${BEARER_BASENAME}`
524
+ const taskRel = `${paths.runDirRel}/task.txt`
525
+ const contextRel = `${paths.runDirRel}/loop-context.json`
526
+ const contextAbs = `${paths.runDirAbs}/loop-context.json`
527
+ const logRel = `${paths.runDirRel}/loop.log`
528
+ const loopContext: HostedLoopContext = {
529
+ apiBaseUrl: opts.apiBaseUrl,
530
+ runId,
531
+ bearerFile: paths.bearerFileAbs,
532
+ taskFile: `${paths.runDirAbs}/task.txt`,
533
+ workspaceDir: paths.repoDirRel,
534
+ model,
535
+ reasoningEffort: opts.reasoningEffort,
536
+ messageId: `${runId}:task`,
537
+ author: { name: 'Orizu Agent', email: 'agent@orizu.ai' },
538
+ anthropicDummyKey: opts.modelApiKey ? ANTHROPIC_DUMMY_KEY : undefined,
539
+ // ALI-1017: skip the in-sandbox opencode install when the image ships it.
540
+ prebaked: prebakedImage,
541
+ egressCanaryHost,
542
+ // Run the DEFERRED setup hook in the loop iff bootstrap deferred it (same
543
+ // condition: an enforced-egress provider armed the canary).
544
+ runSetupHook: egressCanaryHost !== undefined,
545
+ }
546
+ await (opts.launchLoop ?? defaultLaunchLoop)({
547
+ session: sandbox,
548
+ context: loopContext,
549
+ taskRel,
550
+ contextRel,
551
+ contextAbs,
552
+ logRel,
553
+ taskPrompt: opts.task,
554
+ bearer: agentBearer,
555
+ })
556
+ log(`hosted-loop launched (run ${runId})`)
557
+
558
+ const now = opts.now ?? ((): number => Date.now())
559
+ const defaultSleep = (ms: number): Promise<void> => new Promise<void>(resolve => setTimeout(resolve, ms))
560
+ const durationMs = clampDuration(opts.durationMinutes) * 60 * 1000
561
+
562
+ // Readiness gate: confirm the DETACHED loop actually started (first event) —
563
+ // or reached terminal early — before we call the session ready. On failure,
564
+ // destroy the sandbox and return non-zero (never leave a paid box orphaned
565
+ // behind a launch that never came up).
566
+ const ready = await (opts.waitForReady ?? pollLoopReady)({
567
+ fetcher: opts.fetcher,
568
+ runId,
569
+ timeoutMs: opts.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS,
570
+ pollMs: opts.readinessPollMs ?? DEFAULT_READINESS_POLL_MS,
571
+ now,
572
+ sleep: defaultSleep,
573
+ log,
574
+ })
575
+ if (!ready) {
576
+ log('hosted session failed: the agent loop did not report ready — destroying the sandbox')
577
+ try {
578
+ await sandbox.destroy()
579
+ } catch {
580
+ // best-effort teardown
581
+ }
582
+ return { ...base, ok: false, error: 'agent loop did not become ready' }
583
+ }
584
+ base.ok = true
585
+ log('agent loop ready')
586
+
587
+ // (g) detached mode: the sandbox lives to its --duration timeout (v0). Print
588
+ // exactly when it self-terminates + its id (session-end reaping is G6/ALI-1007).
589
+ if (!opts.tail) {
590
+ const selfTerminateAt = new Date(now() + durationMs).toISOString()
591
+ log(
592
+ `sandbox ${sandbox.id} will self-terminate at ${selfTerminateAt} ` +
593
+ `(v0: detached sandboxes live to their --duration timeout; no session-end reaping yet)`
594
+ )
595
+ return base
596
+ }
597
+
598
+ // (g') attached mode: rotate the bearer before expiry, keep the sandbox alive,
599
+ // stream the tail, and reap the sandbox when the run terminates.
600
+ const rotationIntervalMs = opts.rotationIntervalMs ?? DEFAULT_ROTATION_INTERVAL_MS
601
+ const sandboxCapMs = MAX_DURATION_MINUTES * 60 * 1000
602
+ let sandboxBudgetMs = durationMs
603
+ let tokenExpiresAtMs = now() + ttlMinutes * 60 * 1000
604
+ let stopped = false
605
+ let cancelSleep: (() => void) | null = null
606
+ const stop = (): void => {
607
+ stopped = true
608
+ cancelSleep?.()
609
+ }
610
+ // Abortable delay: stop() clears the pending timer so an attached CLI exits
611
+ // PROMPTLY when the tail ends, instead of blocking on a 55-minute sleep.
612
+ const abortableSleep = (ms: number): Promise<void> =>
613
+ new Promise<void>(resolve => {
614
+ if (stopped) {
615
+ resolve()
616
+ return
617
+ }
618
+ const timer = setTimeout(() => {
619
+ cancelSleep = null
620
+ resolve()
621
+ }, ms)
622
+ cancelSleep = (): void => {
623
+ clearTimeout(timer)
624
+ cancelSleep = null
625
+ resolve()
626
+ }
627
+ })
628
+
629
+ // Best-effort diagnostic run event when rotation is abandoned. Uses a resumed
630
+ // sink (single append) — the run is dying anyway; a burned sequence is fine.
631
+ const emitRotationFailureEvent = async (): Promise<void> => {
632
+ try {
633
+ const diagSink = await resumeRunEventSink({
634
+ apiBaseUrl: opts.apiBaseUrl,
635
+ runId,
636
+ bearer: agentBearer,
637
+ fetchImpl: rawFetch,
638
+ })
639
+ await diagSink.append({
640
+ kind: 'artifact',
641
+ payload: {
642
+ step: 'bearer_rotation_abandoned',
643
+ detail: 'host could not refresh the agent bearer before expiry',
644
+ },
645
+ })
646
+ } catch {
647
+ // best-effort — the bearer may already be unusable
648
+ }
649
+ }
650
+
651
+ const rotate = async (): Promise<void> => {
652
+ let delayMs = rotationIntervalMs
653
+ while (!stopped) {
654
+ await abortableSleep(delayMs)
655
+ if (stopped) break
656
+ try {
657
+ const fresh = await postJson(
658
+ opts.fetcher,
659
+ `/api/cli/sessions/${encodeURIComponent(sessionId)}/agent-token`,
660
+ { ttlMinutes }
661
+ )
662
+ const freshToken = requireString(fresh.token, 'rotated agent token')
663
+ agentBearer = freshToken
664
+ await writeBearerFile(sandbox, bearerRel, freshToken)
665
+ tokenExpiresAtMs = now() + ttlMinutes * 60 * 1000
666
+ delayMs = rotationIntervalMs
667
+ // The DURABLE credential-use audit for this rotation is the fresh
668
+ // agent_session_tokens row minted above (created_at/expires_at/revoked_at
669
+ // per mint). We do NOT emit a run event: the in-sandbox loop is the sole
670
+ // writer on this run's event/sequence space (single-writer invariant, see
671
+ // app/api/cli/workbench-runs/[id]/events/route.ts) and a host-side append
672
+ // here races the loop → 409 → a healthy run killed. Operator visibility is
673
+ // the local stderr log below.
674
+ log('rotated agent bearer (file overwritten)')
675
+ // Keepalive: extend the sandbox by one rotation interval each rotation
676
+ // while attached (SDK supports extendTimeout(ms)), bounded by the 24h cap
677
+ // so a long attached session outlives the initial --duration window.
678
+ if (sandbox.extendTimeout && sandboxBudgetMs + rotationIntervalMs <= sandboxCapMs) {
679
+ try {
680
+ await sandbox.extendTimeout(rotationIntervalMs)
681
+ sandboxBudgetMs += rotationIntervalMs
682
+ log(`extended sandbox timeout (+${Math.round(rotationIntervalMs / 1000)}s, budget ${Math.round(sandboxBudgetMs / 60000)}m)`)
683
+ } catch (error) {
684
+ log(`sandbox extendTimeout failed: ${error instanceof Error ? error.message : String(error)}`)
685
+ }
686
+ }
687
+ } catch (error) {
688
+ // No run-event emit on failure either: the host is not the run's event
689
+ // writer (see the successful-rotation note above). Failure visibility is
690
+ // this local log plus, if the token will lapse, emitRotationFailureEvent()
691
+ // below — which fires ONLY on the terminal/dying path, not the live stream.
692
+ log(`bearer rotation failed: ${error instanceof Error ? error.message : String(error)}`)
693
+ // Fast-retry on the short interval — never silently ride the token to
694
+ // expiry. Give up loudly if the current token will lapse before another
695
+ // retry could land (leave-to-expire is the documented v0 behavior).
696
+ if (now() + ROTATION_FAST_RETRY_MS >= tokenExpiresAtMs) {
697
+ log(
698
+ 'CRITICAL: cannot refresh the agent bearer before it expires; the hosted run will ' +
699
+ 'lose its Orizu credential and finish when the token lapses. Reconnect with `orizu run tail`.'
700
+ )
701
+ await emitRotationFailureEvent()
702
+ stop()
703
+ break
704
+ }
705
+ delayMs = ROTATION_FAST_RETRY_MS
706
+ }
707
+ }
708
+ }
709
+ const tailer =
710
+ opts.tailImpl ??
711
+ (async (): Promise<void> => {
712
+ // Production default is `orizu run tail` (existing command); the caller
713
+ // wires it. Without an injected tailer we simply return so the CLI can
714
+ // hand off to the standard tail command.
715
+ })
716
+ const rotationTask = rotate()
717
+ try {
718
+ await tailer({ fetcher: opts.fetcher, runId, onEvent: opts.onTailEvent })
719
+ } finally {
720
+ stop()
721
+ await rotationTask
722
+ // Reap: the tail returns once the run reaches terminal — destroy the box.
723
+ try {
724
+ await sandbox.destroy()
725
+ log(`sandbox ${sandbox.id} destroyed (run terminal)`)
726
+ } catch (error) {
727
+ log(`sandbox teardown failed: ${error instanceof Error ? error.message : String(error)}`)
728
+ }
729
+ }
730
+
731
+ return base
732
+ }
733
+
734
+ // -- Thin CLI dispatch (`session start --hosted` + `internal hosted-loop`) -----
735
+
736
+ export interface HostedCommandIo {
737
+ print: (line: string) => void
738
+ printErr?: (line: string) => void
739
+ json?: boolean
740
+ }
741
+
742
+ function argVal(args: readonly string[], flag: string): string | null {
743
+ const index = args.indexOf(flag)
744
+ if (index === -1 || index + 1 >= args.length) return null
745
+ const value = args[index + 1]
746
+ return value.startsWith('--') ? null : value
747
+ }
748
+
749
+ function hasFlag(args: readonly string[], flag: string): boolean {
750
+ return args.includes(flag)
751
+ }
752
+
753
+ function numFlag(args: readonly string[], flag: string): number | undefined {
754
+ const raw = argVal(args, flag)
755
+ if (!raw) return undefined
756
+ const parsed = Number(raw)
757
+ return Number.isFinite(parsed) ? parsed : undefined
758
+ }
759
+
760
+ const HOSTED_USAGE =
761
+ 'Usage: orizu session start --hosted --task "<prompt>" [--duration <min> (default 60, max 1440)] ' +
762
+ '[--model <provider/model>] [--project <team/project>] [--provider vercel|local-sim] ' +
763
+ '[--image <orizu-hosted-runtime:tag> (pre-baked VCR image; default env ORIZU_HOSTED_IMAGE)] ' +
764
+ '[--snapshot <snapshot-id> (pre-baked Vercel snapshot, zero-Docker; default env ORIZU_HOSTED_SNAPSHOT; ' +
765
+ 'mutually exclusive with --image)] [--tail]\n' +
766
+ 'Note (v0): if you disconnect, the agent token expires within its TTL (<=60m) and the run finishes ' +
767
+ 'cleanly; reconnect with `orizu run tail --run <id>`. The sandbox timeout is independent (up to 24h).'
768
+
769
+ /**
770
+ * Dispatch `orizu internal hosted-loop` (in-sandbox) and `orizu session start
771
+ * --hosted` (host-side). Kept out of index.ts so the CLI entrypoint stays a thin
772
+ * dispatcher (ALI-976 ratchet).
773
+ */
774
+ export async function hostedCommand(args: string[], io: HostedCommandIo): Promise<number> {
775
+ const positional = args.filter(arg => !arg.startsWith('--'))
776
+ if (positional[0] === 'internal') {
777
+ if (positional[1] === 'hosted-loop') {
778
+ return hostedLoopCommand(args, io)
779
+ }
780
+ io.printErr?.('Usage: orizu internal hosted-loop --context <path>')
781
+ return 1
782
+ }
783
+
784
+ const task = argVal(args, '--task')
785
+ if (!task) {
786
+ io.printErr?.(HOSTED_USAGE)
787
+ return 1
788
+ }
789
+ const workspaceId = argVal(args, '--workspace') ?? attachedWorkspaceId(getWorkspaceRoot(process.cwd()))
790
+ if (!workspaceId) {
791
+ io.printErr?.('No attached workspace — run `orizu workspace sync` first, or pass --workspace <id>.')
792
+ return 1
793
+ }
794
+ const tail = hasFlag(args, '--tail')
795
+ const providerKind = (argVal(args, '--provider') ?? 'vercel').toLowerCase()
796
+ const provider: SandboxProvider =
797
+ providerKind === 'local-sim' ? createLocalSimProvider() : createVercelProvider()
798
+
799
+ const hostFetcher: HostFetcher = (path, init) => authedFetch(path, init)
800
+ const result = await startHostedSession({
801
+ fetcher: hostFetcher,
802
+ apiBaseUrl: resolveBaseUrl(),
803
+ provider,
804
+ workspaceId,
805
+ task,
806
+ // Resolve the per-team additive egress domains at create (best-effort — a
807
+ // failure falls back to the base allowlist only). Human-scoped read.
808
+ resolveExtraEgressDomains: async (id: string): Promise<readonly string[]> => {
809
+ const response = await hostFetcher(`/api/cli/workspaces/${encodeURIComponent(id)}/egress-allowlist`)
810
+ if (!response.ok) return []
811
+ const data = (await response.json().catch(() => ({}))) as { extraDomains?: unknown }
812
+ return Array.isArray(data.extraDomains) ? data.extraDomains.filter((d): d is string => typeof d === 'string') : []
813
+ },
814
+ projectSlug: argVal(args, '--project'),
815
+ model: argVal(args, '--model') ?? undefined,
816
+ durationMinutes: numFlag(args, '--duration'),
817
+ runtime: argVal(args, '--runtime') ?? undefined,
818
+ // Pre-baked runtime (ALI-1017): flag wins, else env. Image = Docker/VCR path,
819
+ // snapshot = zero-Docker path; both flip the install-skip flag. startHostedSession
820
+ // enforces mutual exclusion.
821
+ hostedImage: argVal(args, '--image') ?? process.env.ORIZU_HOSTED_IMAGE ?? undefined,
822
+ hostedSnapshot: argVal(args, '--snapshot') ?? process.env.ORIZU_HOSTED_SNAPSHOT ?? undefined,
823
+ tail,
824
+ modelApiKey: process.env.ANTHROPIC_API_KEY || undefined,
825
+ logLine: line => io.print(line),
826
+ onTailEvent: event =>
827
+ io.print(io.json ? JSON.stringify(event) : `#${String(event.sequence ?? '?')} ${String(event.eventType ?? '')}`),
828
+ tailImpl: tail
829
+ ? async ({ fetcher, runId, onEvent }) => {
830
+ await tailWorkbenchRun({ fetcher, runId, onEvent: event => onEvent?.(event as Record<string, unknown>) })
831
+ }
832
+ : undefined,
833
+ })
834
+
835
+ if (io.json) {
836
+ io.print(JSON.stringify({ ok: result.ok, sessionId: result.sessionId, runId: result.runId, sandboxId: result.sandboxId, error: result.error }))
837
+ } else if (result.ok) {
838
+ io.print(`hosted session ready: session ${result.sessionId}, run ${result.runId}`)
839
+ if (!tail) io.print(`stream it: orizu run tail --run ${result.runId}`)
840
+ } else {
841
+ io.print(`hosted session failed: ${result.error ?? 'unknown error'}`)
842
+ }
843
+ return result.ok ? 0 : 1
844
+ }