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.
- package/dist/credentials.js +37 -0
- package/dist/daytona-slice-rehearsal.js +2 -2
- package/dist/deploy-key-merge-core.js +230 -0
- package/dist/git-credential.js +10 -7
- package/dist/github-setup.js +18 -2
- package/dist/help.js +16 -11
- package/dist/hosted-boot.js +214 -18
- package/dist/hosted-bootstrap.js +18 -40
- package/dist/hosted-harness-opencode.js +4 -2
- package/dist/hosted-headless.js +17 -6
- package/dist/hosted-loop.js +43 -2
- package/dist/hosted-run-event-sink.js +49 -10
- package/dist/hosted-session-cli.js +10 -1
- package/dist/hosted-skill-staging.js +89 -0
- package/dist/index.js +79 -74
- package/dist/manifests-cli.js +84 -7
- package/dist/merge-job.js +325 -0
- package/dist/preview-runtime.js +21 -4
- package/dist/prompt-draft-push.js +95 -0
- package/dist/report-comments-cli.js +9 -2
- package/dist/sandbox-provider.js +8 -2
- package/dist/scorer-draft-push.js +110 -0
- package/dist/workbench-cli.js +312 -8
- package/dist/workspace-sync.js +9 -2
- package/dist/workspace.js +15 -43
- package/dist/zip-draft-push.js +125 -0
- package/package.json +7 -9
- package/src/credentials.ts +40 -0
- package/src/daytona-slice-rehearsal.ts +2 -2
- package/src/deploy-key-merge-core.ts +326 -0
- package/src/git-credential.ts +10 -7
- package/src/github-setup.ts +24 -2
- package/src/help.ts +18 -11
- package/src/hosted-boot.ts +249 -17
- package/src/hosted-bootstrap.ts +19 -43
- package/src/hosted-harness-opencode.ts +4 -2
- package/src/hosted-headless.ts +26 -8
- package/src/hosted-loop-lifecycle.ts +8 -0
- package/src/hosted-loop.ts +53 -7
- package/src/hosted-run-event-sink.ts +58 -11
- package/src/hosted-session-cli.ts +10 -1
- package/src/hosted-skill-staging.ts +126 -0
- package/src/index.ts +100 -86
- package/src/manifests-cli.ts +110 -11
- package/src/merge-job.ts +462 -0
- package/src/preview-runtime.ts +27 -4
- package/src/prompt-draft-push.ts +143 -0
- package/src/report-comments-cli.ts +10 -2
- package/src/sandbox-provider.ts +8 -2
- package/src/scorer-draft-push.ts +155 -0
- package/src/workbench-cli.ts +426 -10
- package/src/workspace-sync.ts +9 -2
- package/src/workspace.ts +15 -45
- package/src/zip-draft-push.ts +177 -0
- package/vendor/skills/orizu-cli/SKILL.md +1 -0
- package/vendor/skills/orizu-cli/references/building-apps.md +2 -0
- package/vendor/skills/orizu-cli/references/cli-reference.md +1 -0
package/dist/hosted-boot.js
CHANGED
|
@@ -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`).
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
524
|
-
log
|
|
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
|
+
}
|
package/dist/hosted-bootstrap.js
CHANGED
|
@@ -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).
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
`
|
|
289
|
-
`
|
|
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:
|
|
309
|
-
method:
|
|
310
|
-
dest:
|
|
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
|
-
|
|
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).
|
package/dist/hosted-headless.js
CHANGED
|
@@ -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
|
|
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
|
|
38
|
-
* and `AgentStalledError` is thrown. The idle
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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),
|
|
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
|
}
|
package/dist/hosted-loop.js
CHANGED
|
@@ -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
|
-
|
|
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, {
|