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.
@@ -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.anthropicDummyKey ? { ANTHROPIC_API_KEY: context.anthropicDummyKey } : undefined,
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
- const status = await drainHarnessToSink(harness.runPrompt(prompt, signal), sink, {
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
- try {
680
- await sandbox.extendTimeout(rotationIntervalMs)
681
- sandboxBudgetMs += rotationIntervalMs
682
- log(`extended sandbox timeout (+${Math.round(rotationIntervalMs / 1000)}s, budget ${Math.round(sandboxBudgetMs / 60000)}m)`)
683
- } catch (error) {
684
- log(`sandbox extendTimeout failed: ${error instanceof Error ? error.message : String(error)}`)
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
- modelApiKey: process.env.ANTHROPIC_API_KEY || undefined,
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 { getActiveBaseUrl, getServerCredentials, updateServerCredentials } from './credentials.js'
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.`)