@agent-compose/sdk 0.8.1 → 0.8.2
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/agent/agent-context.d.ts +1 -1
- package/dist/client.d.ts +29 -11
- package/dist/index.d.ts +2 -2
- package/dist/index.js +267 -85
- package/dist/runtimes/_cli-agent.d.ts +25 -0
- package/dist/runtimes/codex.buildcommand.test.d.ts +9 -0
- package/dist/runtimes/openai-desktop.js +259 -85
- package/dist/sandbox/sizes.d.ts +120 -30
- package/dist/sandbox.d.ts +1 -1
- package/dist/types/api-conversations.d.ts +167 -0
- package/dist/types/api-factory.d.ts +59 -7
- package/dist/types/api-runs.d.ts +2 -1
- package/dist/types/workflow-metadata.d.ts +6 -5
- package/package.json +1 -1
- package/src/agent/agent-context.ts +28 -17
- package/src/client.ts +56 -20
- package/src/index.ts +11 -6
- package/src/runtimes/_cli-agent.ts +293 -56
- package/src/runtimes/codex.ts +8 -2
- package/src/sandbox/providers/e2b.ts +8 -4
- package/src/sandbox/sizes.ts +127 -44
- package/src/sandbox.ts +8 -0
- package/src/types/api-conversations.ts +155 -0
- package/src/types/api-factory.ts +57 -7
- package/src/types/api-runs.ts +2 -1
- package/src/types/workflow-metadata.ts +6 -5
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* values set via `agentc secrets set` are visible to the CLI.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import type { AgentMessage, ModelExecutionContract, RuntimeOptions,
|
|
27
|
+
import type { AgentMessage, ModelExecutionContract, RuntimeOptions, SandboxProvider, ToolCallGateResult } from "../index.js";
|
|
28
28
|
import { defineRuntime } from "../types/runtime.js";
|
|
29
29
|
import { AsyncQueue } from "../agent/async-queue.js";
|
|
30
30
|
import { formatError } from "../utils/errors.js";
|
|
@@ -35,12 +35,65 @@ import { RequestContext } from "../request-context/request-context.js";
|
|
|
35
35
|
import { AcpClientPeer, ACP_PROTOCOL_VERSION } from "./_acp-client.js";
|
|
36
36
|
import { isPauseSignal, boundProcessorPause } from "../pause/pause-core.js";
|
|
37
37
|
import {
|
|
38
|
-
JSONL_GUARD_LINE_CAP_BYTES, JSONL_GUARD_KEEP_BYTES,
|
|
38
|
+
JSONL_GUARD_LINE_CAP_BYTES, JSONL_GUARD_KEEP_BYTES, JSONL_GUARD_NO_SENTINEL_EXIT,
|
|
39
39
|
jsonlGuardScript, wrapJsonlCommand,
|
|
40
40
|
} from "./_jsonl-guard.js";
|
|
41
|
+
import type { SandboxBackgroundProcess } from "../types/sandbox.js";
|
|
41
42
|
|
|
42
43
|
function now(): string { return new Date().toISOString(); }
|
|
43
44
|
|
|
45
|
+
/** Backoff between tail re-attach attempts after a mid-turn stream fault
|
|
46
|
+
* (durable detached transport). Short: the runner is alive and producing,
|
|
47
|
+
* and each retry costs one exec; the executor's evidence machinery — not
|
|
48
|
+
* this loop — bounds a truly dead sandbox. Exported for tests. */
|
|
49
|
+
export const TAIL_REATTACH_DELAY_MS = 2_000;
|
|
50
|
+
|
|
51
|
+
/** Provider deadline on the detached LAUNCH exec. The launch shell only
|
|
52
|
+
* truncates the durable files, forks the detached runner (stdio fully
|
|
53
|
+
* redirected — see the launch command), writes the pidfile, and echoes the
|
|
54
|
+
* pid — sub-second work, so 30s is generous headroom for a slow envd, not a
|
|
55
|
+
* budget the runner ever consumes. Exported for tests. */
|
|
56
|
+
export const LAUNCH_EXEC_TIMEOUT_MS = 30_000;
|
|
57
|
+
|
|
58
|
+
/** How many times (and how spaced) a THROWN launch exec falls back to reading
|
|
59
|
+
* the durable pidfile before the fault is surfaced. A deadlined/canceled
|
|
60
|
+
* launch STREAM does not mean the launch failed — the detached tree may be
|
|
61
|
+
* up and working — so the pid is recovered over the envd HTTP file
|
|
62
|
+
* transport (immune to the command-stream fault) and the turn proceeds.
|
|
63
|
+
* Only "no pidfile after these attempts" is a real launch failure.
|
|
64
|
+
* Exported for tests. */
|
|
65
|
+
export const LAUNCH_PID_RECOVERY_ATTEMPTS = 3;
|
|
66
|
+
export const LAUNCH_PID_RECOVERY_DELAY_MS = 1_000;
|
|
67
|
+
|
|
68
|
+
/** Explicit deadline on the `command -v` install probe. E2B's default command
|
|
69
|
+
* deadline (60s) throws the deadline_exceeded TimeoutError — every provider
|
|
70
|
+
* call in the turn path carries an explicit timeout so no SDK default can
|
|
71
|
+
* decide a turn's fate. Exported for tests. */
|
|
72
|
+
export const INSTALL_PROBE_TIMEOUT_MS = 30_000;
|
|
73
|
+
|
|
74
|
+
/** Parse a pid out of captured stdout (last non-empty line). NaN when absent. */
|
|
75
|
+
function parsePid(raw: string): number {
|
|
76
|
+
const pid = Number.parseInt(raw.trim().split("\n").pop() ?? "", 10);
|
|
77
|
+
return Number.isInteger(pid) && pid > 0 ? pid : Number.NaN;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Recover the detached runner's pid from its durable pidfile after the
|
|
81
|
+
* launch exec's STREAM died (deadline_exceeded, canceled context, any wire
|
|
82
|
+
* fault). Reads over the file transport with a short retry so the race
|
|
83
|
+
* where the launch shell is still writing the pidfile is absorbed. */
|
|
84
|
+
async function recoverLaunchPid(read: (path: string) => Promise<string>, pidPath: string): Promise<number> {
|
|
85
|
+
for (let attempt = 0; attempt < LAUNCH_PID_RECOVERY_ATTEMPTS; attempt++) {
|
|
86
|
+
try {
|
|
87
|
+
const pid = parsePid(await read(pidPath));
|
|
88
|
+
if (!Number.isNaN(pid)) return pid;
|
|
89
|
+
} catch { /* file plane briefly unreachable — retried */ }
|
|
90
|
+
if (attempt < LAUNCH_PID_RECOVERY_ATTEMPTS - 1) {
|
|
91
|
+
await new Promise((r) => setTimeout(r, LAUNCH_PID_RECOVERY_DELAY_MS));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return Number.NaN;
|
|
95
|
+
}
|
|
96
|
+
|
|
44
97
|
/** Single-quote a value for safe interpolation into a `sh -c` command line. */
|
|
45
98
|
export function shellQuote(value: string): string {
|
|
46
99
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
@@ -302,7 +355,8 @@ export class CliAgentRunner implements ModelExecutionContract {
|
|
|
302
355
|
* install command runs exactly once: on the first run of a content hash. */
|
|
303
356
|
private async ensureInstalled(): Promise<void> {
|
|
304
357
|
if (this.installed) return;
|
|
305
|
-
const probe = await this.sandbox.commands.run(
|
|
358
|
+
const probe = await this.sandbox.commands.run(
|
|
359
|
+
`command -v ${this.spec.bin}`, { timeoutMs: INSTALL_PROBE_TIMEOUT_MS });
|
|
306
360
|
if (probe.exitCode === 0) { this.installed = true; return; }
|
|
307
361
|
const res = await this.sandbox.commands.run(this.spec.install, { timeoutMs: 300_000 });
|
|
308
362
|
if (res.exitCode !== 0) {
|
|
@@ -640,53 +694,224 @@ export class CliAgentRunner implements ModelExecutionContract {
|
|
|
640
694
|
keepBytes: JSONL_GUARD_KEEP_BYTES,
|
|
641
695
|
});
|
|
642
696
|
|
|
643
|
-
// Bridge the
|
|
644
|
-
// JSONL lines.
|
|
697
|
+
// Bridge the transport's output into an async-iterable of complete
|
|
698
|
+
// JSONL lines. Two transports feed it (chosen below):
|
|
699
|
+
//
|
|
700
|
+
// - DURABLE DETACHED (providers with `runBackground` + `files.read`,
|
|
701
|
+
// i.e. E2B — every cloud session): the CLI runs as a DETACHED
|
|
702
|
+
// in-guest process whose stdout is a durable file; the server only
|
|
703
|
+
// ever TAILS that file, and a dead tail is re-attached (or the file
|
|
704
|
+
// re-read over the envd HTTP file transport) without the runner
|
|
705
|
+
// noticing. The exit code travels IN-BAND as a final sentinel line.
|
|
706
|
+
// This exists because of the 2026-08-14 prod incident (conversation
|
|
707
|
+
// 8bc082f0, turn bfcb318f): the turn's single streaming exec was the
|
|
708
|
+
// CLI's lifeline — when the connect-web stream's context was
|
|
709
|
+
// canceled at the wire 50s in (envd logged `context canceled`), envd
|
|
710
|
+
// tore down the exec's stdout, the guard died on EPIPE, the CLI took
|
|
711
|
+
// SIGPIPE mid-work, the finished output was lost, and `wait()` never
|
|
712
|
+
// settled — the turn wedged silently until the next dispatch
|
|
713
|
+
// superseded it as a phantom "session restarted".
|
|
714
|
+
//
|
|
715
|
+
// - SINGLE-EXEC STREAMING (everything else: local child_process,
|
|
716
|
+
// Vercel): the pre-incident shape — one exec, stdout streamed,
|
|
717
|
+
// exit code from the exec result.
|
|
645
718
|
const lines = new AsyncQueue<string>();
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
const runOpts = {
|
|
657
|
-
...(this.options.cwd ? { cwd: this.options.cwd } : {}),
|
|
658
|
-
onStdout,
|
|
659
|
-
timeoutMs: 0, // no provider deadline — see the doc comment above
|
|
660
|
-
};
|
|
661
|
-
|
|
662
|
-
// The command resolves when the process exits. Kick it off (don't await
|
|
663
|
-
// yet); flush the trailing buffer + close the queue on completion so the
|
|
664
|
-
// for-await below drains and we can read the exit code. Prefer the
|
|
665
|
-
// kill-capable background handle so abort/early-exit can terminate the
|
|
666
|
-
// process itself, not just stop reading its stream.
|
|
667
|
-
let exited = false;
|
|
668
|
-
const settle = (res: SandboxCommandResult): SandboxCommandResult => {
|
|
669
|
-
exited = true;
|
|
670
|
-
const tail = buf.trim();
|
|
671
|
-
if (tail) lines.push(tail);
|
|
672
|
-
lines.close();
|
|
673
|
-
return res;
|
|
674
|
-
};
|
|
675
|
-
const fail = (err: unknown): never => { exited = true; lines.close(); throw err; };
|
|
676
|
-
let kill: (() => Promise<void>) | undefined;
|
|
677
|
-
let runPromise: Promise<SandboxCommandResult>;
|
|
719
|
+
// How the turn ended: the CLI's exit code once known (null while
|
|
720
|
+
// running), plus a best-effort stderr tail for the error message.
|
|
721
|
+
let exitCode: number | null = null;
|
|
722
|
+
let exitStderr = "";
|
|
723
|
+
let transportError: unknown = null;
|
|
724
|
+
let consumerStopped = false;
|
|
725
|
+
let reap: () => void = () => { /* set per transport */ };
|
|
726
|
+
let transport: Promise<void>;
|
|
727
|
+
|
|
728
|
+
const durableRead = this.sandbox.files.read?.bind(this.sandbox.files);
|
|
678
729
|
if (this.sandbox.commands.runBackground) {
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
730
|
+
// ── Durable detached transport ─────────────────────────────────────
|
|
731
|
+
const outPath = `${promptPath}.out`;
|
|
732
|
+
const errPath = `${promptPath}.err`;
|
|
733
|
+
// The exit sentinel is appended by the DETACHED script itself after
|
|
734
|
+
// the guarded pipeline exits, so it lands in the durable file even if
|
|
735
|
+
// every server↔sandbox stream is dead by then. In node-guard mode the
|
|
736
|
+
// pipeline's exit code is already the CLI's (the guard re-raises the
|
|
737
|
+
// inner sentinel, including NO_SENTINEL_EXIT for a mid-pipe death).
|
|
738
|
+
const detachedScript =
|
|
739
|
+
`{ ${guardedCmd}; } >> ${shellQuote(outPath)} 2>> ${shellQuote(errPath)} </dev/null; `
|
|
740
|
+
+ `printf '\\n%s %s\\n' ${shellQuote(sentinel)} "$?" >> ${shellQuote(outPath)}`;
|
|
741
|
+
// `setsid` detaches the runner into its own session/process group so
|
|
742
|
+
// it survives the launch exec ending AND gives `kill -- -pid` a whole
|
|
743
|
+
// tree to terminate. The fallback keeps working where setsid is
|
|
744
|
+
// absent; a runner that dies anyway surfaces as NO_SENTINEL below.
|
|
745
|
+
//
|
|
746
|
+
// The `>/dev/null 2>&1 </dev/null` on the forked child is LOAD-BEARING,
|
|
747
|
+
// not hygiene (2026-08-14 prod regression, v0.10.39): without it the
|
|
748
|
+
// detached `sh` inherits the LAUNCH exec's stdout/stderr pipes, and
|
|
749
|
+
// envd only ends a command's event stream on pipe EOF — so the launch
|
|
750
|
+
// exec's wait() couldn't settle until the entire detached CLI tree
|
|
751
|
+
// exited, the 30s launch deadline fired first, and EVERY turn died
|
|
752
|
+
// with E2B's `[deadline_exceeded] the operation timed out: … exceeding
|
|
753
|
+
// 'timeoutMs' …` while the orphaned runner kept working unobserved.
|
|
754
|
+
// (The runner's real stdio is redirected INSIDE detachedScript — into
|
|
755
|
+
// the durable out/err files — so the child needs nothing from the
|
|
756
|
+
// launch exec's pipes.)
|
|
757
|
+
//
|
|
758
|
+
// The pid ALSO lands in a durable pidfile so a launch whose STREAM
|
|
759
|
+
// dies (the same wire faults the tail survives) is recovered over the
|
|
760
|
+
// file transport instead of surfacing as turn death — see the catch
|
|
761
|
+
// below.
|
|
762
|
+
const pidPath = `${promptPath}.pid`;
|
|
763
|
+
const launchCmd =
|
|
764
|
+
`: > ${shellQuote(outPath)}; : > ${shellQuote(errPath)}; `
|
|
765
|
+
+ `if command -v setsid >/dev/null 2>&1; then setsid sh -c ${shellQuote(detachedScript)} >/dev/null 2>&1 </dev/null & `
|
|
766
|
+
+ `else sh -c ${shellQuote(detachedScript)} >/dev/null 2>&1 </dev/null & fi; `
|
|
767
|
+
+ `echo "$!" > ${shellQuote(pidPath)}; echo "$!"`;
|
|
768
|
+
let pid = Number.NaN;
|
|
769
|
+
let launched: { exitCode: number; stdout?: string; stderr?: string } | null = null;
|
|
770
|
+
let launchErr: unknown = null;
|
|
771
|
+
try {
|
|
772
|
+
launched = await this.sandbox.commands.run(launchCmd, {
|
|
773
|
+
...(this.options.cwd ? { cwd: this.options.cwd } : {}), timeoutMs: LAUNCH_EXEC_TIMEOUT_MS });
|
|
774
|
+
} catch (err) { launchErr = err; }
|
|
775
|
+
if (launched) {
|
|
776
|
+
// The exec ROUND-TRIPPED: its exit code and stdout are authoritative.
|
|
777
|
+
pid = parsePid(launched.stdout ?? "");
|
|
778
|
+
if (launched.exitCode !== 0 || Number.isNaN(pid)) {
|
|
779
|
+
throw new Error(`failed to launch ${this.spec.kind}: `
|
|
780
|
+
+ `exit ${launched.exitCode}${launched.stderr ? `: ${launched.stderr.slice(-500)}` : ""}`);
|
|
781
|
+
}
|
|
782
|
+
} else {
|
|
783
|
+
// Structural rule of this transport: a dead STREAM is never treated
|
|
784
|
+
// as a dead RUNNER. The launch may well have run to completion with
|
|
785
|
+
// only its exec stream lost (deadline_exceeded, canceled context) —
|
|
786
|
+
// recover the pid from the durable pidfile and continue; only an
|
|
787
|
+
// absent pidfile makes the original fault real.
|
|
788
|
+
pid = durableRead ? await recoverLaunchPid(durableRead, pidPath) : Number.NaN;
|
|
789
|
+
if (Number.isNaN(pid)) throw launchErr;
|
|
790
|
+
console.warn(`[cli-agent] ${this.spec.kind} launch exec stream died but the detached runner is up `
|
|
791
|
+
+ `(pid ${pid} via ${pidPath}) — continuing on the durable transport: ${formatError(launchErr)}`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Bytes of COMPLETE lines already consumed from outPath — a re-attach
|
|
795
|
+
// tails from here, so a dead stream never duplicates or drops lines.
|
|
796
|
+
let offset = 0;
|
|
797
|
+
let currentTail: SandboxBackgroundProcess | null = null;
|
|
798
|
+
const consumeLine = (raw: string): void => {
|
|
799
|
+
offset += Buffer.byteLength(raw, "utf8") + 1;
|
|
800
|
+
const line = raw.trim();
|
|
801
|
+
if (!line) return;
|
|
802
|
+
if (line.startsWith(sentinel)) {
|
|
803
|
+
const code = Number(line.slice(sentinel.length).trim());
|
|
804
|
+
exitCode = Number.isFinite(code) ? code : 0;
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
lines.push(line);
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
reap = () => {
|
|
811
|
+
// Kill the detached tree (group first — setsid made pid the group
|
|
812
|
+
// leader), then whatever tail is currently attached. Fresh execs,
|
|
813
|
+
// deliberately independent of any possibly-dead stream.
|
|
814
|
+
void this.sandbox.commands.run(
|
|
815
|
+
`kill -TERM -- -${pid} 2>/dev/null; kill -TERM ${pid} 2>/dev/null; true`,
|
|
816
|
+
{ timeoutMs: 10_000 }).catch(() => { /* best-effort */ });
|
|
817
|
+
void currentTail?.kill().catch(() => { /* already gone */ });
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
transport = (async () => {
|
|
821
|
+
while (exitCode === null && !consumerStopped && !opts.signal?.aborted) {
|
|
822
|
+
let buf = "";
|
|
823
|
+
let handle: SandboxBackgroundProcess | null = null;
|
|
824
|
+
const onStdout = (data: string): void => {
|
|
825
|
+
buf += data;
|
|
826
|
+
let nl: number;
|
|
827
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
828
|
+
consumeLine(buf.slice(0, nl));
|
|
829
|
+
buf = buf.slice(nl + 1);
|
|
830
|
+
if (exitCode !== null) { void handle?.kill().catch(() => { /* racing exit */ }); return; }
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
try {
|
|
834
|
+
handle = await this.sandbox.commands.runBackground!(
|
|
835
|
+
`tail -c +${offset + 1} -f ${shellQuote(outPath)}`,
|
|
836
|
+
{ timeoutMs: 0, onStdout });
|
|
837
|
+
} catch { /* attach failed at the wire — durable catch-up below */ }
|
|
838
|
+
if (handle) {
|
|
839
|
+
if (exitCode !== null) void handle.kill().catch(() => { /* done */ });
|
|
840
|
+
currentTail = handle;
|
|
841
|
+
await handle.wait().catch(() => undefined);
|
|
842
|
+
currentTail = null;
|
|
843
|
+
}
|
|
844
|
+
if (exitCode !== null || consumerStopped || opts.signal?.aborted) break;
|
|
845
|
+
// The tail stream died without delivering the exit line. Catch up
|
|
846
|
+
// over the FILE transport (envd HTTP — immune to the command-
|
|
847
|
+
// stream fault): this is also what recovers an already-finished
|
|
848
|
+
// turn's result when every stream is dead.
|
|
849
|
+
if (durableRead) {
|
|
850
|
+
try {
|
|
851
|
+
const bytes = Buffer.from(await durableRead(outPath), "utf8");
|
|
852
|
+
let rest = bytes.subarray(offset).toString("utf8");
|
|
853
|
+
let nl: number;
|
|
854
|
+
while (exitCode === null && (nl = rest.indexOf("\n")) >= 0) {
|
|
855
|
+
consumeLine(rest.slice(0, nl));
|
|
856
|
+
rest = rest.slice(nl + 1);
|
|
857
|
+
}
|
|
858
|
+
} catch { /* file plane briefly unreachable — retried below */ }
|
|
859
|
+
}
|
|
860
|
+
if (exitCode !== null) break;
|
|
861
|
+
// Runner still alive? A FRESH exec answers; unknown (wire fault)
|
|
862
|
+
// keeps retrying — the executor's evidence machinery bounds a
|
|
863
|
+
// truly dead sandbox, not this loop.
|
|
864
|
+
let alive: boolean | null = null;
|
|
865
|
+
try {
|
|
866
|
+
const probe = await this.sandbox.commands.run(
|
|
867
|
+
`kill -0 ${pid} 2>/dev/null`, { timeoutMs: 10_000 });
|
|
868
|
+
alive = probe.exitCode === 0;
|
|
869
|
+
} catch { alive = null; }
|
|
870
|
+
if (alive === false) {
|
|
871
|
+
// Dead with no exit line even in the durable file: abnormal
|
|
872
|
+
// death — surfaces as an error, never a clean done.
|
|
873
|
+
exitCode = JSONL_GUARD_NO_SENTINEL_EXIT;
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
console.warn(`[cli-agent] ${this.spec.kind} turn stream lost with the runner alive — re-tailing ${outPath} from byte ${offset}`);
|
|
877
|
+
await new Promise((r) => setTimeout(r, TAIL_REATTACH_DELAY_MS));
|
|
878
|
+
}
|
|
879
|
+
if (exitCode !== null && exitCode !== 0 && durableRead) {
|
|
880
|
+
try { exitStderr = (await durableRead(errPath)).slice(-2000); }
|
|
881
|
+
catch { /* best-effort */ }
|
|
882
|
+
}
|
|
883
|
+
})().finally(() => lines.close());
|
|
682
884
|
} else {
|
|
683
|
-
|
|
885
|
+
// ── Single-exec streaming transport (no runBackground/files.read) ──
|
|
886
|
+
let buf = "";
|
|
887
|
+
const onStdout = (data: string) => {
|
|
888
|
+
buf += data;
|
|
889
|
+
let nl: number;
|
|
890
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
891
|
+
const line = buf.slice(0, nl).trim();
|
|
892
|
+
buf = buf.slice(nl + 1);
|
|
893
|
+
if (line) lines.push(line);
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
const runOpts = {
|
|
897
|
+
...(this.options.cwd ? { cwd: this.options.cwd } : {}),
|
|
898
|
+
onStdout,
|
|
899
|
+
timeoutMs: 0, // no provider deadline — see the doc comment above
|
|
900
|
+
};
|
|
901
|
+
transport = this.sandbox.commands.run(guardedCmd, runOpts).then(
|
|
902
|
+
(res) => {
|
|
903
|
+
const tail = buf.trim();
|
|
904
|
+
if (tail) lines.push(tail);
|
|
905
|
+
exitCode = res.exitCode;
|
|
906
|
+
exitStderr = (res.stderr ?? "").slice(-2000);
|
|
907
|
+
},
|
|
908
|
+
(err) => { transportError = err; },
|
|
909
|
+
).finally(() => lines.close());
|
|
684
910
|
}
|
|
685
|
-
// An early generator exit stops awaiting
|
|
911
|
+
// An early generator exit stops awaiting the transport — keep it
|
|
686
912
|
// observed so an aborted turn can never surface an unhandled rejection.
|
|
687
|
-
|
|
913
|
+
void transport.catch(() => { /* observed via `await transport` when consumed */ });
|
|
688
914
|
|
|
689
|
-
const reap = () => { if (!exited) void kill?.().catch(() => { /* already gone */ }); };
|
|
690
915
|
if (opts.signal) {
|
|
691
916
|
if (opts.signal.aborted) reap();
|
|
692
917
|
else opts.signal.addEventListener("abort", reap, { once: true });
|
|
@@ -707,26 +932,38 @@ export class CliAgentRunner implements ModelExecutionContract {
|
|
|
707
932
|
}
|
|
708
933
|
}
|
|
709
934
|
|
|
710
|
-
|
|
711
|
-
if (
|
|
712
|
-
|
|
713
|
-
|
|
935
|
+
await transport;
|
|
936
|
+
if (transportError) throw transportError;
|
|
937
|
+
if (exitCode === null) {
|
|
938
|
+
// Aborted/reaped before an exit line existed — honest terminal,
|
|
939
|
+
// never a hang and never a clean done.
|
|
940
|
+
if (!sawError) {
|
|
941
|
+
yield { type: "error", text: `${this.spec.kind} aborted — the in-sandbox runner was terminated`, timestamp: now() };
|
|
942
|
+
}
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (exitCode !== 0 && !sawError) {
|
|
946
|
+
const detail = exitCode === JSONL_GUARD_NO_SENTINEL_EXIT
|
|
947
|
+
? " (the runner died without reporting an exit code)" : "";
|
|
948
|
+
yield { type: "error", text: `${this.spec.kind} exited with code ${exitCode}${detail}${exitStderr ? `: ${exitStderr}` : ""}`, timestamp: now() };
|
|
714
949
|
return;
|
|
715
950
|
}
|
|
716
951
|
if (!sawError) yield { type: "done", sessionId: sessionId ?? "", timestamp: now() };
|
|
717
952
|
} finally {
|
|
718
953
|
// Runs on completion AND on early generator exit (the caller broke
|
|
719
954
|
// out of its for-await: abort, turn closed under the executor,
|
|
720
|
-
// supersede).
|
|
955
|
+
// supersede). Reap only while the runner hasn't reported an exit.
|
|
721
956
|
opts.signal?.removeEventListener("abort", reap);
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
//
|
|
957
|
+
consumerStopped = true;
|
|
958
|
+
if (exitCode === null) reap();
|
|
959
|
+
// Opportunistic prompt-file + guard-script + transcript cleanup:
|
|
960
|
+
// paths are never reused (see uniquePromptPath), so this is hygiene,
|
|
961
|
+
// not correctness — fire and forget, and a dead sandbox / failed rm
|
|
962
|
+
// is fine. The shell holds an open fd on the redirect, so unlinking
|
|
963
|
+
// under a still-exiting CLI is harmless.
|
|
728
964
|
void this.sandbox.commands.run(
|
|
729
|
-
`rm -f ${shellQuote(promptPath)} ${shellQuote(guardPath)}
|
|
965
|
+
`rm -f ${shellQuote(promptPath)} ${shellQuote(guardPath)} ${shellQuote(`${promptPath}.out`)} ${shellQuote(`${promptPath}.err`)} ${shellQuote(`${promptPath}.pid`)}`,
|
|
966
|
+
{ timeoutMs: 10_000 })
|
|
730
967
|
.catch(() => { /* best-effort */ });
|
|
731
968
|
}
|
|
732
969
|
} catch (err) {
|
package/src/runtimes/codex.ts
CHANGED
|
@@ -95,14 +95,20 @@ export const codexSpec: CliAgentSpec = {
|
|
|
95
95
|
// backstop for a caller that bypasses that gate. The value comes from
|
|
96
96
|
// the closed CliReasoningEffort set, so it is shell-safe unquoted.
|
|
97
97
|
...(effort ? ["-c", `model_reasoning_effort=${effort === "max" ? "xhigh" : effort}`] : []),
|
|
98
|
-
...(cwd ? ["-C", shellQuote(cwd)] : []),
|
|
99
98
|
].join(" ");
|
|
99
|
+
// Working directory via a shell `cd` — the idiom EVERY other runtime uses
|
|
100
|
+
// (claude-code/cursor/droid/opencode) — NOT codex's `-C` flag. `-C` is
|
|
101
|
+
// accepted by `codex exec` but REJECTED by `codex exec resume`
|
|
102
|
+
// ("unexpected argument '-C'"), so a fresh turn worked and every follow-up
|
|
103
|
+
// died. `cd` sets the cwd identically for both paths; promptPath is
|
|
104
|
+
// absolute (/tmp/…), so the `< prompt` redirect survives the cd.
|
|
105
|
+
const cd = cwd ? `cd ${shellQuote(cwd)} && ` : "";
|
|
100
106
|
// Fresh turn: `codex exec <flags> - < prompt`. Continue a thread:
|
|
101
107
|
// `codex exec resume <id> <flags> - < prompt`. (`-` = read prompt from stdin.)
|
|
102
108
|
const exec = sessionId
|
|
103
109
|
? `codex exec resume ${shellQuote(sessionId)} ${flags}`
|
|
104
110
|
: `codex exec ${flags}`;
|
|
105
|
-
return `${exec} - < ${shellQuote(promptPath)}`;
|
|
111
|
+
return `${cd}${exec} - < ${shellQuote(promptPath)}`;
|
|
106
112
|
},
|
|
107
113
|
extractSessionId: (p) =>
|
|
108
114
|
p.type === "thread.started" && typeof p.thread_id === "string" ? p.thread_id : undefined,
|
|
@@ -237,11 +237,15 @@ function makeE2bSandboxProvider(sb: Sandbox): SandboxProvider {
|
|
|
237
237
|
return { resumeHandle: sb.sandboxId };
|
|
238
238
|
},
|
|
239
239
|
// The provider kill-clock seam: e2b's `setTimeout` REPLACES the deadline
|
|
240
|
-
// (extend or reduce) relative to now. The server's
|
|
241
|
-
//
|
|
242
|
-
//
|
|
240
|
+
// (extend or reduce) relative to now, in MILLISECONDS. The server's
|
|
241
|
+
// session lifecycle uses it two ways: a FAR-HORIZON push while the
|
|
242
|
+
// session is active (the kill-clock is an orphan backstop, never
|
|
243
|
+
// load-bearing during a live turn) and a short re-arm when the deferred
|
|
244
|
+
// pause is about to park the VM. Clamped to the plan cap exactly like
|
|
245
|
+
// the create timeout — an over-cap push would 400 and leave the OLD
|
|
246
|
+
// (possibly short) deadline standing.
|
|
243
247
|
async extendLifetime(ms) {
|
|
244
|
-
await sb.setTimeout(ms);
|
|
248
|
+
await sb.setTimeout(Math.min(ms, e2bMaxSandboxMs()));
|
|
245
249
|
},
|
|
246
250
|
// Push a freshly-resolved egress policy onto the live sandbox via E2B's
|
|
247
251
|
// native `updateNetwork` — the E2B analogue of Vercel's `update({
|