orizu 0.5.10 → 0.5.11

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 (49) hide show
  1. package/dist/daytona-slice-rehearsal.js +2 -2
  2. package/dist/git-credential.js +10 -7
  3. package/dist/github-setup.js +18 -2
  4. package/dist/help.js +16 -11
  5. package/dist/hosted-boot.js +195 -18
  6. package/dist/hosted-bootstrap.js +18 -40
  7. package/dist/hosted-harness-opencode.js +4 -2
  8. package/dist/hosted-headless.js +17 -6
  9. package/dist/hosted-loop.js +43 -2
  10. package/dist/hosted-run-event-sink.js +49 -10
  11. package/dist/hosted-session-cli.js +4 -0
  12. package/dist/hosted-skill-staging.js +89 -0
  13. package/dist/index.js +64 -62
  14. package/dist/manifests-cli.js +7 -2
  15. package/dist/preview-runtime.js +21 -4
  16. package/dist/prompt-draft-push.js +95 -0
  17. package/dist/report-comments-cli.js +9 -2
  18. package/dist/sandbox-provider.js +8 -2
  19. package/dist/scorer-draft-push.js +110 -0
  20. package/dist/workbench-cli.js +234 -6
  21. package/dist/workspace-sync.js +9 -2
  22. package/dist/workspace.js +15 -43
  23. package/dist/zip-draft-push.js +125 -0
  24. package/package.json +7 -9
  25. package/src/daytona-slice-rehearsal.ts +2 -2
  26. package/src/git-credential.ts +10 -7
  27. package/src/github-setup.ts +24 -2
  28. package/src/help.ts +18 -11
  29. package/src/hosted-boot.ts +227 -17
  30. package/src/hosted-bootstrap.ts +19 -43
  31. package/src/hosted-harness-opencode.ts +4 -2
  32. package/src/hosted-headless.ts +26 -8
  33. package/src/hosted-loop-lifecycle.ts +8 -0
  34. package/src/hosted-loop.ts +53 -7
  35. package/src/hosted-run-event-sink.ts +58 -11
  36. package/src/hosted-session-cli.ts +4 -0
  37. package/src/hosted-skill-staging.ts +126 -0
  38. package/src/index.ts +79 -72
  39. package/src/manifests-cli.ts +8 -2
  40. package/src/preview-runtime.ts +27 -4
  41. package/src/prompt-draft-push.ts +143 -0
  42. package/src/report-comments-cli.ts +10 -2
  43. package/src/sandbox-provider.ts +8 -2
  44. package/src/scorer-draft-push.ts +155 -0
  45. package/src/workbench-cli.ts +326 -6
  46. package/src/workspace-sync.ts +9 -2
  47. package/src/workspace.ts +15 -45
  48. package/src/zip-draft-push.ts +177 -0
  49. package/vendor/skills/orizu-cli/references/building-apps.md +2 -0
