orizu 0.5.10 → 0.5.12

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 (57) hide show
  1. package/dist/credentials.js +37 -0
  2. package/dist/daytona-slice-rehearsal.js +2 -2
  3. package/dist/deploy-key-merge-core.js +230 -0
  4. package/dist/git-credential.js +10 -7
  5. package/dist/github-setup.js +18 -2
  6. package/dist/help.js +16 -11
  7. package/dist/hosted-boot.js +214 -18
  8. package/dist/hosted-bootstrap.js +18 -40
  9. package/dist/hosted-harness-opencode.js +4 -2
  10. package/dist/hosted-headless.js +17 -6
  11. package/dist/hosted-loop.js +43 -2
  12. package/dist/hosted-run-event-sink.js +49 -10
  13. package/dist/hosted-session-cli.js +10 -1
  14. package/dist/hosted-skill-staging.js +89 -0
  15. package/dist/index.js +79 -74
  16. package/dist/manifests-cli.js +84 -7
  17. package/dist/merge-job.js +325 -0
  18. package/dist/preview-runtime.js +21 -4
  19. package/dist/prompt-draft-push.js +95 -0
  20. package/dist/report-comments-cli.js +9 -2
  21. package/dist/sandbox-provider.js +8 -2
  22. package/dist/scorer-draft-push.js +110 -0
  23. package/dist/workbench-cli.js +312 -8
  24. package/dist/workspace-sync.js +9 -2
  25. package/dist/workspace.js +15 -43
  26. package/dist/zip-draft-push.js +125 -0
  27. package/package.json +7 -9
  28. package/src/credentials.ts +40 -0
  29. package/src/daytona-slice-rehearsal.ts +2 -2
  30. package/src/deploy-key-merge-core.ts +326 -0
  31. package/src/git-credential.ts +10 -7
  32. package/src/github-setup.ts +24 -2
  33. package/src/help.ts +18 -11
  34. package/src/hosted-boot.ts +249 -17
  35. package/src/hosted-bootstrap.ts +19 -43
  36. package/src/hosted-harness-opencode.ts +4 -2
  37. package/src/hosted-headless.ts +26 -8
  38. package/src/hosted-loop-lifecycle.ts +8 -0
  39. package/src/hosted-loop.ts +53 -7
  40. package/src/hosted-run-event-sink.ts +58 -11
  41. package/src/hosted-session-cli.ts +10 -1
  42. package/src/hosted-skill-staging.ts +126 -0
  43. package/src/index.ts +100 -86
  44. package/src/manifests-cli.ts +110 -11
  45. package/src/merge-job.ts +462 -0
  46. package/src/preview-runtime.ts +27 -4
  47. package/src/prompt-draft-push.ts +143 -0
  48. package/src/report-comments-cli.ts +10 -2
  49. package/src/sandbox-provider.ts +8 -2
  50. package/src/scorer-draft-push.ts +155 -0
  51. package/src/workbench-cli.ts +426 -10
  52. package/src/workspace-sync.ts +9 -2
  53. package/src/workspace.ts +15 -45
  54. package/src/zip-draft-push.ts +177 -0
  55. package/vendor/skills/orizu-cli/SKILL.md +1 -0
  56. package/vendor/skills/orizu-cli/references/building-apps.md +2 -0
  57. package/vendor/skills/orizu-cli/references/cli-reference.md +1 -0
@@ -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,
@@ -443,6 +546,25 @@ export async function runHostedBoot(opts) {
443
546
  bearerProvider: () => readFile(bearerFileAbs).trim(),
444
547
  });
445
548
  log(`hosted-loop finished: ${result.status}`);
