orizu 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +22 -13
  2. package/dist/artifact-archive.js +152 -0
  3. package/dist/connectors-cli.js +80 -0
  4. package/dist/credentials.js +4 -1
  5. package/dist/daytona-slice-rehearsal.js +255 -0
  6. package/dist/daytona-slice.js +441 -0
  7. package/dist/egress-allowlist-cli.js +132 -0
  8. package/dist/egress-policy.js +159 -0
  9. package/dist/git-credential.js +131 -0
  10. package/dist/github-setup.js +248 -0
  11. package/dist/help.js +271 -16
  12. package/dist/hosted-bootstrap-rehearsal.js +235 -0
  13. package/dist/hosted-bootstrap.js +365 -0
  14. package/dist/hosted-broker-server.js +220 -0
  15. package/dist/hosted-git-http-server.js +136 -0
  16. package/dist/hosted-harness-claude.js +493 -0
  17. package/dist/hosted-harness-opencode.js +729 -0
  18. package/dist/hosted-harness.js +60 -0
  19. package/dist/hosted-loop.js +477 -0
  20. package/dist/hosted-run-event-sink.js +499 -0
  21. package/dist/hosted-runtime-assets.js +216 -0
  22. package/dist/hosted-session-cli.js +642 -0
  23. package/dist/index.js +855 -237
  24. package/dist/kill-switch-cli.js +89 -0
  25. package/dist/manifests-cli.js +143 -0
  26. package/dist/preview-runtime.js +4 -1
  27. package/dist/sandbox-provider.js +238 -0
  28. package/dist/secret-redaction.js +112 -0
  29. package/dist/session-timeline.js +103 -0
  30. package/dist/setup-prompt.js +4 -2
  31. package/dist/skill-installer.js +9 -1
  32. package/dist/smoke.js +250 -0
  33. package/dist/task-assignment-manifest.js +39 -2
  34. package/dist/vercel-sandbox-provider.js +260 -0
  35. package/dist/workbench-cli.js +493 -0
  36. package/dist/workspace-sync.js +924 -0
  37. package/dist/workspace.js +868 -57
  38. package/package.json +4 -1
  39. package/src/artifact-archive.ts +182 -0
  40. package/src/connectors-cli.ts +115 -0
  41. package/src/credentials.ts +6 -2
  42. package/src/daytona-slice-rehearsal.ts +320 -0
  43. package/src/daytona-slice.ts +598 -0
  44. package/src/egress-allowlist-cli.ts +147 -0
  45. package/src/egress-policy.ts +188 -0
  46. package/src/git-credential.ts +155 -0
  47. package/src/github-setup.ts +396 -0
  48. package/src/help.ts +272 -16
  49. package/src/hosted-bootstrap-rehearsal.ts +312 -0
  50. package/src/hosted-bootstrap.ts +571 -0
  51. package/src/hosted-broker-server.ts +278 -0
  52. package/src/hosted-git-http-server.ts +164 -0
  53. package/src/hosted-harness-claude.ts +621 -0
  54. package/src/hosted-harness-opencode.ts +823 -0
  55. package/src/hosted-harness.ts +124 -0
  56. package/src/hosted-loop.ts +680 -0
  57. package/src/hosted-run-event-sink.ts +632 -0
  58. package/src/hosted-runtime-assets.ts +272 -0
  59. package/src/hosted-session-cli.ts +844 -0
  60. package/src/index.ts +987 -296
  61. package/src/kill-switch-cli.ts +113 -0
  62. package/src/manifests-cli.ts +168 -0
  63. package/src/preview-runtime.ts +14 -9
  64. package/src/sandbox-provider.ts +440 -0
  65. package/src/secret-redaction.ts +124 -0
  66. package/src/session-timeline.ts +152 -0
  67. package/src/setup-prompt.ts +4 -2
  68. package/src/skill-installer.ts +13 -1
  69. package/src/smoke.ts +342 -0
  70. package/src/task-assignment-manifest.ts +70 -2
  71. package/src/vercel-sandbox-provider.ts +388 -0
  72. package/src/workbench-cli.ts +701 -0
  73. package/src/workspace-sync.ts +1148 -0
  74. package/src/workspace.ts +1051 -64
  75. package/vendor/skills/orizu-cli/SKILL.md +3 -1
  76. package/vendor/skills/orizu-cli/references/cli-reference.md +3 -3