@@ -51,17 +51,17 @@ export function seedLocalBareRepo(baseDir, config) {
51
51
  const work = mkdtempSync(join(baseDir, 'seed-'));
52
52
  gitOrThrow(['init', '--initial-branch=main', work]);
53
53
  const ident = ['-c', 'user.email=seed@orizu.local', '-c', 'user.name=Seed'];
54
+ // ALI-1075: no `canonical`/`repoState` liveness fields — manifests carry
55
+ // machine-readable ids only; the DB label is the sole production pointer.
54
56
  const teamManifest = {
55
57
  schemaVersion: WORKSPACE_SCHEMA_VERSION,
56
58
  kind: 'team',
57
59
  slug: config.teamSlug,
58
- canonical: { owner: 'repo', repoState: 'source', objectRef: null },
59
60
  };
60
61
  const projectManifest = {
61
62
  schemaVersion: WORKSPACE_SCHEMA_VERSION,
62
63
  kind: 'project',
63
64
  slug: config.projectSlug,
64
- canonical: { owner: 'repo', repoState: 'draft', objectRef: null },
65
65
  };
66
66
  writeFileSync(join(work, 'orizu.team.json'), `${JSON.stringify(teamManifest, null, 2)}\n`);
67
67
  writeFileSync(join(work, '.gitignore'), '# Orizu workspace policy\n.orizu/\n');
@@ -45,17 +45,20 @@ export function resolveAttachedWorkspaceId(cwd) {
45
45
  const manifestPath = join(dir, 'orizu.team.json');
46
46
  if (existsSync(manifestPath)) {
47
47
  const manifest = readJsonManifest(manifestPath);
48
- const canonical = manifest?.canonical;
49
- const serviceId = canonical && typeof canonical === 'object' && !Array.isArray(canonical)
50
- ? canonical.serviceId
51
- : null;
52
- if (typeof serviceId === 'string' && serviceId)
53
- return serviceId;
48
+ // `setup.attachedWorkspaceId` is the attachment id; `canonical.serviceId`
49
+ // is a legacy duplicate kept only as a read fallback for old repos
50
+ // (ALI-1075: fresh manifests carry no `canonical` block).
54
51
  const setup = manifest?.setup;
55
52
  const attached = setup && typeof setup === 'object' && !Array.isArray(setup)
56
53
  ? setup.attachedWorkspaceId
57
54
  : null;
58
- return typeof attached === 'string' && attached ? attached : null;
55
+ if (typeof attached === 'string' && attached)
56
+ return attached;
57
+ const canonical = manifest?.canonical;
58
+ const serviceId = canonical && typeof canonical === 'object' && !Array.isArray(canonical)
59
+ ? canonical.serviceId
60
+ : null;
61
+ return typeof serviceId === 'string' && serviceId ? serviceId : null;
59
62
  }
60
63
  const parent = dirname(dir);
61
64
  if (parent === dir)
@@ -14,7 +14,7 @@
14
14
  * is immediately rewritten to a clean origin.
15
15
  */
16
16
  import { spawnSync } from 'child_process';
17
- import { mkdirSync } from 'fs';
17
+ import { existsSync, mkdirSync } from 'fs';
18
18
  import { join } from 'path';
19
19
  import { authedFetch } from './http.js';
20
20
  import { assertWorkspaceDirUsable } from './workspace.js';
@@ -196,6 +196,23 @@ export async function runHostedAttach(options, io) {
196
196
  }
197
197
  }
198
198
  const cleanUrl = `https://github.com/${repoFullName}.git`;
199
+ const ensureOrizuDir = io.ensureOrizuDir ?? ((repoDir) => mkdirSync(join(repoDir, '.orizu'), { recursive: true }));
200
+ // Idempotent re-run (ALI-1069): `orizu setup` in an ALREADY-attached workspace
201
+ // reaches here with `targetDir` holding the prior clone (its `.git` plus the
202
+ // committed orizu.team.json — `assertWorkspaceDirUsable` deliberately exempts
203
+ // that dir as the idempotent case). `git clone` refuses ANY non-empty dir, so
204
+ // an unconditional clone would abort the re-run with a misleading "not an empty
205
+ // directory" error. Short-circuit: re-affirm the credential helper config
206
+ // (idempotent) and return without recloning.
207
+ const repoAlreadyCloned = io.repoAlreadyCloned ?? ((dir) => existsSync(join(dir, '.git')));
208
+ if (repoAlreadyCloned(options.targetDir)) {
209
+ io.print(' Workbench repo already attached — skipping clone.');
210
+ gitOrThrow(io.git, ['config', 'credential.helper', CREDENTIAL_HELPER], { cwd: options.targetDir });
211
+ gitOrThrow(io.git, ['config', 'credential.useHttpPath', 'true'], { cwd: options.targetDir });
212
+ ensureOrizuDir(options.targetDir);
213
+ io.print(' Attached. Configured the Orizu credential helper for git.');
214
+ return { repoFullName, provisioned, targetDir: options.targetDir };
215
+ }
199
216
  io.print(` Cloning ${repoFullName}...`);
200
217
  // Clone with the helper inline + the workspace id in the env so the token is
201
218
  // brokered on demand and never materializes in the URL or on disk.
@@ -211,7 +228,6 @@ export async function runHostedAttach(options, io) {
211
228
  // Persist the helper repo-locally for future pull/push (cwd-resolved).
212
229
  gitOrThrow(io.git, ['config', 'credential.helper', CREDENTIAL_HELPER], { cwd: options.targetDir });
213
230
  gitOrThrow(io.git, ['config', 'credential.useHttpPath', 'true'], { cwd: options.targetDir });
214
- const ensureOrizuDir = io.ensureOrizuDir ?? ((repoDir) => mkdirSync(join(repoDir, '.orizu'), { recursive: true }));
215
231
  ensureOrizuDir(options.targetDir);
216
232
  io.print(' Attached. Configured the Orizu credential helper for git.');
217
233
  return { repoFullName, provisioned, targetDir: options.targetDir };
package/dist/help.js CHANGED
@@ -318,8 +318,8 @@ export const COMMAND_DOCS = [
318
318
  },
319
319
  {
320
320
  path: ['prompts', 'push'],
321
- usage: 'orizu prompts push <dir> [--runner-version <id>] [--project <team/project>] [--parent <version-id>] [--json]',
322
- summary: 'Push a local prompt artifact directory as a new version.',
321
+ usage: 'orizu prompts push <dir> [--runner-version <id>] [--project <team/project>] [--parent <version-id>] [--session <session-id>] [--json]',
322
+ summary: 'Push a local prompt artifact directory as a new version (with --session: a commit-first git draft).',
323
323
  group: 'Prompts and judges',
324
324
  },
325
325
  {
@@ -354,8 +354,8 @@ export const COMMAND_DOCS = [
354
354
  },
355
355
  {
356
356
  path: ['judges', 'push'],
357
- usage: 'orizu judges push <dir> [--runner-version <id>] [--project <team/project>] [--parent <version-id>] [--json]',
358
- summary: 'Push a local judge artifact directory as a new version.',
357
+ usage: 'orizu judges push <dir> [--runner-version <id>] [--project <team/project>] [--parent <version-id>] [--session <session-id>] [--json]',
358
+ summary: 'Push a local judge artifact directory as a new version (with --session: a commit-first git draft).',
359
359
  group: 'Prompts and judges',
360
360
  },
361
361
  {
@@ -747,23 +747,25 @@ export const COMMAND_DOCS = [
747
747
  },
748
748
  {
749
749
  path: ['session', 'start'],
750
- usage: 'orizu session start [--project <team/project>] [--json]',
751
- summary: 'Start a durable workspace session for the attached workspace and print the session id for later resume.',
750
+ usage: 'orizu session start [--project <team/project>] [--workspace <dir>] [--json]',
751
+ summary: 'Start a durable workspace session and print the session id. When run inside the workbench clone (or with --workspace), the remote session branch is fetched and checked out locally, and the exact next git commands are printed.',
752
752
  group: 'Sessions',
753
753
  options: [
754
754
  { name: '--project <team/project>', help: 'Optionally scope the session to a project in the attached workspace.' },
755
+ { name: '--workspace <dir>', help: 'Workbench clone directory when not running from inside it.' },
755
756
  { name: '--json', help: 'Emit the machine-readable session payload.' },
756
757
  ],
757
758
  examples: ['orizu session start', 'orizu session start --project highlight/hip --json'],
758
759
  },
759
760
  {
760
761
  path: ['session', 'status'],
761
- usage: 'orizu session status [--session <id> | --status active|ended] [--json]',
762
- summary: 'Inspect one session by id, including run summaries, or list sessions for the attached workspace.',
762
+ usage: 'orizu session status [--session <id> | --status active|ended] [--workspace <dir>] [--json]',
763
+ summary: 'Inspect one session by id (including run summaries and, when run inside the workbench clone, the session branch with local ahead/behind vs origin), or list sessions for the attached workspace.',
763
764
  group: 'Sessions',
764
765
  options: [
765
766
  { name: '--session <id>', help: 'Resume by session id; does not require the original terminal.' },
766
767
  { name: '--status <status>', help: 'Filter attached-workspace sessions when --session is omitted.', choices: ['active', 'ended'] },
768
+ { name: '--workspace <dir>', help: 'Workbench clone directory when not running from inside it.' },
767
769
  { name: '--json', help: 'Emit the machine-readable session or session-list payload.' },
768
770
  ],
769
771
  examples: ['orizu session status --session sess_123 --json', 'orizu session status --status active'],
@@ -781,15 +783,18 @@ export const COMMAND_DOCS = [
781
783
  },
782
784
  {
783
785
  path: ['session', 'finish'],
784
- usage: 'orizu session finish --session <id> [--project <team/project>] [--json]',
785
- summary: 'Finish a hosted session branch: no changes deletes the branch; changes create a repo_merge promotion manifest to review and approve (no GitHub PR).',
786
+ usage: 'orizu session finish --session <id> [--project <team/project>] [--push [--message <text>]] [--workspace <dir>] [--json]',
787
+ summary: 'Finish a hosted session branch: no changes deletes the branch; changes create a repo_merge promotion manifest to review and approve (no GitHub PR). Warns when your local session-branch checkout has uncommitted/unpushed work; --push stages, commits, and pushes it first.',
786
788
  group: 'Sessions',
787
789
  options: [
788
790
  { name: '--session <id>', help: 'Session id whose branch to finish.', required: true },
789
791
  { name: '--project <team/project>', help: 'Project scope for the manifest when the session is not already project-scoped.' },
792
+ { name: '--push', help: 'Opt-in: stage, commit, and push local session-branch work before finishing (raw git stays the default workflow).' },
793
+ { name: '--message <text>', help: 'Commit message for --push (a default is used when omitted).' },
794
+ { name: '--workspace <dir>', help: 'Workbench clone directory when not running from inside it.' },
790
795
  { name: '--json', help: 'Emit the machine-readable outcome (no-changes or the created manifest).' },
791
796
  ],
792
- examples: ['orizu session finish --session sess_123', 'orizu session finish --session sess_123 --project highlight/hip --json'],
797
+ examples: ['orizu session finish --session sess_123', 'orizu session finish --session sess_123 --push --message "tune judge prompt"'],
793
798
  },
794
799
  {
795
800
  path: ['run', 'start'],
@@ -34,6 +34,8 @@ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, writeSync } f
34
34
  import { AGENT_GIT_IDENTITY, BEARER_BASENAME, BOOT_CONTEXT_BASENAME, DEFAULT_CACHE_REFRESH_BUFFER_MS, HELPER_SCRIPT_BASENAME, REPO_CRED_CACHE_BASENAME, renderCredentialHelperScript, serializeBootContext, } from './hosted-runtime-assets.js';
35
35
  import { INJECTED_ENV_VARS_ENV, runHostedLoop, } from './hosted-loop.js';
36
36
  import { DEFAULT_EGRESS_CANARY_HOST } from './hosted-loop-lifecycle.js';
37
+ import { resumeRunEventSink } from './hosted-run-event-sink.js';
38
+ import { stageOrizuSkill } from './hosted-skill-staging.js';
37
39
  /** Non-secret placeholder model key (firewall brokers the real org key on
38
40
  * egress). Byte-for-byte the operator path's constant
39
41
  * (packages/cli/src/hosted-session-cli.ts `ANTHROPIC_DUMMY_KEY` and the
@@ -72,6 +74,10 @@ export function resolveHostedBootEnv(env) {
72
74
  value: {
73
75
  bootSecret,
74
76
  agentTokenUrl,
77
+ // The boot-status route is the agent-token route's sibling on the same
78
+ // coordinator, same boot-secret auth (ALI-1060). Deriving it here means no
79
+ // new env var in the frozen contract / planSandboxEnv.
80
+ bootStatusUrl: deriveBootStatusUrl(agentTokenUrl),
75
81
  envBundleUrl,
76
82
  baseUrl: baseUrl.replace(/\/+$/, ''),
77
83
  sessionId,
@@ -81,6 +87,51 @@ export function resolveHostedBootEnv(env) {
81
87
  },
82
88
  };
83
89
  }
90
+ /** The boot-status route is the agent-token route's sibling; swap the trailing
91
+ * path segment. Falls back to appending when the shape is unexpected. */
92
+ export function deriveBootStatusUrl(agentTokenUrl) {
93
+ if (/\/agent-token\/?$/.test(agentTokenUrl)) {
94
+ return agentTokenUrl.replace(/\/agent-token\/?$/, '/boot-status');
95
+ }
96
+ return `${agentTokenUrl.replace(/\/+$/, '')}/boot-status`;
97
+ }
98
+ // -- Boot-status callback (ALI-1060) ------------------------------------------
99
+ /** Max chars of the failure reason reported to the coordinator. A boot reason
100
+ * is never a place for a secret; this bounds it anyway, and we scrub the boot
101
+ * secret defensively before sending. */
102
+ const MAX_BOOT_REASON_CHARS = 800;
103
+ /** Best-effort scrub + truncate for a reported failure reason: never leak the
104
+ * boot secret (the one credential the boot always holds), and keep it short. */
105
+ export function redactBootReason(reason, bootSecret) {
106
+ let out = reason;
107
+ if (bootSecret && out.includes(bootSecret)) {
108
+ out = out.split(bootSecret).join('[redacted]');
109
+ }
110
+ return out.slice(0, MAX_BOOT_REASON_CHARS);
111
+ }
112
+ /**
113
+ * Report the boot outcome to the coordinator's boot-status route (ALI-1060),
114
+ * authed with the boot secret (same as the agent-token pull). Best-effort: the
115
+ * DO's own readiness-timeout is the backstop if this never lands, so a failed
116
+ * report must never mask the boot result. NEVER logs the secret.
117
+ */
118
+ export async function postBootStatus(opts) {
119
+ try {
120
+ const res = await opts.fetchImpl(opts.bootStatusUrl, {
121
+ method: 'POST',
122
+ headers: { Authorization: `Bearer ${opts.bootSecret}`, 'Content-Type': 'application/json' },
123
+ body: JSON.stringify({
124
+ status: opts.status,
125
+ ...(opts.runId ? { runId: opts.runId } : {}),
126
+ ...(opts.reason ? { reason: opts.reason } : {}),
127
+ }),
128
+ });
129
+ opts.log?.(`boot-status ${opts.status} reported (${res.status})`);
130
+ }
131
+ catch (error) {
132
+ opts.log?.(`boot-status ${opts.status} report failed: ${error instanceof Error ? error.message : String(error)}`);
133
+ }
134
+ }
84
135
  /**
85
136
  * Exchange the boot secret for a fresh Orizu agent bearer at the coordinator's
86
137
  * agent-token route (`GET`, `Authorization: Bearer <boot secret>` → 200
@@ -187,6 +238,9 @@ async function bearerJson(fetchImpl, url, bearer, init = {}) {
187
238
  function asString(value) {
188
239
  return typeof value === 'string' && value.length > 0 ? value : null;
189
240
  }
241
+ function asPositiveNumber(value) {
242
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
243
+ }
190
244
  /** GET the session (agent-capable, RLS-scoped) to learn its workspace, branch,
191
245
  * task, and any existing run — the DO start body carries none of these. */
192
246
  export async function resolveSession(opts) {
@@ -210,6 +264,7 @@ export async function resolveSession(opts) {
210
264
  task,
211
265
  model: asString(clientInfo.model),
212
266
  reasoningEffort: asString(clientInfo.reasoningEffort),
267
+ durationMinutes: asPositiveNumber(clientInfo.durationMinutes),
213
268
  runId,
214
269
  };
215
270
  }
@@ -233,8 +288,7 @@ export async function ensureRun(opts) {
233
288
  return runId;
234
289
  }
235
290
  /** Mint a session_read repo token to learn the repo full name, then build the
236
- * GitHub clone URL (mirrors the operator path's `defaultResolveRepo`). The
237
- * minted token is best-effort revoked — the credential helper mints its own. */
291
+ * GitHub clone URL (mirrors the operator path's `defaultResolveRepo`). */
238
292
  export async function resolveRepo(opts) {
239
293
  const url = `${opts.baseUrl}/api/cli/workspaces/${encodeURIComponent(opts.workspaceId)}/repo-token`;
240
294
  const minted = await bearerJson(opts.fetchImpl, url, opts.bearer, {
@@ -245,20 +299,13 @@ export async function resolveRepo(opts) {
245
299
  const repoFullName = asString(minted.repo);
246
300
  if (!repoFullName)
247
301
  throw new Error('repo-token response carried no repo');
248
- const token = asString(minted.token);
249
- const mintId = asString(minted.mintId);
250
- if (token && mintId) {
251
- try {
252
- await opts.fetchImpl(url, {
253
- method: 'DELETE',
254
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opts.bearer}` },
255
- body: JSON.stringify({ mintId, token }),
256
- });
257
- }
258
- catch {
259
- // best-effort revoke — the short TTL is the backstop.
260
- }
261
- }
302
+ // We deliberately do NOT early-revoke this probe token (ALI-1069): the
303
+ // repo-token DELETE route is HUMAN-ONLY (`requireCliSupabase` default-denies
304
+ // agent bearers), so an agent-bearer revoke would always 401 — a guaranteed
305
+ // failed request per boot, not a real revoke. Let the short (~60-min)
306
+ // session_read TTL expire on its own; the credential helper mints its own
307
+ // per-op tokens for the actual clone/fetch/push, so this probe token is never
308
+ // reused after we read `repo` from it.
262
309
  return { repoFullName, cloneUrl: `https://github.com/${repoFullName}.git` };
263
310
  }
264
311
  const defaultExec = (cmd, args, opts) => {
@@ -274,6 +321,21 @@ function assertSafeGitValue(name, value) {
274
321
  throw new Error(`unsafe characters in ${name}; refusing to pass to git`);
275
322
  }
276
323
  }
324
+ /**
325
+ * Path-safety for the run id BEFORE it is interpolated into `.orizu-run/${runId}`
326
+ * (ALI-1060). ORIZU_RUN_ID can be CALLER-SUPPLIED (resume flows), and unlike the
327
+ * boot-created uuid it is untrusted — a `../` value would escape the run dir and
328
+ * let boot assets (0600 bearer/boot-secret files) land outside it. A run id is a
329
+ * single opaque segment: allow only `[A-Za-z0-9._-]` and reject any `..`, so no
330
+ * separators or traversal survive. Stricter than the operator path's shared
331
+ * shell-value assertion (which allows `/`), applied to the same value.
332
+ */
333
+ const SAFE_RUN_ID = /^[A-Za-z0-9._-]+$/;
334
+ export function assertSafeRunId(runId) {
335
+ if (!SAFE_RUN_ID.test(runId) || runId.includes('..')) {
336
+ throw new Error('unsafe run id; refusing to build run-dir paths');
337
+ }
338
+ }
277
339
  function writeSecretFile(path, contents, write) {
278
340
  write(path, contents.endsWith('\n') ? contents : `${contents}\n`);
279
341
  }
@@ -334,6 +396,12 @@ export async function runHostedBoot(opts) {
334
396
  fetchImpl,
335
397
  existingRunId: env.runId ?? session.runId,
336
398
  });
399
+ // Path-safety BEFORE the run id reaches any `.orizu-run/${runId}` path
400
+ // (ALI-1060): a caller-supplied ORIZU_RUN_ID must not escape the run dir.
401
+ assertSafeRunId(runId);
402
+ // Hand the run id + bearer to the caller so a throw AFTER this point can mark
403
+ // the run failed + report the boot failure (the DO is the backstop otherwise).
404
+ opts.onBootContext?.({ runId, bearer: bearer.token });
337
405
  const repo = await resolveRepo({ baseUrl: env.baseUrl, workspaceId, sessionId: env.sessionId, bearer: bearer.token, fetchImpl });
338
406
  log(`resolved run ${runId} on ${repo.repoFullName}@${session.repoBranch}`);
339
407
  // 4 — Write the run-scoped assets: boot secret + initial bearer (0600), the
@@ -398,6 +466,37 @@ export async function runHostedBoot(opts) {
398
466
  exec('git', ['-C', workspaceDir, 'config', 'user.name', AGENT_GIT_IDENTITY.name]);
399
467
  exec('git', ['-C', workspaceDir, 'config', 'user.email', AGENT_GIT_IDENTITY.email]);
400
468
  log(`cloned ${session.repoBranch}`);
469
+ // 5b — Stage the orizu-cli skill into the cloned repo so the agent discovers the
470
+ // Orizu workflows (ALI-1059). SHARED with the operator path via `stageOrizuSkill`
471
+ // (one resolution chain + the harvest-safe .git/info/exclude append). Non-fatal:
472
+ // a staging failure is logged, never aborts the boot. The DO `BootExec` is
473
+ // shell-less, so the staging script runs under `sh -c`.
474
+ try {
475
+ const skillStage = await stageOrizuSkill({
476
+ workspaceDir,
477
+ exec: async (command) => {
478
+ const res = exec('sh', ['-c', command]);
479
+ return { exitCode: res.status, stdout: res.stdout, stderr: res.stderr ?? '' };
480
+ },
481
+ });
482
+ log(`orizu-cli skill staged: ${skillStage.ok ? skillStage.method : 'unresolved'}`);
483
+ }
484
+ catch (error) {
485
+ log(`orizu-cli skill staging failed: ${error instanceof Error ? error.message : String(error)}`);
486
+ }
487
+ // ALI-1060: the agent is now genuinely LIVE — bearer minted, connectors
488
+ // applied, session/run resolved, repo cloned, skill staged. Signal the
489
+ // coordinator's readiness gate BEFORE the loop so the DO stops waiting (and
490
+ // never tears down a healthy box on timeout). Best-effort; the DO's timeout is
491
+ // the backstop.
492
+ await postBootStatus({
493
+ bootStatusUrl: env.bootStatusUrl,
494
+ bootSecret: env.bootSecret,
495
+ status: 'ready',
496
+ runId,
497
+ fetchImpl,
498
+ log,
499
+ });
401
500
  // 6 — Launch the hosted loop, reused in-process. The bearer PROVIDER keeps the
402
501
  // event sink's bearer fresh from the same pull-mode source; the DO-provisioned
403
502
  // sandbox always runs the pre-baked image under an enforced egress policy, so
@@ -414,6 +513,10 @@ export async function runHostedBoot(opts) {
414
513
  messageId: `${runId}:task`,
415
514
  author: AGENT_GIT_IDENTITY,
416
515
  anthropicDummyKey: env.anthropicDummyKey ?? ANTHROPIC_DUMMY_KEY,
516
+ // Derive the per-prompt max-duration cap from the session duration so a long
517
+ // run is not killed at the hard-coded 90-min prompt cap (ALI-1061). Unset →
518
+ // the harness default (5400s) floor holds.
519
+ sandboxBudgetMs: session.durationMinutes != null ? session.durationMinutes * 60 * 1000 : undefined,
417
520
  prebaked: true,
418
521
  egressCanaryHost: DEFAULT_EGRESS_CANARY_HOST,
419
522
  runSetupHook: true,
@@ -518,10 +621,18 @@ export async function hostedBootCommand(io) {
518
621
  '(the DO sandbox env contract — see planSandboxEnv)');
519
622
  return 1;
520
623
  }
624
+ const env = resolved.value;
625
+ const log = (line) => io.printErr?.(`[hosted-boot] ${line}`);
626
+ // Captured once the run id + bearer are known, so a LATER throw can mark the
627
+ // run failed with an authenticated bearer (ALI-1060).
628
+ let bootCtx = null;
521
629
  try {
522
630
  const result = await runHostedBoot({
523
- env: resolved.value,
524
- log: line => io.printErr?.(`[hosted-boot] ${line}`),
631
+ env,
632
+ log,
633
+ onBootContext: ctx => {
634
+ bootCtx = ctx;
635
+ },
525
636
  });
526
637
  io.print(io.json
527
638
  ? JSON.stringify({ ok: result.ok, runId: result.runId, status: result.loopStatus, error: result.error })
@@ -531,6 +642,72 @@ export async function hostedBootCommand(io) {
531
642
  catch (error) {
532
643
  const message = error instanceof Error ? error.message : String(error);
533
644
  io.printErr?.(`hosted-boot failed: ${message}`);
645
+ // A boot THROW (before/around ensureRun) means the agent never came alive.
646
+ // Report it so the DO stops + destroys the sandbox NOW instead of waiting
647
+ // for the readiness timeout, and mark the run failed so it never stays
648
+ // 'running' forever (the DO also marks it — both are idempotent).
649
+ await reportBootFailure({ env, ctx: bootCtx, reason: message, log });
534
650
  return 1;
535
651
  }
536
652
  }
653
+ /**
654
+ * Failure fan-out on a boot throw (ALI-1060): mark the run terminally `failed`
655
+ * (the operator path's agent-bearer terminal PATCH) and always signal the
656
+ * coordinator's boot-status route so the DO tears the sandbox down promptly.
657
+ * Best-effort throughout — the boot secret is scrubbed from the reported reason.
658
+ *
659
+ * PRE-BEARER failures (a throw before `onBootContext` — `pullAgentBearer` /
660
+ * `fetchEnvBundle` blew up, so `ctx` is null) still have a run id: the server
661
+ * PRE-CREATES it and injects ORIZU_RUN_ID. We attempt a SHORT bearer pull here
662
+ * so we can self-mark that run failed rather than leaving it `running` until the
663
+ * DO's readiness timeout. If even that pull fails, the DO backstop (boot-status
664
+ * failure now, readiness timeout otherwise) is the last line.
665
+ */
666
+ export async function reportBootFailure(opts) {
667
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
668
+ const reason = redactBootReason(opts.reason, opts.env.bootSecret);
669
+ // The run id is known from the boot context, or (pre-created / resume flows)
670
+ // straight from the env contract.
671
+ const runId = opts.ctx?.runId ?? opts.env.runId;
672
+ // Prefer the bearer we already hold; otherwise pull a fresh one (bounded — the
673
+ // DO is the backstop) so a pre-bearer failure can still self-mark the run.
674
+ let bearer = opts.ctx?.bearer ?? null;
675
+ if (!bearer && runId) {
676
+ try {
677
+ bearer = (await pullAgentBearer({
678
+ agentTokenUrl: opts.env.agentTokenUrl,
679
+ bootSecret: opts.env.bootSecret,
680
+ fetchImpl,
681
+ attempts: 2,
682
+ log: opts.log,
683
+ })).token;
684
+ }
685
+ catch (error) {
686
+ opts.log(`failure-path bearer pull failed: ${error instanceof Error ? error.message : String(error)}`);
687
+ }
688
+ }
689
+ if (bearer && runId) {
690
+ try {
691
+ const sink = await resumeRunEventSink({
692
+ apiBaseUrl: opts.env.baseUrl,
693
+ runId,
694
+ bearer,
695
+ fetchImpl,
696
+ });
697
+ await sink.finish('failed', { summary: { error: reason } });
698
+ opts.log('marked run failed');
699
+ }
700
+ catch (error) {
701
+ opts.log(`run-failed mark failed: ${error instanceof Error ? error.message : String(error)}`);
702
+ }
703
+ }
704
+ await postBootStatus({
705
+ bootStatusUrl: opts.env.bootStatusUrl,
706
+ bootSecret: opts.env.bootSecret,
707
+ status: 'failed',
708
+ runId,
709
+ reason,
710
+ fetchImpl,
711
+ log: opts.log,
712
+ });
713
+ }
@@ -22,6 +22,7 @@
22
22
  * build ON TOP of this — `bootstrapHostedSandbox` is the single writer for the
23
23
  * run at this stage and returns the run cursor so a follow-on can continue it.
24
24
  */
25
+ import { stageOrizuSkill } from './hosted-skill-staging.js';
25
26
  import { sweepForTokenResidue } from './sandbox-hygiene.js';
26
27
  import { redactSecrets } from './secret-redaction.js';
27
28
  import { AGENT_GIT_IDENTITY, BEARER_BASENAME, BOOT_CONTEXT_BASENAME, DEFAULT_CACHE_REFRESH_BUFFER_MS, HELPER_SCRIPT_BASENAME, PREBAKED_MARKER_PATH, REPO_CRED_CACHE_BASENAME, SETUP_HOOK_RELATIVE_PATH, parsePrebakedMarker, renderCredentialHelperScript, serializeBootContext, } from './hosted-runtime-assets.js';
@@ -266,48 +267,25 @@ export async function bootstrapHostedSandbox(opts) {
266
267
  });
267
268
  }
268
269
  // 4b — Stage the orizu-cli skill into the workspace so the agent discovers it
269
- // (ALI-1044). Resolve the CLI's OWN vendored skill dir in-sandbox, preferring:
270
- // (1) $ORIZU_SKILL_SOURCE_DIR — explicit override (also honored by the CLI's
271
- // skill-installer; the local-sim rehearsal sets it),
272
- // (2) `orizu skills path` — the packaged vendor/skills/orizu-cli of the
273
- // globally-installed CLI (the production path),
274
- // (3) `$(npm root -g)/orizu/vendor/skills/orizu-cli` — belt fallback.
275
- // Then SYMLINK it under <workspaceDir>/.claude/skills/orizu-cli, falling back
276
- // to a copy if the symlink cannot be created. Non-fatal + recorded, exactly
277
- // like the CLI install. Resolved paths stay in shell vars (never interpolated),
278
- // so they cannot inject into the command.
270
+ // (ALI-1044). SHARED with the DO path (ALI-1059): the resolution chain + the
271
+ // .git/info/exclude append live in `stageOrizuSkill`; here we only adapt the
272
+ // sandbox exec seam and record the outcome. Non-fatal + recorded, exactly like
273
+ // the CLI install.
279
274
  currentStep = 'skill_staged';
280
- const skillSkillsDirRel = `${workspaceDir}/.claude/skills`;
281
- const stage = await session.exec([
282
- `mkdir -p ${skillSkillsDirRel}`,
283
- // The staged skill is bootstrap-injected RUNTIME scaffolding (a symlink to
284
- // the sandbox-local CLI vendor dir), NOT the agent's work. Exclude it
285
- // repo-LOCALLY (.git/info/exclude — invisible to the diff, never committed)
286
- // so auto-harvest's `git add -A` can't sweep it into the session branch and
287
- // (ALI-1051) auto-apply a broken symlink to the customer's main.
288
- `if [ -d ${workspaceDir}/.git ]; then printf '%s\\n' '/.claude/skills/' >> ${workspaceDir}/.git/info/exclude; fi`,
289
- `src="${'${ORIZU_SKILL_SOURCE_DIR:-}'}"`,
290
- `if [ -z "$src" ] || [ ! -d "$src" ]; then src="$(orizu skills path 2>/dev/null || true)"; fi`,
291
- `if [ -z "$src" ] || [ ! -d "$src" ]; then r="$(npm root -g 2>/dev/null || true)"; if [ -n "$r" ] && [ -d "$r/orizu/vendor/skills/orizu-cli" ]; then src="$r/orizu/vendor/skills/orizu-cli"; fi; fi`,
292
- `if [ -z "$src" ] || [ ! -d "$src" ]; then echo "NO_SOURCE"; exit 0; fi`,
293
- `dest="${skillSkillsDirRel}/orizu-cli"`,
294
- `rm -rf "$dest"`,
295
- `if ln -s "$src" "$dest" 2>/dev/null; then echo "SYMLINK $src"; else cp -R "$src" "$dest" && echo "COPY $src"; fi`,
296
- ].join('\n'));
297
- const stageOut = stage.stdout.trim();
298
- const stagedMethod = stageOut.startsWith('SYMLINK')
299
- ? 'symlink'
300
- : stageOut.startsWith('COPY')
301
- ? 'copy'
302
- : null;
303
- const stagedOk = stage.exitCode === 0 && stagedMethod !== null;
304
- record('skill_staged', stagedOk, stagedOk
305
- ? `orizu-cli skill staged (${stagedMethod})`
306
- : `skill staging unresolved: ${stageOut || `exit ${stage.exitCode}`}`);
275
+ const stage = await stageOrizuSkill({
276
+ workspaceDir,
277
+ exec: async (command) => {
278
+ const res = await session.exec(command);
279
+ return { exitCode: res.exitCode, stdout: res.stdout, stderr: res.stderr ?? '' };
280
+ },
281
+ });
282
+ record('skill_staged', stage.ok, stage.ok
283
+ ? `orizu-cli skill staged (${stage.method})`
284
+ : `skill staging unresolved: ${stage.stdout.trim() || `exit ${stage.exitCode}`}`);
307
285
  await sink.append('skill_staged', {
308
- ok: stagedOk,
309
- method: stagedMethod,
310
- dest: `${skillSkillsDirRel}/orizu-cli`,
286
+ ok: stage.ok,
287
+ method: stage.method,
288
+ dest: stage.dest,
311
289
  outputTail: tail(stage.stdout, stage.stderr),
312
290
  });
313
291
  // 5 — Customer setup hook (fresh-boot only, non-fatal, output captured).
@@ -62,8 +62,10 @@ const DEFAULT_SSE_INACTIVITY_MS = 120_000;
62
62
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
63
63
  // Wall-clock ceiling on a single prompt (port of bridge.py PROMPT_MAX_DURATION,
64
64
  // 5400s). Runs alongside the inactivity deadline: inactivity catches a stalled
65
- // stream, this catches a stream that stays busy forever.
66
- const DEFAULT_PROMPT_MAX_DURATION_MS = 5_400_000;
65
+ // stream, this catches a stream that stays busy forever. Exported so the hosted
66
+ // loop can use it as the FLOOR when deriving a duration-aware cap (ALI-1061):
67
+ // the cap is never shortened below this default for short/undated runs.
68
+ export const DEFAULT_PROMPT_MAX_DURATION_MS = 5_400_000;
67
69
  // Placeholder titles OpenCode auto-assigns ("New session - <ISO8601>Z" /
68
70
  // "Child session - <ISO8601>Z"); noise, never forwarded (port of bridge.py
69
71
  // OPENCODE_DEFAULT_TITLE_RE).
@@ -26,7 +26,7 @@ export class AgentStalledError extends Error {
26
26
  }
27
27
  }
28
28
  /** Render the idle window for the error message. Minutes (the operator-facing
29
- * unit; default window is 10m), rounded to one decimal so sub-minute test
29
+ * unit; default window is 25m), rounded to one decimal so sub-minute test
30
30
  * windows still read sensibly. */
31
31
  export function formatIdleWindow(ms) {
32
32
  const minutes = Math.round((ms / 60000) * 10) / 10;
@@ -34,19 +34,26 @@ export function formatIdleWindow(ms) {
34
34
  }
35
35
  const IDLE_SENTINEL = Symbol('idle');
36
36
  /**
37
- * Wrap `source` so that if no value arrives within `timeoutMs`, `onTimeout` runs
38
- * and `AgentStalledError` is thrown. The idle timer resets on EVERY value, so it
39
- * measures gaps between events (progress), not total duration. The still-pending
40
- * `next()` is not left as an unhandled rejection, and the underlying iterator is
37
+ * Wrap `source` so that if no PROGRESS arrives within `timeoutMs`, `onTimeout`
38
+ * runs and `AgentStalledError` is thrown. The idle deadline advances only on
39
+ * values `opts.isProgress` accepts (default: all), so it measures gaps between
40
+ * real progress events, not total duration — a stream of non-progress values
41
+ * (e.g. `question_auto_answered` deny-path events) passes through WITHOUT
42
+ * resetting the timer, so a stuck loop still stalls. The still-pending `next()`
43
+ * is not left as an unhandled rejection, and the underlying iterator is
41
44
  * returned/closed on any early exit.
42
45
  */
43
46
  export async function* withIdleWatchdog(source, opts) {
44
47
  const iterator = source[Symbol.asyncIterator]();
48
+ const isProgress = opts.isProgress ?? (() => true);
49
+ // Absolute deadline so a non-progress value can be yielded WITHOUT extending
50
+ // the window (each iteration arms the timer for whatever remains).
51
+ let deadline = Date.now() + opts.timeoutMs;
45
52
  try {
46
53
  for (;;) {
47
54
  let timer;
48
55
  const idle = new Promise(resolve => {
49
- timer = setTimeout(() => resolve(IDLE_SENTINEL), opts.timeoutMs);
56
+ timer = setTimeout(() => resolve(IDLE_SENTINEL), Math.max(0, deadline - Date.now()));
50
57
  });
51
58
  const nextResult = iterator.next();
52
59
  let raced;
@@ -66,6 +73,10 @@ export async function* withIdleWatchdog(source, opts) {
66
73
  }
67
74
  if (raced.done)
68
75
  return;
76
+ // Only genuine progress pushes the deadline out; non-progress values are
77
+ // relayed but leave the existing deadline intact.
78
+ if (isProgress(raced.value))
79
+ deadline = Date.now() + opts.timeoutMs;
69
80
  yield raced.value;
70
81
  }
71
82
  }