orizu 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +22 -13
  2. package/dist/artifact-archive.js +152 -0
  3. package/dist/connectors-cli.js +80 -0
  4. package/dist/credentials.js +4 -1
  5. package/dist/daytona-slice-rehearsal.js +255 -0
  6. package/dist/daytona-slice.js +441 -0
  7. package/dist/egress-allowlist-cli.js +132 -0
  8. package/dist/egress-policy.js +159 -0
  9. package/dist/git-credential.js +131 -0
  10. package/dist/github-setup.js +226 -0
  11. package/dist/help.js +276 -21
  12. package/dist/hosted-bootstrap-rehearsal.js +235 -0
  13. package/dist/hosted-bootstrap.js +365 -0
  14. package/dist/hosted-broker-server.js +220 -0
  15. package/dist/hosted-git-http-server.js +136 -0
  16. package/dist/hosted-harness-claude.js +493 -0
  17. package/dist/hosted-harness-opencode.js +729 -0
  18. package/dist/hosted-harness.js +60 -0
  19. package/dist/hosted-loop.js +477 -0
  20. package/dist/hosted-run-event-sink.js +499 -0
  21. package/dist/hosted-runtime-assets.js +216 -0
  22. package/dist/hosted-session-cli.js +642 -0
  23. package/dist/index.js +945 -258
  24. package/dist/kill-switch-cli.js +89 -0
  25. package/dist/manifests-cli.js +143 -0
  26. package/dist/preview-runtime.js +4 -1
  27. package/dist/sandbox-provider.js +238 -0
  28. package/dist/secret-redaction.js +112 -0
  29. package/dist/session-timeline.js +103 -0
  30. package/dist/setup-prompt.js +4 -2
  31. package/dist/skill-installer.js +9 -1
  32. package/dist/smoke.js +250 -0
  33. package/dist/task-assignment-manifest.js +96 -0
  34. package/dist/vercel-sandbox-provider.js +260 -0
  35. package/dist/workbench-cli.js +493 -0
  36. package/dist/workspace-sync.js +924 -0
  37. package/dist/workspace.js +868 -57
  38. package/package.json +4 -1
  39. package/src/artifact-archive.ts +182 -0
  40. package/src/connectors-cli.ts +115 -0
  41. package/src/credentials.ts +6 -2
  42. package/src/daytona-slice-rehearsal.ts +320 -0
  43. package/src/daytona-slice.ts +598 -0
  44. package/src/egress-allowlist-cli.ts +147 -0
  45. package/src/egress-policy.ts +188 -0
  46. package/src/git-credential.ts +155 -0
  47. package/src/github-setup.ts +366 -0
  48. package/src/help.ts +277 -21
  49. package/src/hosted-bootstrap-rehearsal.ts +312 -0
  50. package/src/hosted-bootstrap.ts +571 -0
  51. package/src/hosted-broker-server.ts +278 -0
  52. package/src/hosted-git-http-server.ts +164 -0
  53. package/src/hosted-harness-claude.ts +621 -0
  54. package/src/hosted-harness-opencode.ts +823 -0
  55. package/src/hosted-harness.ts +124 -0
  56. package/src/hosted-loop.ts +680 -0
  57. package/src/hosted-run-event-sink.ts +632 -0
  58. package/src/hosted-runtime-assets.ts +272 -0
  59. package/src/hosted-session-cli.ts +844 -0
  60. package/src/index.ts +1102 -317
  61. package/src/kill-switch-cli.ts +113 -0
  62. package/src/manifests-cli.ts +168 -0
  63. package/src/preview-runtime.ts +14 -9
  64. package/src/sandbox-provider.ts +440 -0
  65. package/src/secret-redaction.ts +124 -0
  66. package/src/session-timeline.ts +152 -0
  67. package/src/setup-prompt.ts +4 -2
  68. package/src/skill-installer.ts +13 -1
  69. package/src/smoke.ts +342 -0
  70. package/src/task-assignment-manifest.ts +164 -0
  71. package/src/vercel-sandbox-provider.ts +388 -0
  72. package/src/workbench-cli.ts +701 -0
  73. package/src/workspace-sync.ts +1148 -0
  74. package/src/workspace.ts +1051 -64
  75. package/vendor/skills/orizu-cli/SKILL.md +8 -4
  76. package/vendor/skills/orizu-cli/references/cli-reference.md +12 -4
