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