@@ -0,0 +1,441 @@
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
+ import { WORKSPACE_SCHEMA_VERSION } from './workspace.js';
26
+ const REPO_DIR = 'repo';
27
+ const DEFAULT_GENERATED_FILE = 'orizu-slice-note.md';
28
+ const STEP_PLAN = [
29
+ { key: 'session', title: 'Resolve workspace + start session branch', plan: 'POST /api/cli/workspaces (idempotent) then POST .../sessions { repoBranch }' },
30
+ { key: 'sandbox', title: 'Create sandbox via provider', plan: 'provider.createSandbox()' },
31
+ { key: 'clone', title: 'Mint read token + clone session branch', plan: 'POST .../repo-token { read } then sandbox.gitClone(branch)' },
32
+ { key: 'manifests', title: 'Read + parse root manifests', plan: 'sandbox.readFile(orizu.team.json) — schemaVersion check' },
33
+ { key: 'objectref', title: 'Resolve object ref (no bytes in git)', plan: 'materialize into .orizu/ (git-ignored) — bulk bytes stay out of git' },
34
+ { key: 'write', title: 'Write + commit generated file', plan: 'sandbox.writeFile + git commit inside the sandbox' },
35
+ { key: 'push', title: 'Mint write token + push; revoke tokens', plan: 'POST .../repo-token { write }, gitPush, DELETE both mints' },
36
+ { key: 'promote', title: 'Hygiene sweep + finish-branch + approve + apply + destroy', plan: 'sweep (no residue) → finish-branch → approve → apply → destroy' },
37
+ ];
38
+ export function planSliceSteps() {
39
+ return STEP_PLAN.map((step, i) => ({ index: i + 1, key: step.key, title: step.title, plan: step.plan }));
40
+ }
41
+ function titleFor(key) {
42
+ return STEP_PLAN.find(step => step.key === key)?.title ?? key;
43
+ }
44
+ function indexFor(key) {
45
+ return STEP_PLAN.findIndex(step => step.key === key) + 1;
46
+ }
47
+ /**
48
+ * Resolve the provider kind. An explicit `--provider` flag always wins; with no
49
+ * flag the default is `local-sim` when DAYTONA_API_KEY is absent, and `daytona`
50
+ * when it is present (the live run is founder-gated by that key).
51
+ */
52
+ export function selectProviderKind(input) {
53
+ const flag = input.providerFlag?.trim().toLowerCase();
54
+ if (flag === 'daytona' || flag === 'local-sim') {
55
+ return flag;
56
+ }
57
+ if (flag) {
58
+ throw new Error(`Unknown --provider "${input.providerFlag}". Use "daytona" or "local-sim".`);
59
+ }
60
+ return input.hasDaytonaKey ? 'daytona' : 'local-sim';
61
+ }
62
+ // -- HTTP helpers ------------------------------------------------------------
63
+ async function readBody(response) {
64
+ try {
65
+ return (await response.json());
66
+ }
67
+ catch {
68
+ return {};
69
+ }
70
+ }
71
+ async function errorMessage(response) {
72
+ const body = await readBody(response.clone());
73
+ const error = typeof body.error === 'string' ? body.error : null;
74
+ return error || response.statusText || String(response.status);
75
+ }
76
+ async function postJson(fetcher, path, payload) {
77
+ const response = await fetcher(path, {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify(payload),
81
+ });
82
+ if (!response.ok) {
83
+ throw new Error(`${path} failed (${response.status}): ${await errorMessage(response)}`);
84
+ }
85
+ return { status: response.status, body: await readBody(response) };
86
+ }
87
+ async function deleteJson(fetcher, path, payload) {
88
+ const response = await fetcher(path, {
89
+ method: 'DELETE',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify(payload),
92
+ });
93
+ if (!response.ok) {
94
+ throw new Error(`${path} (revoke) failed (${response.status}): ${await errorMessage(response)}`);
95
+ }
96
+ }
97
+ function requireString(value, label) {
98
+ if (typeof value !== 'string' || value.length === 0) {
99
+ throw new Error(`${label} missing from response`);
100
+ }
101
+ return value;
102
+ }
103
+ function requireObject(value, label) {
104
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
105
+ throw new Error(`${label} missing from response`);
106
+ }
107
+ return value;
108
+ }
109
+ async function mintRepoToken(fetcher, workspaceId, purpose, sessionId) {
110
+ const { body } = await postJson(fetcher, `/api/cli/workspaces/${encodeURIComponent(workspaceId)}/repo-token`, {
111
+ purpose,
112
+ sessionId,
113
+ });
114
+ return {
115
+ token: requireString(body.token, `${purpose} token`),
116
+ mintId: requireString(body.mintId, `${purpose} mintId`),
117
+ repo: requireString(body.repo, `${purpose} repo`),
118
+ expiresAt: typeof body.expiresAt === 'string' ? body.expiresAt : null,
119
+ };
120
+ }
121
+ /**
122
+ * The list of places a short-lived git token can leak inside a sandbox after a
123
+ * transient clone+push. List-driven so it is trivial to extend. `${repo}` is
124
+ * substituted with the repo path. Ordered from the repo-local stores outward to
125
+ * global config, the credential cache, the reflog, shell history, and the live
126
+ * process listing / argv — the exact surfaces the Daytona-support question names.
127
+ */
128
+ export function tokenLeakProbes(repoPath) {
129
+ const R = repoPath;
130
+ return [
131
+ { location: `${R}/.git/config`, command: `cat ${R}/.git/config 2>/dev/null || true` },
132
+ { location: `${R}/.git/logs (reflog)`, command: `find ${R}/.git/logs -type f -exec cat {} + 2>/dev/null || true` },
133
+ { location: '~/.git-credentials', command: 'cat "$HOME/.git-credentials" 2>/dev/null || true' },
134
+ { location: '~/.gitconfig (global)', command: 'cat "$HOME/.gitconfig" 2>/dev/null || true' },
135
+ { location: 'git credential cache/store', command: 'cat "$HOME/.cache/git/credential/"* "${XDG_CACHE_HOME:-$HOME/.cache}/git/credential/"* 2>/dev/null || true' },
136
+ { location: 'shell history', command: 'cat "$HOME/.bash_history" "$HOME/.zsh_history" 2>/dev/null || true' },
137
+ { location: 'process listing / argv', command: 'ps -eo args 2>/dev/null || ps aux 2>/dev/null || true' },
138
+ { location: 'process env', command: 'env' },
139
+ ];
140
+ }
141
+ /**
142
+ * Grep the sandbox filesystem + process table for token values and the
143
+ * `x-access-token` marker across every known leak surface (see `tokenLeakProbes`).
144
+ * Returns every finding; an empty array means the transient operations left no
145
+ * residue. This is the load-bearing check the whole slice exists to make.
146
+ *
147
+ * NOTE (local-sim): the reflog/config/credential-store/history probes run
148
+ * meaningfully in local-sim, but `ps`/argv coverage is only decisive on the live
149
+ * Daytona run (local-sim runs the git clone via a child process on the host, not
150
+ * inside an isolated PID namespace). The WS-F memo marks that probe live-only.
151
+ */
152
+ export async function sweepForTokenResidue(session, opts) {
153
+ const markers = opts.markers ?? ['x-access-token'];
154
+ const tokens = opts.tokens.filter(token => token.length > 0);
155
+ const probes = opts.probes ?? tokenLeakProbes(opts.repoPath);
156
+ const findings = [];
157
+ for (const probe of probes) {
158
+ const { stdout } = await session.exec(probe.command);
159
+ for (const token of tokens) {
160
+ if (stdout.includes(token)) {
161
+ findings.push({ location: probe.location, marker: 'token-value' });
162
+ }
163
+ }
164
+ for (const marker of markers) {
165
+ if (stdout.includes(marker)) {
166
+ findings.push({ location: probe.location, marker });
167
+ }
168
+ }
169
+ }
170
+ return findings;
171
+ }
172
+ // -- The workflow ------------------------------------------------------------
173
+ /**
174
+ * Run a command in the sandbox and throw on a non-zero (or missing) exit code,
175
+ * surfacing captured stderr so a live git failure does not read as success.
176
+ */
177
+ async function execChecked(session, command, opts) {
178
+ const result = await session.exec(command, opts.cwd ? { cwd: opts.cwd } : undefined);
179
+ if (result.exitCode !== 0) {
180
+ const detail = (result.stderr && result.stderr.trim()) || result.stdout.trim() || `exit ${result.exitCode}`;
181
+ throw new Error(`${opts.label} failed inside the sandbox: ${detail}`);
182
+ }
183
+ return result;
184
+ }
185
+ export async function runDaytonaSlice(opts) {
186
+ const { config, provider } = opts;
187
+ const fetcher = opts.makeFetcher();
188
+ const now = opts.now ?? (() => Date.now());
189
+ const steps = [];
190
+ const ids = {};
191
+ const generatedName = opts.generatedFileName ?? DEFAULT_GENERATED_FILE;
192
+ const generatedBody = opts.generatedFileBody ??
193
+ `# Orizu Daytona slice\n\nGenerated inside the sandbox by the WS-F load-path slice.\nprovider=${provider.kind}\n`;
194
+ let hygiene = null;
195
+ let session = null;
196
+ // Token values live only in these locals — never on the report, so a
197
+ // serialized SliceReport can never carry a live credential.
198
+ let readToken = '';
199
+ let writeToken = '';
200
+ const startedAll = now();
201
+ const step = async (key, fn) => {
202
+ const startedAt = now();
203
+ const detail = await fn();
204
+ const report = {
205
+ index: indexFor(key),
206
+ key,
207
+ title: titleFor(key),
208
+ ok: true,
209
+ detail,
210
+ durationMs: now() - startedAt,
211
+ };
212
+ steps.push(report);
213
+ opts.onStep?.(report);
214
+ };
215
+ try {
216
+ // 1 — Workspace resolve-or-attach, then a session on its own repo branch.
217
+ await step('session', async () => {
218
+ const workspace = await postJson(fetcher, '/api/cli/workspaces', {
219
+ teamSlug: config.teamSlug,
220
+ name: config.workspaceSlug,
221
+ slug: config.workspaceSlug,
222
+ });
223
+ const workspaceId = requireString(requireObject(workspace.body.workspace, 'workspace').id, 'workspace id');
224
+ ids.workspaceId = workspaceId;
225
+ const sessionResp = await postJson(fetcher, `/api/cli/workspaces/${encodeURIComponent(workspaceId)}/sessions`, {
226
+ projectSlug: config.projectSlug,
227
+ repoBranch: true,
228
+ clientInfo: { source: 'orizu-daytona-slice', provider: provider.kind },
229
+ });
230
+ const sessionRow = requireObject(sessionResp.body.session, 'session');
231
+ const sessionId = requireString(sessionRow.id, 'session id');
232
+ const branch = requireString(sessionRow.repoBranch, 'session repoBranch');
233
+ ids.sessionId = sessionId;
234
+ ids.branch = branch;
235
+ return `workspace ${workspaceId}; session ${sessionId} on ${branch}`;
236
+ });
237
+ // 2 — Create the sandbox via the provider seam.
238
+ await step('sandbox', async () => {
239
+ session = await provider.createSandbox({ language: 'typescript', labels: { orizu: 'daytona-slice' } });
240
+ ids.sandboxId = session.id;
241
+ return `${provider.kind} sandbox ${session.id}`;
242
+ });
243
+ // 3 — Mint a READ token and clone the session branch INSIDE the sandbox.
244
+ await step('clone', async () => {
245
+ const box = session;
246
+ const read = await mintRepoToken(fetcher, ids.workspaceId, 'read', ids.sessionId);
247
+ ids.readMintId = read.mintId;
248
+ ids.repo = read.repo;
249
+ readToken = read.token;
250
+ await box.gitClone({
251
+ url: opts.resolveCloneUrl(read.repo),
252
+ path: REPO_DIR,
253
+ branch: ids.branch,
254
+ username: 'x-access-token',
255
+ password: read.token,
256
+ });
257
+ return `cloned ${read.repo}@${ids.branch} with a read mint (${read.mintId})`;
258
+ });
259
+ // 4 — Read + parse the root manifests.
260
+ await step('manifests', async () => {
261
+ const box = session;
262
+ const manifestPath = `${REPO_DIR}/orizu.team.json`;
263
+ if (!(await box.fileExists(manifestPath))) {
264
+ throw new Error('orizu.team.json is missing from the cloned repo root');
265
+ }
266
+ const raw = await box.readFile(manifestPath);
267
+ let parsed;
268
+ try {
269
+ parsed = JSON.parse(raw);
270
+ }
271
+ catch (error) {
272
+ throw new Error(`orizu.team.json did not parse: ${error instanceof Error ? error.message : String(error)}`);
273
+ }
274
+ if (parsed.schemaVersion !== WORKSPACE_SCHEMA_VERSION) {
275
+ throw new Error(`orizu.team.json schemaVersion is ${String(parsed.schemaVersion)}, expected ${WORKSPACE_SCHEMA_VERSION}`);
276
+ }
277
+ return `orizu.team.json parsed; schemaVersion=${WORKSPACE_SCHEMA_VERSION} kind=${String(parsed.kind)}`;
278
+ });
279
+ // 5 — Resolve one object ref WITHOUT committing bytes to git. The exemplar
280
+ // workspace carries no object refs (canonical.objectRef=null), so we prove
281
+ // the invariant instead: `.orizu/` is the git-ignored materialization target.
282
+ await step('objectref', async () => {
283
+ const box = session;
284
+ const gitignore = await box.readFile(`${REPO_DIR}/.gitignore`);
285
+ if (!gitignore.includes('.orizu')) {
286
+ throw new Error('.gitignore does not ignore .orizu/ — bulk bytes could leak into git');
287
+ }
288
+ // Materialize a would-be object payload into .orizu/ and confirm git
289
+ // ignores it (bytes stay out of the commit).
290
+ await execChecked(box, 'mkdir -p .orizu && printf "object-ref-materialization" > .orizu/slice-object.bin', {
291
+ cwd: REPO_DIR,
292
+ label: 'object-ref materialization',
293
+ });
294
+ const status = await execChecked(box, 'git status --porcelain', { cwd: REPO_DIR, label: 'git status' });
295
+ if (status.stdout.includes('.orizu')) {
296
+ throw new Error('.orizu/ payload showed up in git status — it must be ignored');
297
+ }
298
+ return 'no object refs in exemplar; verified .orizu/ is the git-ignored materialization target';
299
+ });
300
+ // 6 — Write a small generated file + commit inside the sandbox.
301
+ await step('write', async () => {
302
+ const box = session;
303
+ await box.writeFile(`${REPO_DIR}/${generatedName}`, generatedBody);
304
+ await execChecked(box, `git add ${generatedName}`, { cwd: REPO_DIR, label: 'git add' });
305
+ await execChecked(box, `git -c user.email=orizu-slice@orizu.local -c user.name="Orizu Slice" commit -m "chore: WS-F slice generated ${generatedName}"`, { cwd: REPO_DIR, label: 'git commit' });
306
+ const sha = (await execChecked(box, 'git rev-parse HEAD', { cwd: REPO_DIR, label: 'git rev-parse' })).stdout.trim();
307
+ ids.commitSha = sha;
308
+ return `committed ${generatedName} (${sha.slice(0, 10)})`;
309
+ });
310
+ // 7 — Mint a WRITE token, push the session branch, then revoke BOTH tokens.
311
+ await step('push', async () => {
312
+ const box = session;
313
+ const write = await mintRepoToken(fetcher, ids.workspaceId, 'write', ids.sessionId);
314
+ ids.writeMintId = write.mintId;
315
+ writeToken = write.token;
316
+ await box.gitPush({ path: REPO_DIR, username: 'x-access-token', password: write.token });
317
+ // Early revocation of both mints (60-min TTL is only the backstop).
318
+ await deleteJson(fetcher, `/api/cli/workspaces/${encodeURIComponent(ids.workspaceId)}/repo-token`, {
319
+ mintId: ids.readMintId,
320
+ token: readToken,
321
+ });
322
+ await deleteJson(fetcher, `/api/cli/workspaces/${encodeURIComponent(ids.workspaceId)}/repo-token`, {
323
+ mintId: write.mintId,
324
+ token: write.token,
325
+ });
326
+ return `pushed ${ids.branch}; revoked read+write mints (${ids.readMintId}, ${write.mintId})`;
327
+ });
328
+ // 8 — Hygiene sweep, then finish-branch → approve → apply, then destroy.
329
+ await step('promote', async () => {
330
+ const box = session;
331
+ if (opts.hooks?.beforeHygiene) {
332
+ await opts.hooks.beforeHygiene(box, { repoPath: REPO_DIR });
333
+ }
334
+ const findings = await sweepForTokenResidue(box, {
335
+ tokens: [readToken, writeToken],
336
+ repoPath: REPO_DIR,
337
+ });
338
+ hygiene = { clean: findings.length === 0, findings };
339
+ if (findings.length > 0) {
340
+ const where = findings.map(f => `${f.location}:${f.marker}`).join(', ');
341
+ throw new Error(`token residue found on the sandbox filesystem: ${where}`);
342
+ }
343
+ const finish = await postJson(fetcher, `/api/cli/sessions/${encodeURIComponent(ids.sessionId)}/finish-branch`, {
344
+ projectSlug: config.projectSlug,
345
+ });
346
+ const outcome = String(finish.body.outcome);
347
+ if (outcome !== 'manifest') {
348
+ throw new Error(`finish-branch expected a manifest (branch had a commit) but returned "${outcome}"`);
349
+ }
350
+ const manifest = requireObject(finish.body.manifest, 'manifest');
351
+ const manifestId = requireString(manifest.id, 'manifest id');
352
+ ids.manifestId = manifestId;
353
+ const actionPath = `/api/cli/promotion-manifests/${encodeURIComponent(manifestId)}`;
354
+ await postJson(fetcher, actionPath, { action: 'approve' });
355
+ const applied = await postJson(fetcher, actionPath, { action: 'apply' });
356
+ const appliedManifest = requireObject(applied.body.manifest, 'manifest');
357
+ ids.mergeOutcome = String(appliedManifest.status ?? 'applied');
358
+ if (!opts.keep) {
359
+ await box.destroy();
360
+ }
361
+ return `hygiene clean; manifest ${manifestId} applied (${ids.mergeOutcome})${opts.keep ? '; sandbox kept' : '; sandbox destroyed'}`;
362
+ });
363
+ return {
364
+ ok: true,
365
+ provider: provider.kind,
366
+ steps,
367
+ failedStep: null,
368
+ totalMs: now() - startedAll,
369
+ hygiene,
370
+ ids,
371
+ };
372
+ }
373
+ catch (error) {
374
+ const key = STEP_PLAN[steps.length]?.key ?? 'unknown';
375
+ const failedStep = {
376
+ index: indexFor(key),
377
+ key,
378
+ title: titleFor(key),
379
+ ok: false,
380
+ detail: error instanceof Error ? error.message : String(error),
381
+ durationMs: 0,
382
+ };
383
+ // Pushed-branch cleanup: if a session branch was pushed (write mint taken)
384
+ // but the run never reached the merge, the branch is now dangling on the
385
+ // remote. Best-effort drive finish-branch to fold it into the WS-E managed
386
+ // path (a repo_merge manifest a curator can reject, or a no-change delete);
387
+ // if that call also fails, name the branch on the report + failed detail so
388
+ // ALI-972's `orizu workspace status` stale-branch report can reap it. We do
389
+ // NOT auto-approve/apply — a failed run must not silently merge.
390
+ if (ids.branch && ids.writeMintId && !ids.mergeOutcome) {
391
+ ids.orphanedBranch = ids.branch;
392
+ let handled = 'left on the remote';
393
+ try {
394
+ const finish = await postJson(fetcher, `/api/cli/sessions/${encodeURIComponent(ids.sessionId)}/finish-branch`, {
395
+ projectSlug: config.projectSlug,
396
+ });
397
+ const outcome = String(finish.body.outcome);
398
+ handled = outcome === 'no-changes' ? 'deleted (no changes)' : 'folded into a pending repo_merge manifest for review';
399
+ }
400
+ catch {
401
+ // finish-branch itself failed; fall through to the manual-cleanup hint.
402
+ }
403
+ failedStep.detail += ` [session branch ${ids.branch} ${handled}; reap via \`orizu workspace status\` if it lingers]`;
404
+ }
405
+ // Best-effort teardown so a failed run does not leak a sandbox. The cast
406
+ // re-asserts the union: `session` is only assigned inside a nested step
407
+ // closure, which TS control-flow does not track (it would narrow to never).
408
+ //
409
+ // ORPHAN-SANDBOX WINDOW (known gap, not fixable from this side of the seam):
410
+ // `session` is only assigned AFTER `provider.createSandbox()` resolves (see
411
+ // the 'sandbox' step above). If the remote sandbox is created but the SDK
412
+ // call throws before returning the session object — a bad response body, a
413
+ // dropped connection on the reply, etc. — `session` stays null here and this
414
+ // teardown is skipped, leaking a live remote sandbox we have no id for.
415
+ // `SandboxProvider.createSandbox()` returns `Promise<SandboxSession>` as a
416
+ // single unit (create-and-return are not separable in the current SDK
417
+ // shape), so there is no id to destroy-by even if we caught it. `--keep`
418
+ // aside, this is covered operationally by manual/scheduled cleanup in the
419
+ // Daytona dashboard, not by this code. If the SDK ever exposes a two-phase
420
+ // create-then-fetch-id (or a create call that returns an id before the rest
421
+ // of provisioning completes), switch to that so this window closes.
422
+ const openSandbox = session;
423
+ if (openSandbox && !opts.keep) {
424
+ try {
425
+ await openSandbox.destroy();
426
+ }
427
+ catch {
428
+ // ignore teardown failures on the error path
429
+ }
430
+ }
431
+ return {
432
+ ok: false,
433
+ provider: provider.kind,
434
+ steps,
435
+ failedStep,
436
+ totalMs: now() - startedAll,
437
+ hygiene,
438
+ ids,
439
+ };
440
+ }
441
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Per-team ADDITIVE egress allowlist CLI (ALI-1006 / G5):
3
+ * orizu team egress-allowlist <team> # show extra domains
4
+ * orizu team egress-allowlist <team> --add <domain>
5
+ * orizu team egress-allowlist <team> --remove <domain>
6
+ * orizu team egress-allowlist <team> --set a.com,b.com
7
+ * orizu team egress-allowlist <team> --clear
8
+ *
9
+ * The BASE allowlist (Orizu API + model provider + git host) is code-owned and
10
+ * NOT shown/managed here — this surface only widens a team's egress with domains
11
+ * a customer workflow needs. Human ADMIN only: the server rejects agent bearers
12
+ * and non-admins (the definer RPC gates on is_team_admin). Pure command logic
13
+ * with an injected fetcher.
14
+ */
15
+ import { authedFetch } from './http.js';
16
+ function fetcherFrom(io) {
17
+ return io.fetcher ?? authedFetch;
18
+ }
19
+ function argValue(args, flag) {
20
+ const index = args.indexOf(flag);
21
+ if (index === -1 || index + 1 >= args.length || args[index + 1].startsWith('--')) {
22
+ return null;
23
+ }
24
+ return args[index + 1];
25
+ }
26
+ function firstPositional(args) {
27
+ for (let i = 0; i < args.length; i += 1) {
28
+ const arg = args[i];
29
+ if (arg.startsWith('--')) {
30
+ if (argValue(args, arg) !== null)
31
+ i += 1;
32
+ continue;
33
+ }
34
+ return arg;
35
+ }
36
+ return null;
37
+ }
38
+ function printErr(io, message) {
39
+ ;
40
+ (io.printErr ?? io.print)(message);
41
+ }
42
+ async function responseMessage(response) {
43
+ try {
44
+ const data = (await response.clone().json());
45
+ const error = typeof data.error === 'string' ? data.error : null;
46
+ return error || response.statusText || String(response.status);
47
+ }
48
+ catch {
49
+ return response.statusText || String(response.status);
50
+ }
51
+ }
52
+ function splitDomains(value) {
53
+ return value
54
+ .split(',')
55
+ .map(d => d.trim())
56
+ .filter(d => d.length > 0);
57
+ }
58
+ async function fetchCurrent(fetcher, path) {
59
+ const response = await fetcher(path, { method: 'GET' });
60
+ if (!response.ok) {
61
+ throw new Error(`show failed (${response.status}): ${await responseMessage(response)}`);
62
+ }
63
+ const data = (await response.json());
64
+ return Array.isArray(data.extraDomains) ? data.extraDomains.filter((d) => typeof d === 'string') : [];
65
+ }
66
+ function emit(io, teamSlug, domains) {
67
+ if (io.json) {
68
+ io.print(JSON.stringify({ teamSlug, extraDomains: domains }));
69
+ return;
70
+ }
71
+ io.print(domains.length === 0
72
+ ? `${teamSlug}: no additive egress domains (base allowlist only)`
73
+ : `${teamSlug} additive egress domains:\n ${domains.join('\n ')}`);
74
+ }
75
+ export async function egressAllowlistCommand(args, io) {
76
+ const teamSlug = firstPositional(args);
77
+ if (!teamSlug) {
78
+ printErr(io, 'Usage: orizu team egress-allowlist <team> [--add <domain>|--remove <domain>|--set a.com,b.com|--clear] [--json]');
79
+ return 1;
80
+ }
81
+ const path = `/api/cli/teams/${encodeURIComponent(teamSlug)}/egress-allowlist`;
82
+ const fetcher = fetcherFrom(io);
83
+ const setValue = argValue(args, '--set');
84
+ const addValue = argValue(args, '--add');
85
+ const removeValue = argValue(args, '--remove');
86
+ const clear = args.includes('--clear');
87
+ const isMutation = setValue !== null || addValue !== null || removeValue !== null || clear;
88
+ try {
89
+ if (!isMutation) {
90
+ emit(io, teamSlug, await fetchCurrent(fetcher, path));
91
+ return 0;
92
+ }
93
+ // Compute the next set. --set/--clear replace; --add/--remove mutate current.
94
+ let next;
95
+ if (setValue !== null) {
96
+ next = splitDomains(setValue);
97
+ }
98
+ else if (clear) {
99
+ next = [];
100
+ }
101
+ else {
102
+ const current = await fetchCurrent(fetcher, path);
103
+ const set = new Set(current);
104
+ if (addValue)
105
+ for (const d of splitDomains(addValue))
106
+ set.add(d);
107
+ if (removeValue)
108
+ for (const d of splitDomains(removeValue))
109
+ set.delete(d);
110
+ next = [...set];
111
+ }
112
+ const response = await fetcher(path, {
113
+ method: 'PUT',
114
+ headers: { 'Content-Type': 'application/json' },
115
+ body: JSON.stringify({ domains: next }),
116
+ });
117
+ if (!response.ok) {
118
+ printErr(io, `egress-allowlist update failed (${response.status}): ${await responseMessage(response)}`);
119
+ return 1;
120
+ }
121
+ const data = (await response.json());
122
+ const stored = Array.isArray(data.extraDomains)
123
+ ? data.extraDomains.filter((d) => typeof d === 'string')
124
+ : next;
125
+ emit(io, teamSlug, stored);
126
+ return 0;
127
+ }
128
+ catch (error) {
129
+ printErr(io, error instanceof Error ? error.message : String(error));
130
+ return 1;
131
+ }
132
+ }