@@ -0,0 +1,598 @@
1
+ /**
2
+ * Daytona thin-slice load path — pure step logic (ALI-973 / WS-F).
3
+ *
4
+ * smoke.ts-style: an importable eight-step workflow with an injected fetcher
5
+ * factory + an injected `SandboxProvider` (ADR-003 seam). The `scripts/daytona-
6
+ * workbench-slice.mjs` driver supplies a live-server fetcher + a real provider;
7
+ * the unit test and the local-sim rehearsal supply an in-memory API fake + the
8
+ * local-sim provider. No process/argv/print here.
9
+ *
10
+ * The slice proves the full hosted-agent load path against the GitHub-hosted
11
+ * repo, deliberately thin — its purpose is to pin the token-broker API shape
12
+ * before Phase 3:
13
+ * 1. resolve workspace + start a session on its own repo branch,
14
+ * 2. create a sandbox via the provider,
15
+ * 3. mint a READ token and clone the session branch INSIDE the sandbox,
16
+ * 4. read + parse the root manifests (orizu.team.json, schemaVersion),
17
+ * 5. resolve one object ref WITHOUT committing bytes (falls back to proving
18
+ * `.orizu/` is the git-ignored materialization target when the exemplar
19
+ * workspace carries no object refs),
20
+ * 6. write a small generated file + commit inside the sandbox,
21
+ * 7. mint a WRITE token, push the session branch, revoke BOTH tokens,
22
+ * 8. token-hygiene sweep (no token residue on the sandbox fs), then finish-
23
+ * branch → approve → apply via the API, then destroy the sandbox.
24
+ */
25
+
26
+ import { WORKSPACE_SCHEMA_VERSION } from './workspace.js'
27
+ import type { SandboxProvider, SandboxProviderKind, SandboxSession } from './sandbox-provider.js'
28
+
29
+ export type SliceFetcher = (path: string, init?: RequestInit) => Promise<Response>
30
+
31
+ export interface SliceConfig {
32
+ teamSlug: string
33
+ projectSlug: string
34
+ workspaceSlug: string
35
+ }
36
+
37
+ export interface SliceStepPlan {
38
+ index: number
39
+ key: string
40
+ title: string
41
+ plan: string
42
+ }
43
+
44
+ export interface SliceStepReport {
45
+ index: number
46
+ key: string
47
+ title: string
48
+ ok: boolean
49
+ detail: string
50
+ durationMs: number
51
+ }
52
+
53
+ export interface HygieneFinding {
54
+ location: string
55
+ marker: string
56
+ }
57
+
58
+ export interface SliceReport {
59
+ ok: boolean
60
+ provider: SandboxProviderKind
61
+ steps: SliceStepReport[]
62
+ failedStep: SliceStepReport | null
63
+ totalMs: number
64
+ hygiene: { clean: boolean; findings: HygieneFinding[] } | null
65
+ ids: {
66
+ workspaceId?: string
67
+ sessionId?: string
68
+ branch?: string
69
+ repo?: string
70
+ sandboxId?: string
71
+ readMintId?: string
72
+ writeMintId?: string
73
+ manifestId?: string
74
+ commitSha?: string
75
+ mergeOutcome?: string
76
+ /** Set on the error path when a session branch was pushed but the run did
77
+ * not reach the merge — names the branch left on the remote for cleanup. */
78
+ orphanedBranch?: string
79
+ }
80
+ }
81
+
82
+ export interface SliceHooks {
83
+ /** Test seam: run after commit/push, immediately BEFORE the hygiene sweep.
84
+ * Used to plant a credential so the sweep's detection power is provable. */
85
+ beforeHygiene?: (session: SandboxSession, ctx: { repoPath: string }) => Promise<void> | void
86
+ }
87
+
88
+ export interface RunSliceOptions {
89
+ config: SliceConfig
90
+ makeFetcher: () => SliceFetcher
91
+ provider: SandboxProvider
92
+ /** Map the broker's repo full-name to a clone URL the provider understands.
93
+ * Live: `https://github.com/<repo>.git`. Local-sim: the bare repo path. */
94
+ resolveCloneUrl: (repo: string) => string
95
+ now?: () => number
96
+ onStep?: (step: SliceStepReport) => void
97
+ keep?: boolean
98
+ generatedFileName?: string
99
+ generatedFileBody?: string
100
+ hooks?: SliceHooks
101
+ }
102
+
103
+ const REPO_DIR = 'repo'
104
+ const DEFAULT_GENERATED_FILE = 'orizu-slice-note.md'
105
+
106
+ const STEP_PLAN: ReadonlyArray<{ key: string; title: string; plan: string }> = [
107
+ { key: 'session', title: 'Resolve workspace + start session branch', plan: 'POST /api/cli/workspaces (idempotent) then POST .../sessions { repoBranch }' },
108
+ { key: 'sandbox', title: 'Create sandbox via provider', plan: 'provider.createSandbox()' },
109
+ { key: 'clone', title: 'Mint read token + clone session branch', plan: 'POST .../repo-token { read } then sandbox.gitClone(branch)' },
110
+ { key: 'manifests', title: 'Read + parse root manifests', plan: 'sandbox.readFile(orizu.team.json) — schemaVersion check' },
111
+ { key: 'objectref', title: 'Resolve object ref (no bytes in git)', plan: 'materialize into .orizu/ (git-ignored) — bulk bytes stay out of git' },
112
+ { key: 'write', title: 'Write + commit generated file', plan: 'sandbox.writeFile + git commit inside the sandbox' },
113
+ { key: 'push', title: 'Mint write token + push; revoke tokens', plan: 'POST .../repo-token { write }, gitPush, DELETE both mints' },
114
+ { key: 'promote', title: 'Hygiene sweep + finish-branch + approve + apply + destroy', plan: 'sweep (no residue) → finish-branch → approve → apply → destroy' },
115
+ ]
116
+
117
+ export function planSliceSteps(): SliceStepPlan[] {
118
+ return STEP_PLAN.map((step, i) => ({ index: i + 1, key: step.key, title: step.title, plan: step.plan }))
119
+ }
120
+
121
+ function titleFor(key: string): string {
122
+ return STEP_PLAN.find(step => step.key === key)?.title ?? key
123
+ }
124
+
125
+ function indexFor(key: string): number {
126
+ return STEP_PLAN.findIndex(step => step.key === key) + 1
127
+ }
128
+
129
+ // -- Provider selection ------------------------------------------------------
130
+
131
+ export interface ProviderSelectionInput {
132
+ providerFlag?: string | null
133
+ hasDaytonaKey: boolean
134
+ }
135
+
136
+ /**
137
+ * Resolve the provider kind. An explicit `--provider` flag always wins; with no
138
+ * flag the default is `local-sim` when DAYTONA_API_KEY is absent, and `daytona`
139
+ * when it is present (the live run is founder-gated by that key).
140
+ */
141
+ export function selectProviderKind(input: ProviderSelectionInput): SandboxProviderKind {
142
+ const flag = input.providerFlag?.trim().toLowerCase()
143
+ if (flag === 'daytona' || flag === 'local-sim') {
144
+ return flag
145
+ }
146
+ if (flag) {
147
+ throw new Error(`Unknown --provider "${input.providerFlag}". Use "daytona" or "local-sim".`)
148
+ }
149
+ return input.hasDaytonaKey ? 'daytona' : 'local-sim'
150
+ }
151
+
152
+ // -- HTTP helpers ------------------------------------------------------------
153
+
154
+ async function readBody(response: Response): Promise<Record<string, unknown>> {
155
+ try {
156
+ return (await response.json()) as Record<string, unknown>
157
+ } catch {
158
+ return {}
159
+ }
160
+ }
161
+
162
+ async function errorMessage(response: Response): Promise<string> {
163
+ const body = await readBody(response.clone())
164
+ const error = typeof body.error === 'string' ? body.error : null
165
+ return error || response.statusText || String(response.status)
166
+ }
167
+
168
+ async function postJson(
169
+ fetcher: SliceFetcher,
170
+ path: string,
171
+ payload: Record<string, unknown>
172
+ ): Promise<{ status: number; body: Record<string, unknown> }> {
173
+ const response = await fetcher(path, {
174
+ method: 'POST',
175
+ headers: { 'Content-Type': 'application/json' },
176
+ body: JSON.stringify(payload),
177
+ })
178
+ if (!response.ok) {
179
+ throw new Error(`${path} failed (${response.status}): ${await errorMessage(response)}`)
180
+ }
181
+ return { status: response.status, body: await readBody(response) }
182
+ }
183
+
184
+ async function deleteJson(
185
+ fetcher: SliceFetcher,
186
+ path: string,
187
+ payload: Record<string, unknown>
188
+ ): Promise<void> {
189
+ const response = await fetcher(path, {
190
+ method: 'DELETE',
191
+ headers: { 'Content-Type': 'application/json' },
192
+ body: JSON.stringify(payload),
193
+ })
194
+ if (!response.ok) {
195
+ throw new Error(`${path} (revoke) failed (${response.status}): ${await errorMessage(response)}`)
196
+ }
197
+ }
198
+
199
+ function requireString(value: unknown, label: string): string {
200
+ if (typeof value !== 'string' || value.length === 0) {
201
+ throw new Error(`${label} missing from response`)
202
+ }
203
+ return value
204
+ }
205
+
206
+ function requireObject(value: unknown, label: string): Record<string, unknown> {
207
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
208
+ throw new Error(`${label} missing from response`)
209
+ }
210
+ return value as Record<string, unknown>
211
+ }
212
+
213
+ interface RepoTokenMint {
214
+ token: string
215
+ mintId: string
216
+ repo: string
217
+ expiresAt: string | null
218
+ }
219
+
220
+ async function mintRepoToken(
221
+ fetcher: SliceFetcher,
222
+ workspaceId: string,
223
+ purpose: 'read' | 'write',
224
+ sessionId: string
225
+ ): Promise<RepoTokenMint> {
226
+ const { body } = await postJson(fetcher, `/api/cli/workspaces/${encodeURIComponent(workspaceId)}/repo-token`, {
227
+ purpose,
228
+ sessionId,
229
+ })
230
+ return {
231
+ token: requireString(body.token, `${purpose} token`),
232
+ mintId: requireString(body.mintId, `${purpose} mintId`),
233
+ repo: requireString(body.repo, `${purpose} repo`),
234
+ expiresAt: typeof body.expiresAt === 'string' ? body.expiresAt : null,
235
+ }
236
+ }
237
+
238
+ // -- Token-hygiene sweep -----------------------------------------------------
239
+
240
+ export interface HygieneProbe {
241
+ location: string
242
+ /** Shell command whose stdout is scanned for tokens + markers. */
243
+ command: string
244
+ }
245
+
246
+ /**
247
+ * The list of places a short-lived git token can leak inside a sandbox after a
248
+ * transient clone+push. List-driven so it is trivial to extend. `${repo}` is
249
+ * substituted with the repo path. Ordered from the repo-local stores outward to
250
+ * global config, the credential cache, the reflog, shell history, and the live
251
+ * process listing / argv — the exact surfaces the Daytona-support question names.
252
+ */
253
+ export function tokenLeakProbes(repoPath: string): HygieneProbe[] {
254
+ const R = repoPath
255
+ return [
256
+ { location: `${R}/.git/config`, command: `cat ${R}/.git/config 2>/dev/null || true` },
257
+ { location: `${R}/.git/logs (reflog)`, command: `find ${R}/.git/logs -type f -exec cat {} + 2>/dev/null || true` },
258
+ { location: '~/.git-credentials', command: 'cat "$HOME/.git-credentials" 2>/dev/null || true' },
259
+ { location: '~/.gitconfig (global)', command: 'cat "$HOME/.gitconfig" 2>/dev/null || true' },
260
+ { location: 'git credential cache/store', command: 'cat "$HOME/.cache/git/credential/"* "${XDG_CACHE_HOME:-$HOME/.cache}/git/credential/"* 2>/dev/null || true' },
261
+ { location: 'shell history', command: 'cat "$HOME/.bash_history" "$HOME/.zsh_history" 2>/dev/null || true' },
262
+ { location: 'process listing / argv', command: 'ps -eo args 2>/dev/null || ps aux 2>/dev/null || true' },
263
+ { location: 'process env', command: 'env' },
264
+ ]
265
+ }
266
+
267
+ /**
268
+ * Grep the sandbox filesystem + process table for token values and the
269
+ * `x-access-token` marker across every known leak surface (see `tokenLeakProbes`).
270
+ * Returns every finding; an empty array means the transient operations left no
271
+ * residue. This is the load-bearing check the whole slice exists to make.
272
+ *
273
+ * NOTE (local-sim): the reflog/config/credential-store/history probes run
274
+ * meaningfully in local-sim, but `ps`/argv coverage is only decisive on the live
275
+ * Daytona run (local-sim runs the git clone via a child process on the host, not
276
+ * inside an isolated PID namespace). The WS-F memo marks that probe live-only.
277
+ */
278
+ export async function sweepForTokenResidue(
279
+ session: SandboxSession,
280
+ opts: { tokens: string[]; repoPath: string; markers?: string[]; probes?: HygieneProbe[] }
281
+ ): Promise<HygieneFinding[]> {
282
+ const markers = opts.markers ?? ['x-access-token']
283
+ const tokens = opts.tokens.filter(token => token.length > 0)
284
+ const probes = opts.probes ?? tokenLeakProbes(opts.repoPath)
285
+
286
+ const findings: HygieneFinding[] = []
287
+ for (const probe of probes) {
288
+ const { stdout } = await session.exec(probe.command)
289
+ for (const token of tokens) {
290
+ if (stdout.includes(token)) {
291
+ findings.push({ location: probe.location, marker: 'token-value' })
292
+ }
293
+ }
294
+ for (const marker of markers) {
295
+ if (stdout.includes(marker)) {
296
+ findings.push({ location: probe.location, marker })
297
+ }
298
+ }
299
+ }
300
+ return findings
301
+ }
302
+
303
+ // -- The workflow ------------------------------------------------------------
304
+
305
+ /**
306
+ * Run a command in the sandbox and throw on a non-zero (or missing) exit code,
307
+ * surfacing captured stderr so a live git failure does not read as success.
308
+ */
309
+ async function execChecked(
310
+ session: SandboxSession,
311
+ command: string,
312
+ opts: { cwd?: string; label: string }
313
+ ): Promise<{ stdout: string; exitCode: number; stderr?: string }> {
314
+ const result = await session.exec(command, opts.cwd ? { cwd: opts.cwd } : undefined)
315
+ if (result.exitCode !== 0) {
316
+ const detail = (result.stderr && result.stderr.trim()) || result.stdout.trim() || `exit ${result.exitCode}`
317
+ throw new Error(`${opts.label} failed inside the sandbox: ${detail}`)
318
+ }
319
+ return result
320
+ }
321
+
322
+ export async function runDaytonaSlice(opts: RunSliceOptions): Promise<SliceReport> {
323
+ const { config, provider } = opts
324
+ const fetcher = opts.makeFetcher()
325
+ const now = opts.now ?? (() => Date.now())
326
+ const steps: SliceStepReport[] = []
327
+ const ids: SliceReport['ids'] = {}
328
+ const generatedName = opts.generatedFileName ?? DEFAULT_GENERATED_FILE
329
+ const generatedBody =
330
+ opts.generatedFileBody ??
331
+ `# Orizu Daytona slice\n\nGenerated inside the sandbox by the WS-F load-path slice.\nprovider=${provider.kind}\n`
332
+
333
+ let hygiene: SliceReport['hygiene'] = null
334
+ let session: SandboxSession | null = null
335
+ // Token values live only in these locals — never on the report, so a
336
+ // serialized SliceReport can never carry a live credential.
337
+ let readToken = ''
338
+ let writeToken = ''
339
+ const startedAll = now()
340
+
341
+ const step = async (
342
+ key: string,
343
+ fn: () => Promise<string>
344
+ ): Promise<void> => {
345
+ const startedAt = now()
346
+ const detail = await fn()
347
+ const report: SliceStepReport = {
348
+ index: indexFor(key),
349
+ key,
350
+ title: titleFor(key),
351
+ ok: true,
352
+ detail,
353
+ durationMs: now() - startedAt,
354
+ }
355
+ steps.push(report)
356
+ opts.onStep?.(report)
357
+ }
358
+
359
+ try {
360
+ // 1 — Workspace resolve-or-attach, then a session on its own repo branch.
361
+ await step('session', async () => {
362
+ const workspace = await postJson(fetcher, '/api/cli/workspaces', {
363
+ teamSlug: config.teamSlug,
364
+ name: config.workspaceSlug,
365
+ slug: config.workspaceSlug,
366
+ })
367
+ const workspaceId = requireString(requireObject(workspace.body.workspace, 'workspace').id, 'workspace id')
368
+ ids.workspaceId = workspaceId
369
+
370
+ const sessionResp = await postJson(fetcher, `/api/cli/workspaces/${encodeURIComponent(workspaceId)}/sessions`, {
371
+ projectSlug: config.projectSlug,
372
+ repoBranch: true,
373
+ clientInfo: { source: 'orizu-daytona-slice', provider: provider.kind },
374
+ })
375
+ const sessionRow = requireObject(sessionResp.body.session, 'session')
376
+ const sessionId = requireString(sessionRow.id, 'session id')
377
+ const branch = requireString(sessionRow.repoBranch, 'session repoBranch')
378
+ ids.sessionId = sessionId
379
+ ids.branch = branch
380
+ return `workspace ${workspaceId}; session ${sessionId} on ${branch}`
381
+ })
382
+
383
+ // 2 — Create the sandbox via the provider seam.
384
+ await step('sandbox', async () => {
385
+ session = await provider.createSandbox({ language: 'typescript', labels: { orizu: 'daytona-slice' } })
386
+ ids.sandboxId = session.id
387
+ return `${provider.kind} sandbox ${session.id}`
388
+ })
389
+
390
+ // 3 — Mint a READ token and clone the session branch INSIDE the sandbox.
391
+ await step('clone', async () => {
392
+ const box = session as SandboxSession
393
+ const read = await mintRepoToken(fetcher, ids.workspaceId!, 'read', ids.sessionId!)
394
+ ids.readMintId = read.mintId
395
+ ids.repo = read.repo
396
+ readToken = read.token
397
+ await box.gitClone({
398
+ url: opts.resolveCloneUrl(read.repo),
399
+ path: REPO_DIR,
400
+ branch: ids.branch,
401
+ username: 'x-access-token',
402
+ password: read.token,
403
+ })
404
+ return `cloned ${read.repo}@${ids.branch} with a read mint (${read.mintId})`
405
+ })
406
+
407
+ // 4 — Read + parse the root manifests.
408
+ await step('manifests', async () => {
409
+ const box = session as SandboxSession
410
+ const manifestPath = `${REPO_DIR}/orizu.team.json`
411
+ if (!(await box.fileExists(manifestPath))) {
412
+ throw new Error('orizu.team.json is missing from the cloned repo root')
413
+ }
414
+ const raw = await box.readFile(manifestPath)
415
+ let parsed: Record<string, unknown>
416
+ try {
417
+ parsed = JSON.parse(raw) as Record<string, unknown>
418
+ } catch (error) {
419
+ throw new Error(`orizu.team.json did not parse: ${error instanceof Error ? error.message : String(error)}`)
420
+ }
421
+ if (parsed.schemaVersion !== WORKSPACE_SCHEMA_VERSION) {
422
+ throw new Error(`orizu.team.json schemaVersion is ${String(parsed.schemaVersion)}, expected ${WORKSPACE_SCHEMA_VERSION}`)
423
+ }
424
+ return `orizu.team.json parsed; schemaVersion=${WORKSPACE_SCHEMA_VERSION} kind=${String(parsed.kind)}`
425
+ })
426
+
427
+ // 5 — Resolve one object ref WITHOUT committing bytes to git. The exemplar
428
+ // workspace carries no object refs (canonical.objectRef=null), so we prove
429
+ // the invariant instead: `.orizu/` is the git-ignored materialization target.
430
+ await step('objectref', async () => {
431
+ const box = session as SandboxSession
432
+ const gitignore = await box.readFile(`${REPO_DIR}/.gitignore`)
433
+ if (!gitignore.includes('.orizu')) {
434
+ throw new Error('.gitignore does not ignore .orizu/ — bulk bytes could leak into git')
435
+ }
436
+ // Materialize a would-be object payload into .orizu/ and confirm git
437
+ // ignores it (bytes stay out of the commit).
438
+ await execChecked(box, 'mkdir -p .orizu && printf "object-ref-materialization" > .orizu/slice-object.bin', {
439
+ cwd: REPO_DIR,
440
+ label: 'object-ref materialization',
441
+ })
442
+ const status = await execChecked(box, 'git status --porcelain', { cwd: REPO_DIR, label: 'git status' })
443
+ if (status.stdout.includes('.orizu')) {
444
+ throw new Error('.orizu/ payload showed up in git status — it must be ignored')
445
+ }
446
+ return 'no object refs in exemplar; verified .orizu/ is the git-ignored materialization target'
447
+ })
448
+
449
+ // 6 — Write a small generated file + commit inside the sandbox.
450
+ await step('write', async () => {
451
+ const box = session as SandboxSession
452
+ await box.writeFile(`${REPO_DIR}/${generatedName}`, generatedBody)
453
+ await execChecked(box, `git add ${generatedName}`, { cwd: REPO_DIR, label: 'git add' })
454
+ await execChecked(
455
+ box,
456
+ `git -c user.email=orizu-slice@orizu.local -c user.name="Orizu Slice" commit -m "chore: WS-F slice generated ${generatedName}"`,
457
+ { cwd: REPO_DIR, label: 'git commit' }
458
+ )
459
+ const sha = (await execChecked(box, 'git rev-parse HEAD', { cwd: REPO_DIR, label: 'git rev-parse' })).stdout.trim()
460
+ ids.commitSha = sha
461
+ return `committed ${generatedName} (${sha.slice(0, 10)})`
462
+ })
463
+
464
+ // 7 — Mint a WRITE token, push the session branch, then revoke BOTH tokens.
465
+ await step('push', async () => {
466
+ const box = session as SandboxSession
467
+ const write = await mintRepoToken(fetcher, ids.workspaceId!, 'write', ids.sessionId!)
468
+ ids.writeMintId = write.mintId
469
+ writeToken = write.token
470
+ await box.gitPush({ path: REPO_DIR, username: 'x-access-token', password: write.token })
471
+
472
+ // Early revocation of both mints (60-min TTL is only the backstop).
473
+ await deleteJson(fetcher, `/api/cli/workspaces/${encodeURIComponent(ids.workspaceId!)}/repo-token`, {
474
+ mintId: ids.readMintId,
475
+ token: readToken,
476
+ })
477
+ await deleteJson(fetcher, `/api/cli/workspaces/${encodeURIComponent(ids.workspaceId!)}/repo-token`, {
478
+ mintId: write.mintId,
479
+ token: write.token,
480
+ })
481
+ return `pushed ${ids.branch}; revoked read+write mints (${ids.readMintId}, ${write.mintId})`
482
+ })
483
+
484
+ // 8 — Hygiene sweep, then finish-branch → approve → apply, then destroy.
485
+ await step('promote', async () => {
486
+ const box = session as SandboxSession
487
+ if (opts.hooks?.beforeHygiene) {
488
+ await opts.hooks.beforeHygiene(box, { repoPath: REPO_DIR })
489
+ }
490
+ const findings = await sweepForTokenResidue(box, {
491
+ tokens: [readToken, writeToken],
492
+ repoPath: REPO_DIR,
493
+ })
494
+ hygiene = { clean: findings.length === 0, findings }
495
+ if (findings.length > 0) {
496
+ const where = findings.map(f => `${f.location}:${f.marker}`).join(', ')
497
+ throw new Error(`token residue found on the sandbox filesystem: ${where}`)
498
+ }
499
+
500
+ const finish = await postJson(fetcher, `/api/cli/sessions/${encodeURIComponent(ids.sessionId!)}/finish-branch`, {
501
+ projectSlug: config.projectSlug,
502
+ })
503
+ const outcome = String(finish.body.outcome)
504
+ if (outcome !== 'manifest') {
505
+ throw new Error(`finish-branch expected a manifest (branch had a commit) but returned "${outcome}"`)
506
+ }
507
+ const manifest = requireObject(finish.body.manifest, 'manifest')
508
+ const manifestId = requireString(manifest.id, 'manifest id')
509
+ ids.manifestId = manifestId
510
+
511
+ const actionPath = `/api/cli/promotion-manifests/${encodeURIComponent(manifestId)}`
512
+ await postJson(fetcher, actionPath, { action: 'approve' })
513
+ const applied = await postJson(fetcher, actionPath, { action: 'apply' })
514
+ const appliedManifest = requireObject(applied.body.manifest, 'manifest')
515
+ ids.mergeOutcome = String(appliedManifest.status ?? 'applied')
516
+
517
+ if (!opts.keep) {
518
+ await box.destroy()
519
+ }
520
+ return `hygiene clean; manifest ${manifestId} applied (${ids.mergeOutcome})${opts.keep ? '; sandbox kept' : '; sandbox destroyed'}`
521
+ })
522
+
523
+ return {
524
+ ok: true,
525
+ provider: provider.kind,
526
+ steps,
527
+ failedStep: null,
528
+ totalMs: now() - startedAll,
529
+ hygiene,
530
+ ids,
531
+ }
532
+ } catch (error) {
533
+ const key = STEP_PLAN[steps.length]?.key ?? 'unknown'
534
+ const failedStep: SliceStepReport = {
535
+ index: indexFor(key),
536
+ key,
537
+ title: titleFor(key),
538
+ ok: false,
539
+ detail: error instanceof Error ? error.message : String(error),
540
+ durationMs: 0,
541
+ }
542
+ // Pushed-branch cleanup: if a session branch was pushed (write mint taken)
543
+ // but the run never reached the merge, the branch is now dangling on the
544
+ // remote. Best-effort drive finish-branch to fold it into the WS-E managed
545
+ // path (a repo_merge manifest a curator can reject, or a no-change delete);
546
+ // if that call also fails, name the branch on the report + failed detail so
547
+ // ALI-972's `orizu workspace status` stale-branch report can reap it. We do
548
+ // NOT auto-approve/apply — a failed run must not silently merge.
549
+ if (ids.branch && ids.writeMintId && !ids.mergeOutcome) {
550
+ ids.orphanedBranch = ids.branch
551
+ let handled = 'left on the remote'
552
+ try {
553
+ const finish = await postJson(fetcher, `/api/cli/sessions/${encodeURIComponent(ids.sessionId!)}/finish-branch`, {
554
+ projectSlug: config.projectSlug,
555
+ })
556
+ const outcome = String(finish.body.outcome)
557
+ handled = outcome === 'no-changes' ? 'deleted (no changes)' : 'folded into a pending repo_merge manifest for review'
558
+ } catch {
559
+ // finish-branch itself failed; fall through to the manual-cleanup hint.
560
+ }
561
+ failedStep.detail += ` [session branch ${ids.branch} ${handled}; reap via \`orizu workspace status\` if it lingers]`
562
+ }
563
+ // Best-effort teardown so a failed run does not leak a sandbox. The cast
564
+ // re-asserts the union: `session` is only assigned inside a nested step
565
+ // closure, which TS control-flow does not track (it would narrow to never).
566
+ //
567
+ // ORPHAN-SANDBOX WINDOW (known gap, not fixable from this side of the seam):
568
+ // `session` is only assigned AFTER `provider.createSandbox()` resolves (see
569
+ // the 'sandbox' step above). If the remote sandbox is created but the SDK
570
+ // call throws before returning the session object — a bad response body, a
571
+ // dropped connection on the reply, etc. — `session` stays null here and this
572
+ // teardown is skipped, leaking a live remote sandbox we have no id for.
573
+ // `SandboxProvider.createSandbox()` returns `Promise<SandboxSession>` as a
574
+ // single unit (create-and-return are not separable in the current SDK
575
+ // shape), so there is no id to destroy-by even if we caught it. `--keep`
576
+ // aside, this is covered operationally by manual/scheduled cleanup in the
577
+ // Daytona dashboard, not by this code. If the SDK ever exposes a two-phase
578
+ // create-then-fetch-id (or a create call that returns an id before the rest
579
+ // of provisioning completes), switch to that so this window closes.
580
+ const openSandbox = session as SandboxSession | null
581
+ if (openSandbox && !opts.keep) {
582
+ try {
583
+ await openSandbox.destroy()
584
+ } catch {
585
+ // ignore teardown failures on the error path
586
+ }
587
+ }
588
+ return {
589
+ ok: false,
590
+ provider: provider.kind,
591
+ steps,
592
+ failedStep,
593
+ totalMs: now() - startedAll,
594
+ hygiene,
595
+ ids,
596
+ }
597
+ }
598
+ }