orizu 0.5.6 → 0.5.8
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/README.md +13 -2
- package/dist/credentials.js +44 -0
- package/dist/help.js +77 -7
- package/dist/hosted-bootstrap.js +39 -0
- package/dist/hosted-harness-opencode.js +21 -1
- package/dist/hosted-harness.js +9 -0
- package/dist/hosted-harvest.js +116 -0
- package/dist/hosted-headless.js +163 -0
- package/dist/hosted-loop.js +119 -5
- package/dist/hosted-run-event-sink.js +21 -0
- package/dist/hosted-runtime-assets.js +27 -0
- package/dist/hosted-session-cli.js +51 -9
- package/dist/http.js +16 -1
- package/dist/index.js +54 -86
- package/dist/manifests-cli.js +103 -5
- package/dist/report-comments-cli.js +325 -0
- package/dist/skill-installer.js +16 -1
- package/package.json +1 -1
- package/src/credentials.ts +46 -0
- package/src/help.ts +77 -7
- package/src/hosted-bootstrap.ts +46 -0
- package/src/hosted-harness-opencode.ts +24 -2
- package/src/hosted-harness.ts +9 -0
- package/src/hosted-harvest.ts +159 -0
- package/src/hosted-headless.ts +178 -0
- package/src/hosted-loop.ts +133 -4
- package/src/hosted-run-event-sink.ts +28 -0
- package/src/hosted-runtime-assets.ts +31 -0
- package/src/hosted-session-cli.ts +50 -9
- package/src/http.ts +23 -1
- package/src/index.ts +74 -156
- package/src/manifests-cli.ts +110 -5
- package/src/report-comments-cli.ts +524 -0
- package/src/skill-installer.ts +18 -1
- package/vendor/skills/orizu-cli/SKILL.md +9 -2
- package/vendor/skills/orizu-cli/references/prompt-control-plane.md +1 -1
package/src/hosted-loop.ts
CHANGED
|
@@ -40,10 +40,14 @@ import { join } from 'path'
|
|
|
40
40
|
|
|
41
41
|
import type { AgentHarness, HarnessEvent, HarnessPrompt } from './hosted-harness.js'
|
|
42
42
|
import {
|
|
43
|
+
HOSTED_TASK_PREAMBLE,
|
|
43
44
|
PREBAKED_MARKER_PATH,
|
|
44
45
|
SETUP_HOOK_RELATIVE_PATH,
|
|
46
|
+
composeHostedTaskPrompt,
|
|
45
47
|
parsePrebakedMarker,
|
|
46
48
|
} from './hosted-runtime-assets.js'
|
|
49
|
+
import { harvestWorkspace, type HarvestExec, type HarvestOutcome } from './hosted-harvest.js'
|
|
50
|
+
import { annotateHeadlessQuestions, withIdleWatchdog } from './hosted-headless.js'
|
|
47
51
|
import {
|
|
48
52
|
OPENCODE_PINNED_VERSION,
|
|
49
53
|
createOpenCodeHarness,
|
|
@@ -132,6 +136,28 @@ export interface HostedLoopContext {
|
|
|
132
136
|
runSetupHook?: boolean
|
|
133
137
|
}
|
|
134
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Environment injected into the spawned OpenCode server so the AGENT's bash sees a
|
|
141
|
+
* PRE-AUTHENTICATED `orizu` CLI (ALI-1044). ORIZU_TOKEN_FILE points at the 0600
|
|
142
|
+
* rotated bearer file and ORIZU_BASE_URL at the Orizu API — the exact vars the
|
|
143
|
+
* CLI's auth resolution reads (resolveEnvBearerToken + resolveBaseUrl). The dummy
|
|
144
|
+
* ANTHROPIC key (model-key brokering, G3) is preserved. No real secret is carried
|
|
145
|
+
* here: the bearer stays on disk and is read fresh per request from the file.
|
|
146
|
+
*/
|
|
147
|
+
export function buildOpenCodeSpawnEnv(context: HostedLoopContext): Record<string, string> | undefined {
|
|
148
|
+
const env: Record<string, string> = {}
|
|
149
|
+
if (context.anthropicDummyKey) {
|
|
150
|
+
env.ANTHROPIC_API_KEY = context.anthropicDummyKey
|
|
151
|
+
}
|
|
152
|
+
if (context.bearerFile) {
|
|
153
|
+
env.ORIZU_TOKEN_FILE = context.bearerFile
|
|
154
|
+
}
|
|
155
|
+
if (context.apiBaseUrl) {
|
|
156
|
+
env.ORIZU_BASE_URL = context.apiBaseUrl
|
|
157
|
+
}
|
|
158
|
+
return Object.keys(env).length > 0 ? env : undefined
|
|
159
|
+
}
|
|
160
|
+
|
|
135
161
|
export interface InstallResult {
|
|
136
162
|
ok: boolean
|
|
137
163
|
detail: string
|
|
@@ -171,6 +197,24 @@ export interface RunHostedLoopOptions {
|
|
|
171
197
|
* workspace, non-fatal). Injectable so tests exercise the deferred-hook path
|
|
172
198
|
* with no real filesystem or child process. */
|
|
173
199
|
runSetupHook?: (input: { workspaceDir: string }) => Promise<SetupHookOutcome> | SetupHookOutcome
|
|
200
|
+
/**
|
|
201
|
+
* Standing preamble wrapped around the user task (ALI-1036). Defaults to
|
|
202
|
+
* `HOSTED_TASK_PREAMBLE`; pass an override to customize it, or an empty string
|
|
203
|
+
* to send the task verbatim (tests). The user task is always kept verbatim
|
|
204
|
+
* beneath a delimiter (see `composeHostedTaskPrompt`).
|
|
205
|
+
*/
|
|
206
|
+
taskPreamble?: string
|
|
207
|
+
/**
|
|
208
|
+
* Injectable git runner for the end-of-run auto-harvest (ALI-1036). Default:
|
|
209
|
+
* real `git` via child_process in `context.workspaceDir`.
|
|
210
|
+
*/
|
|
211
|
+
harvestExec?: HarvestExec
|
|
212
|
+
/**
|
|
213
|
+
* Idle watchdog window (ALI-1037): abort the prompt + fail the run
|
|
214
|
+
* `agent_stalled` if NO harness event arrives for this many ms. Default: env
|
|
215
|
+
* `ORIZU_AGENT_IDLE_TIMEOUT_MS` or 10 minutes. <= 0 disables the watchdog.
|
|
216
|
+
*/
|
|
217
|
+
idleTimeoutMs?: number
|
|
174
218
|
}
|
|
175
219
|
|
|
176
220
|
export interface HostedLoopResult {
|
|
@@ -363,6 +407,25 @@ function defaultRunSetupHook({ workspaceDir }: { workspaceDir: string }): SetupH
|
|
|
363
407
|
return { ran: true, ok, detail: ok ? 'setup.sh ok' : `setup.sh exit ${res.status ?? 'unknown'}` }
|
|
364
408
|
}
|
|
365
409
|
|
|
410
|
+
/** Default idle-watchdog window (ALI-1037): abort a prompt that makes no progress
|
|
411
|
+
* for this long. The timer resets on every harness EVENT, and a legitimate long
|
|
412
|
+
* single tool call (a big `npm install`, a build, an optimization step) emits no
|
|
413
|
+
* intermediate events — so this must comfortably exceed the longest expected
|
|
414
|
+
* quiet tool call (review finding #2). Overridable per-run via `idleTimeoutMs` or
|
|
415
|
+
* the `ORIZU_AGENT_IDLE_TIMEOUT_MS` env var. */
|
|
416
|
+
export const DEFAULT_AGENT_IDLE_TIMEOUT_MS = 25 * 60 * 1000
|
|
417
|
+
|
|
418
|
+
/** Resolve the idle window: explicit option wins, then env, then the default. */
|
|
419
|
+
function resolveIdleTimeoutMs(override: number | undefined): number {
|
|
420
|
+
if (typeof override === 'number' && Number.isFinite(override)) return override
|
|
421
|
+
const raw = process.env.ORIZU_AGENT_IDLE_TIMEOUT_MS
|
|
422
|
+
if (raw) {
|
|
423
|
+
const parsed = Number.parseInt(raw, 10)
|
|
424
|
+
if (Number.isFinite(parsed)) return parsed
|
|
425
|
+
}
|
|
426
|
+
return DEFAULT_AGENT_IDLE_TIMEOUT_MS
|
|
427
|
+
}
|
|
428
|
+
|
|
366
429
|
/** Model-key env vars whose value must be scrubbed from every run event. */
|
|
367
430
|
const MODEL_KEY_ENV_VARS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY']
|
|
368
431
|
|
|
@@ -552,6 +615,44 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
|
|
|
552
615
|
}
|
|
553
616
|
}
|
|
554
617
|
|
|
618
|
+
// End-of-run auto-harvest (ALI-1036): commit + push anything the agent left
|
|
619
|
+
// uncommitted/unpushed so a run NEVER loses work — on BOTH the success and
|
|
620
|
+
// failure paths. Runs at most once (guarded), records exactly one of
|
|
621
|
+
// work_persisted / work_none / work_persist_failed, and NEVER throws or changes
|
|
622
|
+
// the run's terminal status: a harvest failure is recorded and the run proceeds.
|
|
623
|
+
let harvested = false
|
|
624
|
+
const runAutoHarvest = async (): Promise<void> => {
|
|
625
|
+
if (harvested) return
|
|
626
|
+
harvested = true
|
|
627
|
+
let outcome: HarvestOutcome
|
|
628
|
+
try {
|
|
629
|
+
outcome = harvestWorkspace({
|
|
630
|
+
workspaceDir: context.workspaceDir,
|
|
631
|
+
runId: context.runId,
|
|
632
|
+
author: context.author,
|
|
633
|
+
exec: opts.harvestExec,
|
|
634
|
+
// Real harvest only inside a genuine hosted sandbox (prebaked marker
|
|
635
|
+
// present) — never on a host/test working tree (review finding #1).
|
|
636
|
+
enabled: existsSync(PREBAKED_MARKER_PATH),
|
|
637
|
+
})
|
|
638
|
+
} catch (error) {
|
|
639
|
+
outcome = { kind: 'work_persist_failed', error: error instanceof Error ? error.message : String(error) }
|
|
640
|
+
}
|
|
641
|
+
if (sink.sealed) return
|
|
642
|
+
try {
|
|
643
|
+
if (outcome.kind === 'work_persisted') {
|
|
644
|
+
await sink.append({ kind: 'work_persisted', payload: { sha: outcome.sha, files: outcome.files } })
|
|
645
|
+
} else if (outcome.kind === 'work_none') {
|
|
646
|
+
await sink.append({ kind: 'work_none', payload: {} })
|
|
647
|
+
} else {
|
|
648
|
+
await sink.append({ kind: 'work_persist_failed', payload: { error: outcome.error } })
|
|
649
|
+
}
|
|
650
|
+
} catch {
|
|
651
|
+
// Recording harvest is best-effort — never fail the run because the harvest
|
|
652
|
+
// event append failed.
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
555
656
|
let spawned: SpawnedOpenCode | null = null
|
|
556
657
|
let agentSessionId: string | null = null
|
|
557
658
|
let installOk = false
|
|
@@ -604,7 +705,7 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
|
|
|
604
705
|
model: context.model,
|
|
605
706
|
cwd: context.workspaceDir,
|
|
606
707
|
port: context.opencodePort,
|
|
607
|
-
env: context
|
|
708
|
+
env: buildOpenCodeSpawnEnv(context),
|
|
608
709
|
spawn: nodeChildSpawner,
|
|
609
710
|
logPath: join(tmpdir(), `opencode-serve-${context.runId}.log`),
|
|
610
711
|
signal,
|
|
@@ -629,21 +730,49 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
|
|
|
629
730
|
})
|
|
630
731
|
agentSessionId = started.agentSessionId
|
|
631
732
|
|
|
733
|
+
// Prompt scaffolding (ALI-1036): wrap the user task with the standing
|
|
734
|
+
// headless preamble, keeping the task verbatim beneath a delimiter.
|
|
632
735
|
const prompt: HarnessPrompt = {
|
|
633
736
|
runId: context.runId,
|
|
634
737
|
messageId: context.messageId,
|
|
635
|
-
content: taskPrompt,
|
|
738
|
+
content: composeHostedTaskPrompt(taskPrompt, opts.taskPreamble ?? HOSTED_TASK_PREAMBLE),
|
|
636
739
|
author: context.author,
|
|
637
740
|
}
|
|
638
|
-
|
|
741
|
+
// Drive the prompt through (a) question auto-handling annotation and (b) the
|
|
742
|
+
// idle watchdog (ALI-1037): no harness event for `idleTimeoutMs` aborts the
|
|
743
|
+
// prompt and throws `AgentStalledError` → the catch below harvests, then
|
|
744
|
+
// finishes the run `failed`. The prompt runs under a CHILD AbortController
|
|
745
|
+
// linked to the loop signal so the watchdog can abort the in-flight prompt
|
|
746
|
+
// (unblocking a signal-respecting harness the same way a cancel would), then
|
|
747
|
+
// best-effort `harness.stop()` the driver.
|
|
748
|
+
const idleTimeoutMs = resolveIdleTimeoutMs(opts.idleTimeoutMs)
|
|
749
|
+
const promptController = new AbortController()
|
|
750
|
+
if (signal.aborted) promptController.abort()
|
|
751
|
+
else signal.addEventListener('abort', () => promptController.abort(), { once: true })
|
|
752
|
+
const annotated = annotateHeadlessQuestions(harness.runPrompt(prompt, promptController.signal))
|
|
753
|
+
const drivenStream =
|
|
754
|
+
idleTimeoutMs > 0
|
|
755
|
+
? withIdleWatchdog(annotated, {
|
|
756
|
+
timeoutMs: idleTimeoutMs,
|
|
757
|
+
onTimeout: async () => {
|
|
758
|
+
promptController.abort()
|
|
759
|
+
await harness.stop()
|
|
760
|
+
},
|
|
761
|
+
})
|
|
762
|
+
: annotated
|
|
763
|
+
const status = await drainHarnessToSink(drivenStream, sink, {
|
|
639
764
|
summary: { agentSessionId },
|
|
765
|
+
// Auto-harvest runs on EVERY terminal path, before the sink seals.
|
|
766
|
+
beforeFinish: runAutoHarvest,
|
|
640
767
|
})
|
|
641
768
|
await harness.shutdown()
|
|
642
769
|
return { status, agentSessionId, installOk, error: null }
|
|
643
770
|
} catch (error) {
|
|
644
771
|
const message = error instanceof Error ? error.message : String(error)
|
|
645
772
|
// Fail closed: never leave the run non-terminal. finish() is idempotent and
|
|
646
|
-
// no-ops when the drain already sealed the sink.
|
|
773
|
+
// no-ops when the drain already sealed the sink. Harvest FIRST (partial work
|
|
774
|
+
// is valuable, incl. an idle-watchdog abort) — before the terminal seals.
|
|
775
|
+
await runAutoHarvest()
|
|
647
776
|
if (!sink.sealed) {
|
|
648
777
|
try {
|
|
649
778
|
await sink.finish('failed', { summary: { error: message } })
|
|
@@ -77,6 +77,13 @@ const EVENT_TYPE_BY_KIND: Partial<Record<HarnessEventKind, string>> = {
|
|
|
77
77
|
// through this SAME sink (single writer). See EGRESS_*_EVENT_TYPE below.
|
|
78
78
|
egress_blocked: EGRESS_BLOCKED_EVENT_TYPE,
|
|
79
79
|
egress_allowed: EGRESS_ALLOWED_EVENT_TYPE,
|
|
80
|
+
// Durability + headless (ALI-1036 / ALI-1037), emitted by the in-sandbox loop
|
|
81
|
+
// through this SAME sink (single writer): auto-harvest results + headless
|
|
82
|
+
// question auto-handling record.
|
|
83
|
+
work_persisted: 'work_persisted',
|
|
84
|
+
work_none: 'work_none',
|
|
85
|
+
work_persist_failed: 'work_persist_failed',
|
|
86
|
+
question_auto_answered: 'agent_question_auto_answered',
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
/** The RunAPI event type a harness kind maps to, or null if it is not appendable
|
|
@@ -576,6 +583,15 @@ export interface DrainOptions {
|
|
|
576
583
|
transcript?: string
|
|
577
584
|
summary?: Record<string, unknown>
|
|
578
585
|
evidence?: Record<string, unknown>
|
|
586
|
+
/**
|
|
587
|
+
* Hook invoked AFTER the stream yields its terminal event but BEFORE `finish()`
|
|
588
|
+
* seals the sink, on EVERY terminal path (success, failure, abnormal end). Used
|
|
589
|
+
* by the loop's end-of-run auto-harvest (ALI-1036) so `work_persisted` /
|
|
590
|
+
* `work_none` / `work_persist_failed` can still be appended while the sink is
|
|
591
|
+
* writable. Its own failure must never change the run's terminal status — the
|
|
592
|
+
* hook itself is responsible for swallowing errors.
|
|
593
|
+
*/
|
|
594
|
+
beforeFinish?: () => Promise<void>
|
|
579
595
|
}
|
|
580
596
|
|
|
581
597
|
/**
|
|
@@ -590,6 +606,14 @@ export async function drainHarnessToSink(
|
|
|
590
606
|
sink: RunEventSink,
|
|
591
607
|
opts: DrainOptions = {}
|
|
592
608
|
): Promise<TerminalStatus> {
|
|
609
|
+
// Run the pre-terminal hook (auto-harvest) at most once, right before the first
|
|
610
|
+
// finish() on any terminal path — while the sink is still writable.
|
|
611
|
+
let beforeFinishRan = false
|
|
612
|
+
const runBeforeFinish = async (): Promise<void> => {
|
|
613
|
+
if (beforeFinishRan) return
|
|
614
|
+
beforeFinishRan = true
|
|
615
|
+
if (opts.beforeFinish) await opts.beforeFinish()
|
|
616
|
+
}
|
|
593
617
|
for await (const event of events) {
|
|
594
618
|
if (event.kind === 'execution_complete') {
|
|
595
619
|
// A prompt the caller/agent stopped is a cancellation, not a failure: the
|
|
@@ -597,6 +621,7 @@ export async function drainHarnessToSink(
|
|
|
597
621
|
// and stop()-initiated paths. A genuine failure (success:false, no abort
|
|
598
622
|
// flag) still lands 'failed'.
|
|
599
623
|
if (event.payload.aborted === true) {
|
|
624
|
+
await runBeforeFinish()
|
|
600
625
|
await sink.finish('cancelled', {
|
|
601
626
|
summary: opts.summary,
|
|
602
627
|
evidence: opts.evidence,
|
|
@@ -609,10 +634,12 @@ export async function drainHarnessToSink(
|
|
|
609
634
|
const summary = success
|
|
610
635
|
? opts.summary
|
|
611
636
|
: { ...(opts.summary ?? {}), error: event.payload.error ?? 'execution failed' }
|
|
637
|
+
await runBeforeFinish()
|
|
612
638
|
await sink.finish(status, { summary, evidence: opts.evidence, transcript: opts.transcript })
|
|
613
639
|
return status
|
|
614
640
|
}
|
|
615
641
|
if (event.kind === 'error') {
|
|
642
|
+
await runBeforeFinish()
|
|
616
643
|
await sink.finish('failed', {
|
|
617
644
|
summary: { ...(opts.summary ?? {}), error: event.payload.error ?? 'agent error' },
|
|
618
645
|
evidence: opts.evidence,
|
|
@@ -624,6 +651,7 @@ export async function drainHarnessToSink(
|
|
|
624
651
|
}
|
|
625
652
|
// The harness contract guarantees a terminal event; reaching here means the
|
|
626
653
|
// stream ended abnormally. Fail closed so the run never dangles non-terminal.
|
|
654
|
+
await runBeforeFinish()
|
|
627
655
|
await sink.finish('failed', {
|
|
628
656
|
summary: { ...(opts.summary ?? {}), error: 'harness stream ended without a terminal event' },
|
|
629
657
|
transcript: opts.transcript,
|
|
@@ -44,6 +44,37 @@ export const SETUP_HOOK_RELATIVE_PATH = '.orizu/setup.sh'
|
|
|
44
44
|
*/
|
|
45
45
|
export const AGENT_GIT_IDENTITY = { name: 'Orizu Agent', email: 'agent@orizu.ai' } as const
|
|
46
46
|
|
|
47
|
+
// -- Hosted task prompt scaffolding (ALI-1036) -------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Standing preamble wrapped around every hosted task prompt so a NATURAL task
|
|
51
|
+
* ("review X and update the readme") runs unattended with zero magic words. The
|
|
52
|
+
* agent is told to work autonomously (never block on the human), commit as it
|
|
53
|
+
* goes, and treat Orizu — not the repo — as the home for production pointers and
|
|
54
|
+
* run metadata. An end-of-run auto-harvest (ALI-1036, `harvestWorkspace`) is the
|
|
55
|
+
* safety net for anything left uncommitted; the preamble names it so the agent
|
|
56
|
+
* knows partial work is preserved.
|
|
57
|
+
*/
|
|
58
|
+
export const HOSTED_TASK_PREAMBLE =
|
|
59
|
+
'You are running unattended in a hosted sandbox. Work autonomously: never ask the user ' +
|
|
60
|
+
'questions — when uncertain, choose the most reasonable/reversible option and note the ' +
|
|
61
|
+
'decision. Commit your work to the current branch in logical units as you go (an automatic ' +
|
|
62
|
+
'checkpoint will also save anything uncommitted at the end). Never switch branches. ' +
|
|
63
|
+
'Production/default pointers and run metadata live in Orizu, not this repo.'
|
|
64
|
+
|
|
65
|
+
/** Delimiter separating the standing preamble from the verbatim user task. */
|
|
66
|
+
export const HOSTED_TASK_DELIMITER = '--- YOUR TASK ---'
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Compose the full hosted prompt: the standing preamble, a clear delimiter, then
|
|
70
|
+
* the user's task VERBATIM beneath it. Passing an empty preamble yields the task
|
|
71
|
+
* unchanged (an explicit opt-out for tests / callers that scaffold elsewhere).
|
|
72
|
+
*/
|
|
73
|
+
export function composeHostedTaskPrompt(task: string, preamble: string = HOSTED_TASK_PREAMBLE): string {
|
|
74
|
+
if (!preamble) return task
|
|
75
|
+
return `${preamble}\n\n${HOSTED_TASK_DELIMITER}\n${task}`
|
|
76
|
+
}
|
|
77
|
+
|
|
47
78
|
// -- Pre-baked runtime marker (ALI-1017) -------------------------------------
|
|
48
79
|
|
|
49
80
|
/**
|
|
@@ -359,6 +359,12 @@ async function defaultResolveRepo(
|
|
|
359
359
|
export async function startHostedSession(
|
|
360
360
|
opts: StartHostedSessionOptions
|
|
361
361
|
): Promise<StartHostedSessionResult> {
|
|
362
|
+
// ALI-1035 fail-fast: a hosted run cannot reach the model provider without a key
|
|
363
|
+
// (the firewall brokers the real key onto egress). Refuse BEFORE creating any
|
|
364
|
+
// session, run row, or sandbox — no orphaned rows, no paid box, instant error.
|
|
365
|
+
if (!opts.modelApiKey) {
|
|
366
|
+
throw new Error('hosted session needs a model API key: set ANTHROPIC_API_KEY (or pass --model-key)')
|
|
367
|
+
}
|
|
362
368
|
const rawFetch = opts.rawFetch ?? (globalThis.fetch as HostedFetch)
|
|
363
369
|
const log = opts.logLine ?? ((): void => {})
|
|
364
370
|
const model = opts.model ?? DEFAULT_HOSTED_MODEL
|
|
@@ -602,6 +608,32 @@ export async function startHostedSession(
|
|
|
602
608
|
let sandboxBudgetMs = durationMs
|
|
603
609
|
let tokenExpiresAtMs = now() + ttlMinutes * 60 * 1000
|
|
604
610
|
let stopped = false
|
|
611
|
+
|
|
612
|
+
// ALI-1037 host-side idle gate: only KEEP a sandbox alive while its run is
|
|
613
|
+
// making progress. Before each keepalive extension we cheaply check whether any
|
|
614
|
+
// new run event landed since the last check; a run with NO new events since the
|
|
615
|
+
// previous rotation cycle is not extended (the in-sandbox watchdog is the
|
|
616
|
+
// primary stall killer — this stops the HOST from paying to keep a dead box
|
|
617
|
+
// alive). "Can't tell" (non-2xx / transport error) never starves a healthy run.
|
|
618
|
+
let lastEventCursor = 0
|
|
619
|
+
const hasEventProgress = async (): Promise<boolean> => {
|
|
620
|
+
try {
|
|
621
|
+
const res = await opts.fetcher(
|
|
622
|
+
`/api/cli/workbench-runs/${encodeURIComponent(runId)}/events?after=${lastEventCursor}&limit=500`
|
|
623
|
+
)
|
|
624
|
+
if (!res.ok) return true
|
|
625
|
+
const body = (await res.json().catch(() => ({}))) as { events?: unknown }
|
|
626
|
+
const events = Array.isArray(body.events) ? (body.events as Record<string, unknown>[]) : []
|
|
627
|
+
if (events.length === 0) return false
|
|
628
|
+
lastEventCursor = events.reduce((max, event) => {
|
|
629
|
+
const seq = typeof event.sequence === 'number' ? event.sequence : 0
|
|
630
|
+
return seq > max ? seq : max
|
|
631
|
+
}, lastEventCursor)
|
|
632
|
+
return true
|
|
633
|
+
} catch {
|
|
634
|
+
return true
|
|
635
|
+
}
|
|
636
|
+
}
|
|
605
637
|
let cancelSleep: (() => void) | null = null
|
|
606
638
|
const stop = (): void => {
|
|
607
639
|
stopped = true
|
|
@@ -674,14 +706,21 @@ export async function startHostedSession(
|
|
|
674
706
|
log('rotated agent bearer (file overwritten)')
|
|
675
707
|
// Keepalive: extend the sandbox by one rotation interval each rotation
|
|
676
708
|
// while attached (SDK supports extendTimeout(ms)), bounded by the 24h cap
|
|
677
|
-
// so a long attached session outlives the initial --duration window
|
|
709
|
+
// so a long attached session outlives the initial --duration window — BUT
|
|
710
|
+
// only while the run is making progress (ALI-1037). A run with no new
|
|
711
|
+
// events since the last rotation is not extended: the box is left to wind
|
|
712
|
+
// down to its current budget instead of the host paying for a stalled run.
|
|
678
713
|
if (sandbox.extendTimeout && sandboxBudgetMs + rotationIntervalMs <= sandboxCapMs) {
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
714
|
+
if (!(await hasEventProgress())) {
|
|
715
|
+
log('sandbox timeout NOT extended: no run events since the last rotation (agent stalled) — letting the box wind down')
|
|
716
|
+
} else {
|
|
717
|
+
try {
|
|
718
|
+
await sandbox.extendTimeout(rotationIntervalMs)
|
|
719
|
+
sandboxBudgetMs += rotationIntervalMs
|
|
720
|
+
log(`extended sandbox timeout (+${Math.round(rotationIntervalMs / 1000)}s, budget ${Math.round(sandboxBudgetMs / 60000)}m)`)
|
|
721
|
+
} catch (error) {
|
|
722
|
+
log(`sandbox extendTimeout failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
723
|
+
}
|
|
685
724
|
}
|
|
686
725
|
}
|
|
687
726
|
} catch (error) {
|
|
@@ -762,7 +801,7 @@ const HOSTED_USAGE =
|
|
|
762
801
|
'[--model <provider/model>] [--project <team/project>] [--provider vercel|local-sim] ' +
|
|
763
802
|
'[--image <orizu-hosted-runtime:tag> (pre-baked VCR image; default env ORIZU_HOSTED_IMAGE)] ' +
|
|
764
803
|
'[--snapshot <snapshot-id> (pre-baked Vercel snapshot, zero-Docker; default env ORIZU_HOSTED_SNAPSHOT; ' +
|
|
765
|
-
'mutually exclusive with --image)] [--tail]\n' +
|
|
804
|
+
'mutually exclusive with --image)] [--model-key <key> (else ANTHROPIC_API_KEY; required)] [--tail]\n' +
|
|
766
805
|
'Note (v0): if you disconnect, the agent token expires within its TTL (<=60m) and the run finishes ' +
|
|
767
806
|
'cleanly; reconnect with `orizu run tail --run <id>`. The sandbox timeout is independent (up to 24h).'
|
|
768
807
|
|
|
@@ -821,7 +860,9 @@ export async function hostedCommand(args: string[], io: HostedCommandIo): Promis
|
|
|
821
860
|
hostedImage: argVal(args, '--image') ?? process.env.ORIZU_HOSTED_IMAGE ?? undefined,
|
|
822
861
|
hostedSnapshot: argVal(args, '--snapshot') ?? process.env.ORIZU_HOSTED_SNAPSHOT ?? undefined,
|
|
823
862
|
tail,
|
|
824
|
-
|
|
863
|
+
// Model key: explicit --model-key wins, else ANTHROPIC_API_KEY. Falsy → the
|
|
864
|
+
// fail-fast in startHostedSession throws before any session/sandbox is made.
|
|
865
|
+
modelApiKey: argVal(args, '--model-key') ?? (process.env.ANTHROPIC_API_KEY || undefined),
|
|
825
866
|
logLine: line => io.print(line),
|
|
826
867
|
onTailEvent: event =>
|
|
827
868
|
io.print(io.json ? JSON.stringify(event) : `#${String(event.sequence ?? '?')} ${String(event.eventType ?? '')}`),
|
package/src/http.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getActiveBaseUrl,
|
|
3
|
+
getServerCredentials,
|
|
4
|
+
resolveEnvBearerToken,
|
|
5
|
+
updateServerCredentials,
|
|
6
|
+
} from './credentials.js'
|
|
2
7
|
import { getFlagBaseUrl, GlobalFlags, normalizeBaseUrl } from './global-flags.js'
|
|
3
8
|
import { LoginResponse, ServerCredentials, SessionServerCredentials } from './types.js'
|
|
4
9
|
|
|
@@ -122,6 +127,23 @@ async function refreshCredentials(baseUrl: string, credentials: ServerCredential
|
|
|
122
127
|
export async function authedFetch(path: string, init: RequestInit = {}) {
|
|
123
128
|
const baseUrl = resolveBaseUrl()
|
|
124
129
|
assertSecureTokenTransport(baseUrl)
|
|
130
|
+
|
|
131
|
+
// In-sandbox pre-auth (ALI-1044): a bearer supplied via ORIZU_TOKEN /
|
|
132
|
+
// ORIZU_TOKEN_FILE takes precedence over credentials.json. It is externally
|
|
133
|
+
// managed (the hosted loop rotates the token file), so it is read FRESH here on
|
|
134
|
+
// every request and NEVER refreshed by this client — on a 401 the loop rotates
|
|
135
|
+
// the file and the next request naturally picks up the new bearer.
|
|
136
|
+
const envBearer = resolveEnvBearerToken()
|
|
137
|
+
if (envBearer) {
|
|
138
|
+
return await fetch(`${baseUrl}${path}`, {
|
|
139
|
+
...init,
|
|
140
|
+
headers: {
|
|
141
|
+
...(init.headers || {}),
|
|
142
|
+
Authorization: `Bearer ${envBearer}`,
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
125
147
|
const credentials = getServerCredentials(baseUrl)
|
|
126
148
|
if (!credentials) {
|
|
127
149
|
throw new Error(`Not logged in for ${baseUrl}. Run \`orizu login --server ${baseUrl}\` (or \`--local\`) first.`)
|