549
+ // ALI-1064: the loop is TERMINAL — the auto-harvest (ALI-1036) and the
550
+ // run's terminal seal already ran INSIDE runHostedLoop, so it is safe to
551
+ // hand the session back. Report `complete` so the DO ends the workspace
552
+ // session server-side and stops/destroys the sandbox NOW instead of
553
+ // extending it until the 24h cap (live 2026-07-15: zombie boxes burned
554
+ // 2×CPU/4GiB for hours after their loops finished). This fires for a
555
+ // failed loop too — the run is terminal either way, and the loop already
556
+ // owns its own run-level terminal state (never a `failed` boot-status
557
+ // here; that would misreport a run failure as a bootstrap failure).
558
+ // Best-effort: the DO's duration/24h caps are the backstop.
559
+ await postBootStatus({
560
+ bootStatusUrl: env.bootStatusUrl,
561
+ bootSecret: env.bootSecret,
562
+ status: 'complete',
563
+ runId,
564
+ reason: result.error ? redactBootReason(result.error, env.bootSecret) : null,
565
+ fetchImpl,
566
+ log,
567
+ });
446
568
  return { ok: !result.error, runId, loopStatus: result.status, error: result.error };
447
569
  }
448
570
  finally {
@@ -518,10 +640,18 @@ export async function hostedBootCommand(io) {
518
640
  '(the DO sandbox env contract — see planSandboxEnv)');
519
641
  return 1;
520
642
  }
