orizu 0.5.4 → 0.5.6
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/hosted-bootstrap.js +6 -1
- package/dist/hosted-harness-opencode.js +68 -9
- package/dist/hosted-loop.js +39 -4
- package/dist/hosted-runtime-assets.js +8 -0
- package/dist/hosted-session-cli.js +2 -2
- package/package.json +1 -1
- package/src/hosted-bootstrap.ts +6 -0
- package/src/hosted-harness-opencode.ts +91 -10
- package/src/hosted-loop.ts +39 -6
- package/src/hosted-runtime-assets.ts +9 -0
- package/src/hosted-session-cli.ts +2 -2
package/dist/hosted-bootstrap.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { sweepForTokenResidue } from './daytona-slice.js';
|
|
26
26
|
import { redactSecrets } from './secret-redaction.js';
|
|
27
|
-
import { 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';
|
|
27
|
+
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';
|
|
28
28
|
function newEventId() {
|
|
29
29
|
const cryptoObj = globalThis.crypto;
|
|
30
30
|
if (cryptoObj?.randomUUID)
|
|
@@ -236,6 +236,11 @@ export async function bootstrapHostedSandbox(opts) {
|
|
|
236
236
|
// Persist the helper repo-LOCAL (never --global) for subsequent fetch/push.
|
|
237
237
|
await session.exec(`git -C ${workspaceDir} config credential.helper ${shellSingleQuote(helperValue)}`);
|
|
238
238
|
await session.exec(`git -C ${workspaceDir} config credential.useHttpPath true`);
|
|
239
|
+
// Repo-LOCAL agent identity: the sandbox has no ambient git identity, so
|
|
240
|
+
// without this every agent `git commit` dies with "Author identity unknown"
|
|
241
|
+
// (ALI-1036, found live in QA-3). Same identity the loop stamps on events.
|
|
242
|
+
await session.exec(`git -C ${workspaceDir} config user.name ${shellSingleQuote(AGENT_GIT_IDENTITY.name)}`);
|
|
243
|
+
await session.exec(`git -C ${workspaceDir} config user.email ${shellSingleQuote(AGENT_GIT_IDENTITY.email)}`);
|
|
239
244
|
record('repo_cloned', true, `cloned ${opts.sessionBranch}`);
|
|
240
245
|
await sink.append('repo_cloned', { branch: opts.sessionBranch, workspaceDir });
|
|
241
246
|
// 4 — Provision the Orizu CLI (non-fatal, recorded — OpenInspect setup.sh
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
* `OPENCODE_PINNED_VERSION`; a bump that switches OpenCode to CRLF framing
|
|
49
49
|
* would break both ports and must be re-verified.
|
|
50
50
|
*/
|
|
51
|
+
import { readFileSync } from 'fs';
|
|
51
52
|
// OpenCode is HARD-PINNED to this version everywhere it is installed/launched.
|
|
52
53
|
// AUDIT RISK #1 (SSE fragility): the entire event model rides OpenCode's
|
|
53
54
|
// undocumented `/event` SSE part schema (`message.part.updated`, `session.idle`,
|
|
@@ -696,15 +697,34 @@ export function buildOpenCodeConfigContent(opts) {
|
|
|
696
697
|
permission: opts.permission ?? { '*': { '*': 'allow' } },
|
|
697
698
|
});
|
|
698
699
|
}
|
|
700
|
+
const OPENCODE_READY_TIMEOUT_MS = 180_000;
|
|
701
|
+
const OPENCODE_READY_POLL_MS = 500;
|
|
702
|
+
const OPENCODE_READY_PROBE_TIMEOUT_MS = 2_000;
|
|
703
|
+
const OPENCODE_LOG_TAIL_BYTES = 2_000;
|
|
704
|
+
function readLogTail(logPath) {
|
|
705
|
+
if (!logPath)
|
|
706
|
+
return null;
|
|
707
|
+
try {
|
|
708
|
+
const content = readFileSync(logPath, 'utf8');
|
|
709
|
+
if (!content.trim())
|
|
710
|
+
return null;
|
|
711
|
+
return content.slice(-OPENCODE_LOG_TAIL_BYTES).trim();
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
699
717
|
/**
|
|
700
|
-
* Launch `opencode serve` locally
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
*
|
|
718
|
+
* Launch `opencode serve` locally and WAIT until it answers HTTP. OpenCode's
|
|
719
|
+
* first boot performs a one-time sqlite migration before it listens; returning
|
|
720
|
+
* the baseUrl without waiting raced the harness's first `POST /session` into a
|
|
721
|
+
* connection-refused "fetch failed" (ALI-1034, found live in QA-3). Any HTTP
|
|
722
|
+
* status counts as ready — we only need the socket to be accepting. Process
|
|
723
|
+
* supervision is otherwise intentionally minimal for this slice; the pinned
|
|
724
|
+
* version (`OPENCODE_PINNED_VERSION`) is the one the sandbox image must install
|
|
725
|
+
* (see the SSE-fragility note on that constant).
|
|
706
726
|
*/
|
|
707
|
-
export function spawnOpenCode(opts) {
|
|
727
|
+
export async function spawnOpenCode(opts) {
|
|
708
728
|
const port = opts.port ?? DEFAULT_OPENCODE_PORT;
|
|
709
729
|
const env = {
|
|
710
730
|
OPENCODE_CONFIG_CONTENT: buildOpenCodeConfigContent(opts),
|
|
@@ -724,6 +744,45 @@ export function spawnOpenCode(opts) {
|
|
|
724
744
|
stderr: 'ignore',
|
|
725
745
|
});
|
|
726
746
|
});
|
|
727
|
-
const child = spawnImpl(cmd, { cwd: opts.cwd, env });
|
|
728
|
-
|
|
747
|
+
const child = spawnImpl(cmd, { cwd: opts.cwd, env, logPath: opts.logPath });
|
|
748
|
+
const baseUrl = `http://localhost:${port}`;
|
|
749
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
750
|
+
const timeoutMs = opts.readyTimeoutMs ?? OPENCODE_READY_TIMEOUT_MS;
|
|
751
|
+
const pollMs = opts.readyPollMs ?? OPENCODE_READY_POLL_MS;
|
|
752
|
+
const startedAt = Date.now();
|
|
753
|
+
for (;;) {
|
|
754
|
+
if (opts.signal?.aborted) {
|
|
755
|
+
child.kill();
|
|
756
|
+
throw new Error('opencode serve readiness wait aborted');
|
|
757
|
+
}
|
|
758
|
+
try {
|
|
759
|
+
const probe = new AbortController();
|
|
760
|
+
const timer = setTimeout(() => probe.abort(), OPENCODE_READY_PROBE_TIMEOUT_MS);
|
|
761
|
+
// Link the run signal so an abort tears down an in-flight probe
|
|
762
|
+
// immediately instead of waiting out the per-probe timeout.
|
|
763
|
+
const probeSignal = opts.signal ? AbortSignal.any([opts.signal, probe.signal]) : probe.signal;
|
|
764
|
+
try {
|
|
765
|
+
// Any HTTP response (even 404) proves the server is accepting.
|
|
766
|
+
const res = await fetchImpl(`${baseUrl}/session`, { signal: probeSignal });
|
|
767
|
+
await cancelBody(res.body);
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
finally {
|
|
771
|
+
clearTimeout(timer);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
catch {
|
|
775
|
+
// not listening yet — fall through to the timeout check + sleep
|
|
776
|
+
}
|
|
777
|
+
// Soft budget: checked between probes, so the total wait can overrun by up
|
|
778
|
+
// to one probe timeout + poll interval. Negligible against the default.
|
|
779
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
780
|
+
child.kill();
|
|
781
|
+
const tail = readLogTail(opts.logPath);
|
|
782
|
+
throw new Error(`opencode serve did not become ready on ${baseUrl} within ${timeoutMs}ms` +
|
|
783
|
+
(tail ? ` — log tail: ${tail}` : ' (no log output captured)'));
|
|
784
|
+
}
|
|
785
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
786
|
+
}
|
|
787
|
+
return { baseUrl, port, stop: () => child.kill(), readyAfterMs: Date.now() - startedAt };
|
|
729
788
|
}
|
package/dist/hosted-loop.js
CHANGED
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
* against a fake broker + fake OpenCode server with no real binary.
|
|
34
34
|
*/
|
|
35
35
|
import { spawnSync, spawn as nodeSpawn } from 'child_process';
|
|
36
|
-
import { existsSync, readFileSync } from 'fs';
|
|
36
|
+
import { closeSync, existsSync, openSync, readFileSync } from 'fs';
|
|
37
|
+
import { tmpdir } from 'os';
|
|
37
38
|
import { join } from 'path';
|
|
38
39
|
import { PREBAKED_MARKER_PATH, SETUP_HOOK_RELATIVE_PATH, parsePrebakedMarker, } from './hosted-runtime-assets.js';
|
|
39
40
|
import { OPENCODE_PINNED_VERSION, createOpenCodeHarness, spawnOpenCode, } from './hosted-harness-opencode.js';
|
|
@@ -218,13 +219,35 @@ function installOpenCodePinned(version) {
|
|
|
218
219
|
}
|
|
219
220
|
/** Node child_process spawner for `opencode serve` (the sandbox runtime is
|
|
220
221
|
* node24 — Bun.spawn is not guaranteed present, so spawnOpenCode is given this
|
|
221
|
-
* runtime-agnostic spawner).
|
|
222
|
+
* runtime-agnostic spawner). When a logPath is provided, opencode's
|
|
223
|
+
* stdout/stderr land there so a readiness timeout can surface the real boot
|
|
224
|
+
* failure instead of a bare "fetch failed" (ALI-1034). */
|
|
222
225
|
function nodeChildSpawner(cmd, opts) {
|
|
226
|
+
let stdio = 'ignore';
|
|
227
|
+
let fd = null;
|
|
228
|
+
if (opts.logPath) {
|
|
229
|
+
try {
|
|
230
|
+
fd = openSync(opts.logPath, 'a');
|
|
231
|
+
stdio = ['ignore', fd, fd];
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// log capture is best-effort — never block the spawn on it
|
|
235
|
+
}
|
|
236
|
+
}
|
|
223
237
|
const child = nodeSpawn(cmd[0], cmd.slice(1), {
|
|
224
238
|
cwd: opts.cwd,
|
|
225
239
|
env: { ...process.env, ...opts.env },
|
|
226
|
-
stdio
|
|
240
|
+
stdio,
|
|
227
241
|
});
|
|
242
|
+
if (fd !== null) {
|
|
243
|
+
// The child holds its own copy of the descriptor.
|
|
244
|
+
try {
|
|
245
|
+
closeSync(fd);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// already closed
|
|
249
|
+
}
|
|
250
|
+
}
|
|
228
251
|
return { kill: () => child.kill() };
|
|
229
252
|
}
|
|
230
253
|
export async function runHostedLoop(opts) {
|
|
@@ -386,13 +409,25 @@ export async function runHostedLoop(opts) {
|
|
|
386
409
|
});
|
|
387
410
|
}
|
|
388
411
|
const spawnImpl = opts.spawnOpenCode ?? spawnOpenCode;
|
|
389
|
-
|
|
412
|
+
// The default spawnOpenCode WAITS for the server to answer HTTP before
|
|
413
|
+
// returning — OpenCode's first boot runs a one-time sqlite migration, and
|
|
414
|
+
// starting the harness before the server listens raced straight into a
|
|
415
|
+
// connection-refused "fetch failed" (ALI-1034).
|
|
416
|
+
spawned = await spawnImpl({
|
|
390
417
|
model: context.model,
|
|
391
418
|
cwd: context.workspaceDir,
|
|
392
419
|
port: context.opencodePort,
|
|
393
420
|
env: context.anthropicDummyKey ? { ANTHROPIC_API_KEY: context.anthropicDummyKey } : undefined,
|
|
394
421
|
spawn: nodeChildSpawner,
|
|
422
|
+
logPath: join(tmpdir(), `opencode-serve-${context.runId}.log`),
|
|
423
|
+
signal,
|
|
395
424
|
});
|
|
425
|
+
if (typeof spawned.readyAfterMs === 'number') {
|
|
426
|
+
await sink.append({
|
|
427
|
+
kind: 'artifact',
|
|
428
|
+
payload: { step: 'opencode_ready', ok: true, waitedMs: spawned.readyAfterMs },
|
|
429
|
+
});
|
|
430
|
+
}
|
|
396
431
|
harness = (opts.createHarness ?? ((baseUrl) => createOpenCodeHarness({ baseUrl })))(spawned.baseUrl);
|
|
397
432
|
}
|
|
398
433
|
const started = await harness.start({
|
|
@@ -31,6 +31,14 @@ export const DEFAULT_CACHE_REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
|
|
31
31
|
/** Customer setup hook path, relative to the repo root (mirrors OpenInspect's
|
|
32
32
|
* `.openinspect/setup.sh` — fresh-boot only, non-fatal, output captured). */
|
|
33
33
|
export const SETUP_HOOK_RELATIVE_PATH = '.orizu/setup.sh';
|
|
34
|
+
/**
|
|
35
|
+
* The git author identity for hosted-agent commits. Bootstrap configures it
|
|
36
|
+
* repo-LOCAL in the sandbox clone (never --global) so agent `git commit` works
|
|
37
|
+
* out of the box — the sandbox has no ambient identity and commits died with
|
|
38
|
+
* "Author identity unknown" (ALI-1036, found live in QA-3). The loop uses the
|
|
39
|
+
* same identity for run-event attribution; keep them identical.
|
|
40
|
+
*/
|
|
41
|
+
export const AGENT_GIT_IDENTITY = { name: 'Orizu Agent', email: 'agent@orizu.ai' };
|
|
34
42
|
// -- Pre-baked runtime marker (ALI-1017) -------------------------------------
|
|
35
43
|
/**
|
|
36
44
|
* Absolute path of the marker the pre-baked hosted-runtime image writes at build
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
import { createLocalSimProvider, } from './sandbox-provider.js';
|
|
38
38
|
import { bootstrapHostedSandbox, createBootstrapRunEventSink, } from './hosted-bootstrap.js';
|
|
39
39
|
import { resumeRunEventSink } from './hosted-run-event-sink.js';
|
|
40
|
-
import { BEARER_BASENAME } from './hosted-runtime-assets.js';
|
|
40
|
+
import { AGENT_GIT_IDENTITY, BEARER_BASENAME } from './hosted-runtime-assets.js';
|
|
41
41
|
import { createVercelProvider } from './vercel-sandbox-provider.js';
|
|
42
42
|
import { buildEgressPolicy, DEFAULT_EGRESS_CANARY_HOST } from './egress-policy.js';
|
|
43
43
|
import { hostedLoopCommand } from './hosted-loop.js';
|
|
@@ -355,7 +355,7 @@ export async function startHostedSession(opts) {
|
|
|
355
355
|
model,
|
|
356
356
|
reasoningEffort: opts.reasoningEffort,
|
|
357
357
|
messageId: `${runId}:task`,
|
|
358
|
-
author:
|
|
358
|
+
author: AGENT_GIT_IDENTITY,
|
|
359
359
|
anthropicDummyKey: opts.modelApiKey ? ANTHROPIC_DUMMY_KEY : undefined,
|
|
360
360
|
// ALI-1017: skip the in-sandbox opencode install when the image ships it.
|
|
361
361
|
prebaked: prebakedImage,
|
package/package.json
CHANGED
package/src/hosted-bootstrap.ts
CHANGED
|
@@ -27,6 +27,7 @@ import type { SandboxSession } from './sandbox-provider.js'
|
|
|
27
27
|
import { sweepForTokenResidue, type HygieneFinding } from './daytona-slice.js'
|
|
28
28
|
import { redactSecrets } from './secret-redaction.js'
|
|
29
29
|
import {
|
|
30
|
+
AGENT_GIT_IDENTITY,
|
|
30
31
|
BEARER_BASENAME,
|
|
31
32
|
BOOT_CONTEXT_BASENAME,
|
|
32
33
|
DEFAULT_CACHE_REFRESH_BUFFER_MS,
|
|
@@ -410,6 +411,11 @@ export async function bootstrapHostedSandbox(opts: HostedBootstrapOptions): Prom
|
|
|
410
411
|
// Persist the helper repo-LOCAL (never --global) for subsequent fetch/push.
|
|
411
412
|
await session.exec(`git -C ${workspaceDir} config credential.helper ${shellSingleQuote(helperValue)}`)
|
|
412
413
|
await session.exec(`git -C ${workspaceDir} config credential.useHttpPath true`)
|
|
414
|
+
// Repo-LOCAL agent identity: the sandbox has no ambient git identity, so
|
|
415
|
+
// without this every agent `git commit` dies with "Author identity unknown"
|
|
416
|
+
// (ALI-1036, found live in QA-3). Same identity the loop stamps on events.
|
|
417
|
+
await session.exec(`git -C ${workspaceDir} config user.name ${shellSingleQuote(AGENT_GIT_IDENTITY.name)}`)
|
|
418
|
+
await session.exec(`git -C ${workspaceDir} config user.email ${shellSingleQuote(AGENT_GIT_IDENTITY.email)}`)
|
|
413
419
|
record('repo_cloned', true, `cloned ${opts.sessionBranch}`)
|
|
414
420
|
await sink.append('repo_cloned', { branch: opts.sessionBranch, workspaceDir })
|
|
415
421
|
|
|
@@ -49,6 +49,8 @@
|
|
|
49
49
|
* would break both ports and must be re-verified.
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
|
+
import { readFileSync } from 'fs'
|
|
53
|
+
|
|
52
54
|
import type {
|
|
53
55
|
AgentHarness,
|
|
54
56
|
HarnessEvent,
|
|
@@ -781,24 +783,62 @@ export interface SpawnOpenCodeOptions extends OpenCodeConfigOptions {
|
|
|
781
783
|
/** Extra env merged over the config-content injection. */
|
|
782
784
|
env?: Record<string, string>
|
|
783
785
|
/** Injectable spawner (defaults to Bun.spawn) so this stays unit-testable. */
|
|
784
|
-
spawn?: (
|
|
786
|
+
spawn?: (
|
|
787
|
+
cmd: string[],
|
|
788
|
+
opts: { cwd?: string; env: Record<string, string>; logPath?: string }
|
|
789
|
+
) => { kill: () => void }
|
|
790
|
+
/** File the spawner should append opencode's stdout/stderr to. On readiness
|
|
791
|
+
* timeout its tail is included in the thrown error — the bare "fetch failed"
|
|
792
|
+
* this replaces was undiagnosable (ALI-1034). */
|
|
793
|
+
logPath?: string
|
|
794
|
+
/** Overall readiness budget. OpenCode's FIRST boot runs a one-time sqlite
|
|
795
|
+
* migration ("may take a few minutes") before it listens, so this must
|
|
796
|
+
* comfortably cover a cold snapshot. */
|
|
797
|
+
readyTimeoutMs?: number
|
|
798
|
+
/** Poll interval while waiting for the server to answer. */
|
|
799
|
+
readyPollMs?: number
|
|
800
|
+
/** Abort (e.g. run cancelled) while still waiting for readiness. */
|
|
801
|
+
signal?: AbortSignal
|
|
802
|
+
/** Injectable readiness probe (defaults to global fetch) for tests. */
|
|
803
|
+
fetchImpl?: typeof fetch
|
|
785
804
|
}
|
|
786
805
|
|
|
787
806
|
export interface SpawnedOpenCode {
|
|
788
807
|
baseUrl: string
|
|
789
808
|
port: number
|
|
790
809
|
stop: () => void
|
|
810
|
+
/** How long the server took to answer its first request (set by the real
|
|
811
|
+
* spawnOpenCode; injected test spawners may omit it). */
|
|
812
|
+
readyAfterMs?: number
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const OPENCODE_READY_TIMEOUT_MS = 180_000
|
|
816
|
+
const OPENCODE_READY_POLL_MS = 500
|
|
817
|
+
const OPENCODE_READY_PROBE_TIMEOUT_MS = 2_000
|
|
818
|
+
const OPENCODE_LOG_TAIL_BYTES = 2_000
|
|
819
|
+
|
|
820
|
+
function readLogTail(logPath: string | undefined): string | null {
|
|
821
|
+
if (!logPath) return null
|
|
822
|
+
try {
|
|
823
|
+
const content = readFileSync(logPath, 'utf8')
|
|
824
|
+
if (!content.trim()) return null
|
|
825
|
+
return content.slice(-OPENCODE_LOG_TAIL_BYTES).trim()
|
|
826
|
+
} catch {
|
|
827
|
+
return null
|
|
828
|
+
}
|
|
791
829
|
}
|
|
792
830
|
|
|
793
831
|
/**
|
|
794
|
-
* Launch `opencode serve` locally
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
*
|
|
832
|
+
* Launch `opencode serve` locally and WAIT until it answers HTTP. OpenCode's
|
|
833
|
+
* first boot performs a one-time sqlite migration before it listens; returning
|
|
834
|
+
* the baseUrl without waiting raced the harness's first `POST /session` into a
|
|
835
|
+
* connection-refused "fetch failed" (ALI-1034, found live in QA-3). Any HTTP
|
|
836
|
+
* status counts as ready — we only need the socket to be accepting. Process
|
|
837
|
+
* supervision is otherwise intentionally minimal for this slice; the pinned
|
|
838
|
+
* version (`OPENCODE_PINNED_VERSION`) is the one the sandbox image must install
|
|
839
|
+
* (see the SSE-fragility note on that constant).
|
|
800
840
|
*/
|
|
801
|
-
export function spawnOpenCode(opts: SpawnOpenCodeOptions): SpawnedOpenCode {
|
|
841
|
+
export async function spawnOpenCode(opts: SpawnOpenCodeOptions): Promise<SpawnedOpenCode> {
|
|
802
842
|
const port = opts.port ?? DEFAULT_OPENCODE_PORT
|
|
803
843
|
const env: Record<string, string> = {
|
|
804
844
|
OPENCODE_CONFIG_CONTENT: buildOpenCodeConfigContent(opts),
|
|
@@ -818,6 +858,47 @@ export function spawnOpenCode(opts: SpawnOpenCodeOptions): SpawnedOpenCode {
|
|
|
818
858
|
stderr: 'ignore',
|
|
819
859
|
})
|
|
820
860
|
})
|
|
821
|
-
const child = spawnImpl(cmd, { cwd: opts.cwd, env })
|
|
822
|
-
|
|
861
|
+
const child = spawnImpl(cmd, { cwd: opts.cwd, env, logPath: opts.logPath })
|
|
862
|
+
const baseUrl = `http://localhost:${port}`
|
|
863
|
+
|
|
864
|
+
const fetchImpl = opts.fetchImpl ?? (globalThis.fetch as typeof fetch)
|
|
865
|
+
const timeoutMs = opts.readyTimeoutMs ?? OPENCODE_READY_TIMEOUT_MS
|
|
866
|
+
const pollMs = opts.readyPollMs ?? OPENCODE_READY_POLL_MS
|
|
867
|
+
const startedAt = Date.now()
|
|
868
|
+
for (;;) {
|
|
869
|
+
if (opts.signal?.aborted) {
|
|
870
|
+
child.kill()
|
|
871
|
+
throw new Error('opencode serve readiness wait aborted')
|
|
872
|
+
}
|
|
873
|
+
try {
|
|
874
|
+
const probe = new AbortController()
|
|
875
|
+
const timer = setTimeout(() => probe.abort(), OPENCODE_READY_PROBE_TIMEOUT_MS)
|
|
876
|
+
// Link the run signal so an abort tears down an in-flight probe
|
|
877
|
+
// immediately instead of waiting out the per-probe timeout.
|
|
878
|
+
const probeSignal = opts.signal ? AbortSignal.any([opts.signal, probe.signal]) : probe.signal
|
|
879
|
+
try {
|
|
880
|
+
// Any HTTP response (even 404) proves the server is accepting.
|
|
881
|
+
const res = await fetchImpl(`${baseUrl}/session`, { signal: probeSignal })
|
|
882
|
+
await cancelBody(res.body)
|
|
883
|
+
break
|
|
884
|
+
} finally {
|
|
885
|
+
clearTimeout(timer)
|
|
886
|
+
}
|
|
887
|
+
} catch {
|
|
888
|
+
// not listening yet — fall through to the timeout check + sleep
|
|
889
|
+
}
|
|
890
|
+
// Soft budget: checked between probes, so the total wait can overrun by up
|
|
891
|
+
// to one probe timeout + poll interval. Negligible against the default.
|
|
892
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
893
|
+
child.kill()
|
|
894
|
+
const tail = readLogTail(opts.logPath)
|
|
895
|
+
throw new Error(
|
|
896
|
+
`opencode serve did not become ready on ${baseUrl} within ${timeoutMs}ms` +
|
|
897
|
+
(tail ? ` — log tail: ${tail}` : ' (no log output captured)')
|
|
898
|
+
)
|
|
899
|
+
}
|
|
900
|
+
await new Promise(resolve => setTimeout(resolve, pollMs))
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
return { baseUrl, port, stop: () => child.kill(), readyAfterMs: Date.now() - startedAt }
|
|
823
904
|
}
|
package/src/hosted-loop.ts
CHANGED
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import { spawnSync, spawn as nodeSpawn } from 'child_process'
|
|
37
|
-
import { existsSync, readFileSync } from 'fs'
|
|
37
|
+
import { closeSync, existsSync, openSync, readFileSync } from 'fs'
|
|
38
|
+
import { tmpdir } from 'os'
|
|
38
39
|
import { join } from 'path'
|
|
39
40
|
|
|
40
41
|
import type { AgentHarness, HarnessEvent, HarnessPrompt } from './hosted-harness.js'
|
|
@@ -156,7 +157,7 @@ export interface RunHostedLoopOptions {
|
|
|
156
157
|
/** Build the in-process Claude-Agent-SDK harness (default: real SDK loader).
|
|
157
158
|
* Injectable so the swap test drives a fake `query()` with no real SDK. */
|
|
158
159
|
createClaudeHarness?: () => AgentHarness
|
|
159
|
-
spawnOpenCode?: (opts: SpawnOpenCodeOptions) => SpawnedOpenCode
|
|
160
|
+
spawnOpenCode?: (opts: SpawnOpenCodeOptions) => SpawnedOpenCode | Promise<SpawnedOpenCode>
|
|
160
161
|
installOpenCode?: () => Promise<InstallResult>
|
|
161
162
|
now?: () => number
|
|
162
163
|
signal?: AbortSignal
|
|
@@ -402,16 +403,36 @@ function installOpenCodePinned(version: string): InstallResult {
|
|
|
402
403
|
|
|
403
404
|
/** Node child_process spawner for `opencode serve` (the sandbox runtime is
|
|
404
405
|
* node24 — Bun.spawn is not guaranteed present, so spawnOpenCode is given this
|
|
405
|
-
* runtime-agnostic spawner).
|
|
406
|
+
* runtime-agnostic spawner). When a logPath is provided, opencode's
|
|
407
|
+
* stdout/stderr land there so a readiness timeout can surface the real boot
|
|
408
|
+
* failure instead of a bare "fetch failed" (ALI-1034). */
|
|
406
409
|
function nodeChildSpawner(
|
|
407
410
|
cmd: string[],
|
|
408
|
-
opts: { cwd?: string; env: Record<string, string
|
|
411
|
+
opts: { cwd?: string; env: Record<string, string>; logPath?: string }
|
|
409
412
|
): { kill: () => void } {
|
|
413
|
+
let stdio: 'ignore' | Array<'ignore' | number> = 'ignore'
|
|
414
|
+
let fd: number | null = null
|
|
415
|
+
if (opts.logPath) {
|
|
416
|
+
try {
|
|
417
|
+
fd = openSync(opts.logPath, 'a')
|
|
418
|
+
stdio = ['ignore', fd, fd]
|
|
419
|
+
} catch {
|
|
420
|
+
// log capture is best-effort — never block the spawn on it
|
|
421
|
+
}
|
|
422
|
+
}
|
|
410
423
|
const child = nodeSpawn(cmd[0], cmd.slice(1), {
|
|
411
424
|
cwd: opts.cwd,
|
|
412
425
|
env: { ...process.env, ...opts.env },
|
|
413
|
-
stdio
|
|
426
|
+
stdio,
|
|
414
427
|
})
|
|
428
|
+
if (fd !== null) {
|
|
429
|
+
// The child holds its own copy of the descriptor.
|
|
430
|
+
try {
|
|
431
|
+
closeSync(fd)
|
|
432
|
+
} catch {
|
|
433
|
+
// already closed
|
|
434
|
+
}
|
|
435
|
+
}
|
|
415
436
|
return { kill: () => child.kill() }
|
|
416
437
|
}
|
|
417
438
|
|
|
@@ -575,13 +596,25 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
|
|
|
575
596
|
}
|
|
576
597
|
|
|
577
598
|
const spawnImpl = opts.spawnOpenCode ?? spawnOpenCode
|
|
578
|
-
|
|
599
|
+
// The default spawnOpenCode WAITS for the server to answer HTTP before
|
|
600
|
+
// returning — OpenCode's first boot runs a one-time sqlite migration, and
|
|
601
|
+
// starting the harness before the server listens raced straight into a
|
|
602
|
+
// connection-refused "fetch failed" (ALI-1034).
|
|
603
|
+
spawned = await spawnImpl({
|
|
579
604
|
model: context.model,
|
|
580
605
|
cwd: context.workspaceDir,
|
|
581
606
|
port: context.opencodePort,
|
|
582
607
|
env: context.anthropicDummyKey ? { ANTHROPIC_API_KEY: context.anthropicDummyKey } : undefined,
|
|
583
608
|
spawn: nodeChildSpawner,
|
|
609
|
+
logPath: join(tmpdir(), `opencode-serve-${context.runId}.log`),
|
|
610
|
+
signal,
|
|
584
611
|
})
|
|
612
|
+
if (typeof spawned.readyAfterMs === 'number') {
|
|
613
|
+
await sink.append({
|
|
614
|
+
kind: 'artifact',
|
|
615
|
+
payload: { step: 'opencode_ready', ok: true, waitedMs: spawned.readyAfterMs },
|
|
616
|
+
})
|
|
617
|
+
}
|
|
585
618
|
|
|
586
619
|
harness = (opts.createHarness ?? ((baseUrl: string) => createOpenCodeHarness({ baseUrl })))(
|
|
587
620
|
spawned.baseUrl
|
|
@@ -35,6 +35,15 @@ export const DEFAULT_CACHE_REFRESH_BUFFER_MS = 5 * 60 * 1000
|
|
|
35
35
|
* `.openinspect/setup.sh` — fresh-boot only, non-fatal, output captured). */
|
|
36
36
|
export const SETUP_HOOK_RELATIVE_PATH = '.orizu/setup.sh'
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The git author identity for hosted-agent commits. Bootstrap configures it
|
|
40
|
+
* repo-LOCAL in the sandbox clone (never --global) so agent `git commit` works
|
|
41
|
+
* out of the box — the sandbox has no ambient identity and commits died with
|
|
42
|
+
* "Author identity unknown" (ALI-1036, found live in QA-3). The loop uses the
|
|
43
|
+
* same identity for run-event attribution; keep them identical.
|
|
44
|
+
*/
|
|
45
|
+
export const AGENT_GIT_IDENTITY = { name: 'Orizu Agent', email: 'agent@orizu.ai' } as const
|
|
46
|
+
|
|
38
47
|
// -- Pre-baked runtime marker (ALI-1017) -------------------------------------
|
|
39
48
|
|
|
40
49
|
/**
|
|
@@ -49,7 +49,7 @@ import {
|
|
|
49
49
|
type HostedRuntimePaths,
|
|
50
50
|
} from './hosted-bootstrap.js'
|
|
51
51
|
import { resumeRunEventSink } from './hosted-run-event-sink.js'
|
|
52
|
-
import { BEARER_BASENAME } from './hosted-runtime-assets.js'
|
|
52
|
+
import { AGENT_GIT_IDENTITY, BEARER_BASENAME } from './hosted-runtime-assets.js'
|
|
53
53
|
import { createVercelProvider } from './vercel-sandbox-provider.js'
|
|
54
54
|
import { buildEgressPolicy, DEFAULT_EGRESS_CANARY_HOST } from './egress-policy.js'
|
|
55
55
|
import { hostedLoopCommand, type HostedLoopContext } from './hosted-loop.js'
|
|
@@ -534,7 +534,7 @@ export async function startHostedSession(
|
|
|
534
534
|
model,
|
|
535
535
|
reasoningEffort: opts.reasoningEffort,
|
|
536
536
|
messageId: `${runId}:task`,
|
|
537
|
-
author:
|
|
537
|
+
author: AGENT_GIT_IDENTITY,
|
|
538
538
|
anthropicDummyKey: opts.modelApiKey ? ANTHROPIC_DUMMY_KEY : undefined,
|
|
539
539
|
// ALI-1017: skip the in-sandbox opencode install when the image ships it.
|
|
540
540
|
prebaked: prebakedImage,
|