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.
- package/README.md +22 -13
- package/dist/artifact-archive.js +152 -0
- package/dist/connectors-cli.js +80 -0
- package/dist/credentials.js +4 -1
- package/dist/daytona-slice-rehearsal.js +255 -0
- package/dist/daytona-slice.js +441 -0
- package/dist/egress-allowlist-cli.js +132 -0
- package/dist/egress-policy.js +159 -0
- package/dist/git-credential.js +131 -0
- package/dist/github-setup.js +248 -0
- package/dist/help.js +271 -16
- package/dist/hosted-bootstrap-rehearsal.js +235 -0
- package/dist/hosted-bootstrap.js +365 -0
- package/dist/hosted-broker-server.js +220 -0
- package/dist/hosted-git-http-server.js +136 -0
- package/dist/hosted-harness-claude.js +493 -0
- package/dist/hosted-harness-opencode.js +729 -0
- package/dist/hosted-harness.js +60 -0
- package/dist/hosted-loop.js +477 -0
- package/dist/hosted-run-event-sink.js +499 -0
- package/dist/hosted-runtime-assets.js +216 -0
- package/dist/hosted-session-cli.js +642 -0
- package/dist/index.js +855 -237
- package/dist/kill-switch-cli.js +89 -0
- package/dist/manifests-cli.js +143 -0
- package/dist/preview-runtime.js +4 -1
- package/dist/sandbox-provider.js +238 -0
- package/dist/secret-redaction.js +112 -0
- package/dist/session-timeline.js +103 -0
- package/dist/setup-prompt.js +4 -2
- package/dist/skill-installer.js +9 -1
- package/dist/smoke.js +250 -0
- package/dist/task-assignment-manifest.js +39 -2
- package/dist/vercel-sandbox-provider.js +260 -0
- package/dist/workbench-cli.js +493 -0
- package/dist/workspace-sync.js +924 -0
- package/dist/workspace.js +868 -57
- package/package.json +4 -1
- package/src/artifact-archive.ts +182 -0
- package/src/connectors-cli.ts +115 -0
- package/src/credentials.ts +6 -2
- package/src/daytona-slice-rehearsal.ts +320 -0
- package/src/daytona-slice.ts +598 -0
- package/src/egress-allowlist-cli.ts +147 -0
- package/src/egress-policy.ts +188 -0
- package/src/git-credential.ts +155 -0
- package/src/github-setup.ts +396 -0
- package/src/help.ts +272 -16
- package/src/hosted-bootstrap-rehearsal.ts +312 -0
- package/src/hosted-bootstrap.ts +571 -0
- package/src/hosted-broker-server.ts +278 -0
- package/src/hosted-git-http-server.ts +164 -0
- package/src/hosted-harness-claude.ts +621 -0
- package/src/hosted-harness-opencode.ts +823 -0
- package/src/hosted-harness.ts +124 -0
- package/src/hosted-loop.ts +680 -0
- package/src/hosted-run-event-sink.ts +632 -0
- package/src/hosted-runtime-assets.ts +272 -0
- package/src/hosted-session-cli.ts +844 -0
- package/src/index.ts +987 -296
- package/src/kill-switch-cli.ts +113 -0
- package/src/manifests-cli.ts +168 -0
- package/src/preview-runtime.ts +14 -9
- package/src/sandbox-provider.ts +440 -0
- package/src/secret-redaction.ts +124 -0
- package/src/session-timeline.ts +152 -0
- package/src/setup-prompt.ts +4 -2
- package/src/skill-installer.ts +13 -1
- package/src/smoke.ts +342 -0
- package/src/task-assignment-manifest.ts +70 -2
- package/src/vercel-sandbox-provider.ts +388 -0
- package/src/workbench-cli.ts +701 -0
- package/src/workspace-sync.ts +1148 -0
- package/src/workspace.ts +1051 -64
- package/vendor/skills/orizu-cli/SKILL.md +3 -1
- 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
|
+
}
|