643
+ const env = resolved.value;
644
+ const log = (line) => io.printErr?.(`[hosted-boot] ${line}`);
645
+ // Captured once the run id + bearer are known, so a LATER throw can mark the
646
+ // run failed with an authenticated bearer (ALI-1060).
647
+ let bootCtx = null;
521
648
  try {
522
649
  const result = await runHostedBoot({
523
- env: resolved.value,
524
- log: line => io.printErr?.(`[hosted-boot] ${line}`),
650
+ env,
651
+ log,
652
+ onBootContext: ctx => {
653
+ bootCtx = ctx;
654
+ },
525
655
  });
526
656
  io.print(io.json
527
657
  ? JSON.stringify({ ok: result.ok, runId: result.runId, status: result.loopStatus, error: result.error })
@@ -531,6 +661,72 @@ export async function hostedBootCommand(io) {
531
661
  catch (error) {
532
662
  const message = error instanceof Error ? error.message : String(error);
533
663
  io.printErr?.(`hosted-boot failed: ${message}`);
664
+ // A boot THROW (before/around ensureRun) means the agent never came alive.
665
+ // Report it so the DO stops + destroys the sandbox NOW instead of waiting
666
+ // for the readiness timeout, and mark the run failed so it never stays
667
+ // 'running' forever (the DO also marks it — both are idempotent).
668
+ await reportBootFailure({ env, ctx: bootCtx, reason: message, log });
534
669
  return 1;
535
670
  }
536
671
  }
672
+ /**
673
+ * Failure fan-out on a boot throw (ALI-1060): mark the run terminally `failed`
674
+ * (the operator path's agent-bearer terminal PATCH) and always signal the
675
+ * coordinator's boot-status route so the DO tears the sandbox down promptly.
676
+ * Best-effort throughout — the boot secret is scrubbed from the reported reason.
677
+ *
678
+ * PRE-BEARER failures (a throw before `onBootContext` — `pullAgentBearer` /
679
+ * `fetchEnvBundle` blew up, so `ctx` is null) still have a run id: the server
680
+ * PRE-CREATES it and injects ORIZU_RUN_ID. We attempt a SHORT bearer pull here
681
+ * so we can self-mark that run failed rather than leaving it `running` until the
682
+ * DO's readiness timeout. If even that pull fails, the DO backstop (boot-status
683
+ * failure now, readiness timeout otherwise) is the last line.
684
+ */
685
+ export async function reportBootFailure(opts) {
686
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
687
+ const reason = redactBootReason(opts.reason, opts.env.bootSecret);
688
+ // The run id is known from the boot context, or (pre-created / resume flows)
689
+ // straight from the env contract.
690
+ const runId = opts.ctx?.runId ?? opts.env.runId;
691
+ // Prefer the bearer we already hold; otherwise pull a fresh one (bounded — the
692
+ // DO is the backstop) so a pre-bearer failure can still self-mark the run.
693
+ let bearer = opts.ctx?.bearer ?? null;
694
+ if (!bearer && runId) {
695
+ try {
696
+ bearer = (await pullAgentBearer({
697
+ agentTokenUrl: opts.env.agentTokenUrl,
698
+ bootSecret: opts.env.bootSecret,
699
+ fetchImpl,
700
+ attempts: 2,
701
+ log: opts.log,
702
+ })).token;
703
+ }
704
+ catch (error) {
705
+ opts.log(`failure-path bearer pull failed: ${error instanceof Error ? error.message : String(error)}`);
706
+ }
707
+ }
708
+ if (bearer && runId) {
709
+ try {
710
+ const sink = await resumeRunEventSink({
711
+ apiBaseUrl: opts.env.baseUrl,
712
+ runId,
713
+ bearer,
714
+ fetchImpl,
715
+ });
716
+ await sink.finish('failed', { summary: { error: reason } });
717
+ opts.log('marked run failed');
718
+ }
719
+ catch (error) {
720
+ opts.log(`run-failed mark failed: ${error instanceof Error ? error.message : String(error)}`);
721
+ }
722
+ }
723
+ await postBootStatus({
724
+ bootStatusUrl: opts.env.bootStatusUrl,
725
+ bootSecret: opts.env.bootSecret,
726
+ status: 'failed',
727
+ runId,
728
+ reason,
729
+ fetchImpl,
730
+ log: opts.log,
731
+ });
732
+ }
@@ -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
  }
@@ -39,7 +39,7 @@ import { join } from 'path';
39
39
  import { HOSTED_TASK_PREAMBLE, PREBAKED_MARKER_PATH, SETUP_HOOK_RELATIVE_PATH, composeHostedTaskPrompt, parsePrebakedMarker, } from './hosted-runtime-assets.js';
40
40
  import { harvestWorkspace } from './hosted-harvest.js';
41
41
  import { annotateHeadlessQuestions, withIdleWatchdog } from './hosted-headless.js';
42
- import { OPENCODE_PINNED_VERSION, createOpenCodeHarness, spawnOpenCode, } from './hosted-harness-opencode.js';
42
+ import { DEFAULT_PROMPT_MAX_DURATION_MS, OPENCODE_PINNED_VERSION, createOpenCodeHarness, spawnOpenCode, } from './hosted-harness-opencode.js';
43
43
  import { createClaudeAgentHarness } from './hosted-harness-claude.js';
44
44
  import { drainHarnessToSink, resumeRunEventSink, } from './hosted-run-event-sink.js';
45
45
  import { buildOpenCodeSpawnEnv, defaultProbeEgress, egressCanaryAllowedHost, runEgressCanary, } from './hosted-loop-lifecycle.js';
@@ -47,6 +47,24 @@ import { buildOpenCodeSpawnEnv, defaultProbeEgress, egressCanaryAllowedHost, run
47
47
  // (ALI-1015 Phase 1) so `lib/hosted-runtime/` can share it with a server-side
48
48
  // coordinator. Re-exported here so this module's public surface is unchanged.
49
49
  export { DEFAULT_EGRESS_CANARY_HOST, buildOpenCodeSpawnEnv, egressCanaryAllowedHost, runEgressCanary, } from './hosted-loop-lifecycle.js';
50
+ // Time reserved BEFORE the sandbox self-terminates so a duration-capped prompt
51
+ // abort still has room to harvest partial work (commit/push) + write the terminal
52
+ // event: `promptMaxDurationMs = sandboxBudgetMs − this` (ALI-1061). 5 min is
53
+ // comfortably above the observed harvest+flush cost while costing little of a
54
+ // multi-hour budget.
55
+ export const PROMPT_HARVEST_MARGIN_MS = 5 * 60 * 1000;
56
+ /**
57
+ * Derive the per-prompt max-duration cap from the sandbox lifetime budget: the
58
+ * budget minus a harvest margin, FLOORED at the harness default so a short or
59
+ * undated run is never shortened below the historical 5400s cap (ALI-1061).
60
+ * Undated runs (`sandboxBudgetMs` unset / non-positive) get exactly the default.
61
+ */
62
+ export function derivePromptMaxDurationMs(sandboxBudgetMs) {
63
+ if (typeof sandboxBudgetMs !== 'number' || !Number.isFinite(sandboxBudgetMs) || sandboxBudgetMs <= 0) {
64
+ return DEFAULT_PROMPT_MAX_DURATION_MS;
65
+ }
66
+ return Math.max(DEFAULT_PROMPT_MAX_DURATION_MS, sandboxBudgetMs - PROMPT_HARVEST_MARGIN_MS);
67
+ }
50
68
  /**
51
69
  * Default deferred-setup-hook runner (in-sandbox): run `bash .orizu/setup.sh` in
52
70
  * the workspace if present. Non-fatal — a missing hook or a non-zero exit is
@@ -381,7 +399,13 @@ export async function runHostedLoop(opts) {
381
399
  payload: { step: 'opencode_ready', ok: true, waitedMs: spawned.readyAfterMs },
382
400
  });
383
401
  }
384
- harness = (opts.createHarness ?? ((baseUrl) => createOpenCodeHarness({ baseUrl })))(spawned.baseUrl);
402
+ // Derive the per-prompt max-duration cap from the sandbox budget so a long
403
+ // `--duration` run is not killed at the hard-coded 90-min cap while its
404
+ // sandbox is still alive; floored at the harness default for short/undated
405
+ // runs (ALI-1061).
406
+ const promptMaxDurationMs = derivePromptMaxDurationMs(context.sandboxBudgetMs);
407
+ harness = (opts.createHarness ??
408
+ ((baseUrl, options) => createOpenCodeHarness({ baseUrl, promptMaxDurationMs: options?.promptMaxDurationMs })))(spawned.baseUrl, { promptMaxDurationMs });
385
409
  }
386
410
  const started = await harness.start({
387
411
  workspaceDir: context.workspaceDir,
@@ -412,6 +436,18 @@ export async function runHostedLoop(opts) {
412
436
  else
413
437
  signal.addEventListener('abort', () => promptController.abort(), { once: true });
414
438
  const annotated = annotateHeadlessQuestions(harness.runPrompt(prompt, promptController.signal));
439
+ if (idleTimeoutMs <= 0) {
440
+ // <= 0 explicitly DISABLES the watchdog (e.g. ORIZU_AGENT_IDLE_TIMEOUT_MS=0):
441
+ // a stalled prompt will hang until the sandbox/prompt hard cap. Record it so
442
+ // a run that never progresses isn't a silent mystery in the timeline (ALI-1069).
443
+ await sink.append({
444
+ kind: 'artifact',
445
+ payload: {
446
+ step: 'idle_watchdog_disabled',
447
+ detail: 'idle watchdog disabled (idle timeout <= 0); a stalled prompt will not auto-fail agent_stalled',
448
+ },
449
+ });
450
+ }
415
451
  const drivenStream = idleTimeoutMs > 0
416
452
  ? withIdleWatchdog(annotated, {
417
453
  timeoutMs: idleTimeoutMs,
@@ -419,6 +455,11 @@ export async function runHostedLoop(opts) {
419
455
  promptController.abort();
420
456
  await harness.stop();
421
457
  },
458
+ // The `question` tool is DENIED headless, so a model stuck retrying it
459
+ // emits a stream of synthetic `question_auto_answered` events. Those are
460
+ // NOT progress — excluding them lets a deny/retry loop trip agent_stalled
461
+ // instead of resetting the idle timer forever (ALI-1069).
462
+ isProgress: event => event.kind !== 'question_auto_answered',
422
463
  })
423
464
  : annotated;
424
465
  const status = await drainHarnessToSink(drivenStream, sink, {