@commonlyai/cli 0.1.197 → 0.1.199
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/node_modules/@commonlyai/seats/README.md +1 -1
- package/package.json +1 -1
- package/src/commands/agent.js +12 -14
- package/src/lib/adapters/claude.js +80 -14
- package/src/lib/adapters/codex.js +2 -2
- package/src/lib/adapters/dsh.js +2 -2
- package/src/lib/adapters/pi.js +6 -7
- package/src/lib/daemon-supervisor.js +1 -1
- package/src/lib/enforcement.js +3 -3
- package/src/lib/environment.js +6 -6
- package/src/lib/first-output-watchdog.js +141 -0
- package/src/lib/memory-bridge.js +1 -1
- package/src/lib/public-tool-error.js +1 -1
- package/src/lib/sandbox/mode.js +3 -4
- package/src/lib/sandbox/seatbelt.js +1 -1
- package/src/lib/spawn-retry.js +21 -1
- package/src/lib/webhook-server.js +2 -2
|
@@ -20,6 +20,6 @@ It is private and is not published on its own. `@commonlyai/cli` depends on it a
|
|
|
20
20
|
|
|
21
21
|
**Rule:** nothing here imports Commonly code. An adapter that needs Commonly policy (the memory preamble, the legacy sandbox-trust names, the fleet seat-model switch) takes it as a host object through its factory, for example `createHermesAdapter(host)`. The CLI binds that host in `cli/src/lib/adapters/<name>.js`. `cli/__tests__/seats-package.test.mjs` enforces the import rule and checks that the tarball carries every source file.
|
|
22
22
|
|
|
23
|
-
The extraction plan is
|
|
23
|
+
The extraction plan is extract-in-place.
|
|
24
24
|
|
|
25
25
|
**Run record.** The trace store writes each run as a versioned, hash-chained JSONL record. It covers who granted which agent what access for which task, who approved each call, what was attempted, and the result. A tool outside this package can read, verify and export it to OTLP. The format is specified in [`docs/record-v1.md`](docs/record-v1.md), and a generated sample is in [`docs/examples/`](docs/examples/).
|
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -178,8 +178,7 @@ export const NO_AGENT_CLI_CODE = 'no_agent_cli';
|
|
|
178
178
|
* workspace, and an environment that is absent or enforces nothing (a record
|
|
179
179
|
* from before bootstrap applied the default, or one attached without an
|
|
180
180
|
* environment file). Such a record is told how to pick the default up; it is
|
|
181
|
-
* not rewritten here
|
|
182
|
-
* tests).
|
|
181
|
+
* not rewritten here.
|
|
183
182
|
*/
|
|
184
183
|
export const savedSeatLacksDefaultSandbox = (record) => Boolean(record)
|
|
185
184
|
&& ADAPTERS_WITH_DEFAULT_SANDBOX.has(record.adapter)
|
|
@@ -717,7 +716,7 @@ export const assertSandboxDeclaredForPublicPod = async ({
|
|
|
717
716
|
);
|
|
718
717
|
}
|
|
719
718
|
|
|
720
|
-
// Normalized FIRST
|
|
719
|
+
// Normalized FIRST: the predicate below reads a
|
|
721
720
|
// legacy `internal` record as the `public` it means, the same mapping the
|
|
722
721
|
// attach gate and both adapters use. Reading it raw refused a record that
|
|
723
722
|
// means public one call before the gate #1840 fixes — TASK-113 claims
|
|
@@ -904,7 +903,7 @@ export const updateAgentConfiguration = async ({
|
|
|
904
903
|
* The sandbox an attach will actually run under — derived, then gated.
|
|
905
904
|
*
|
|
906
905
|
* The trust is normalized ONCE, before it is used for anything. A stored
|
|
907
|
-
* `internal` means `public` (environment.js
|
|
906
|
+
* `internal` means `public` (environment.js), and resolving the mode
|
|
908
907
|
* off the raw value made this gate disagree with the adapters in BOTH
|
|
909
908
|
* directions: `{ trust: 'internal' }` with no declared mode left the attach-time
|
|
910
909
|
* mode at `'none'` and refused nothing here, and `internal` beside
|
|
@@ -917,8 +916,7 @@ export const updateAgentConfiguration = async ({
|
|
|
917
916
|
* either way; what was wrong is that this gate's verdict contradicted theirs, and
|
|
918
917
|
* its refusal for the `mode: 'none'` shape landed later, at adapter spawn, after
|
|
919
918
|
* attach had already published and minted. Normalizing here makes the two agree
|
|
920
|
-
* and puts the refusal where the user can see it (
|
|
921
|
-
* source after Vera 71664).
|
|
919
|
+
* and puts the refusal where the user can see it (TASK-113).
|
|
922
920
|
*
|
|
923
921
|
* Whether the resolved mode is AVAILABLE here — bwrap installed, macOS for
|
|
924
922
|
* Seatbelt, the codex permission-profile version — is deliberately not this
|
|
@@ -932,7 +930,7 @@ export const updateAgentConfiguration = async ({
|
|
|
932
930
|
* codex.js:546) and passed the normalized object on, which is why their suites
|
|
933
931
|
* confined a legacy record all along; the grant-broker guard normalized for its
|
|
934
932
|
* own trust check and then handed the resolver the raw object, so its symptom was
|
|
935
|
-
* a false `sandbox_mode_unenforceable` refusal
|
|
933
|
+
* a false `sandbox_mode_unenforceable` refusal. Both are settled on
|
|
936
934
|
* main now — the resolver reads the effective trust (mode.js:38) and the guard
|
|
937
935
|
* imports the same reader from environment.js (grant-broker-guard.js:186) — so
|
|
938
936
|
* this helper needs no coupling to them: it normalizes once, here, before its own
|
|
@@ -944,7 +942,7 @@ export const resolveAttachSandbox = ({
|
|
|
944
942
|
// An adapter that enforces one confined shape of its own (dsh: Seatbelt with a
|
|
945
943
|
// restricted network and NO trust) says so here, and its check is the one that
|
|
946
944
|
// decides: the trust-keyed gates below cannot express that shape, so before
|
|
947
|
-
// this they refused the only environment dsh accepts
|
|
945
|
+
// this they refused the only environment dsh accepts. The adapter's
|
|
948
946
|
// check throws on every other shape, so this admits nothing it would refuse.
|
|
949
947
|
if (typeof adapter?.confinesOwnSandbox === 'function' && adapter.confinesOwnSandbox(environment)) {
|
|
950
948
|
return { mode: environment.sandbox.mode, trust: environment.sandbox.trust };
|
|
@@ -967,7 +965,7 @@ export const resolveAttachSandbox = ({
|
|
|
967
965
|
`sandbox.trust=${trust} requires an enforced sandbox mode; refusing to attach unsandboxed`,
|
|
968
966
|
);
|
|
969
967
|
}
|
|
970
|
-
// Moved in from the call site
|
|
968
|
+
// Moved in from the call site: this is the check the raw compare
|
|
971
969
|
// used to fail closed on, refusing `internal` beside `mode: 'workspace'`. It is
|
|
972
970
|
// pure — a mode and an adapter name — so it belongs with the derivation, and
|
|
973
971
|
// its witnesses live beside it.
|
|
@@ -1490,7 +1488,7 @@ export const performRun = ({
|
|
|
1490
1488
|
// (`daemon-supervisor.js`); this is the same predicate at the only other site
|
|
1491
1489
|
// that can see the record — same code, so one refusal with two emitters.
|
|
1492
1490
|
//
|
|
1493
|
-
// IN MEMORY, RECORD UNTOUCHED
|
|
1491
|
+
// IN MEMORY, RECORD UNTOUCHED: the withheld environment is what
|
|
1494
1492
|
// the adapter spawns with, and the stored record keeps its broker, so the
|
|
1495
1493
|
// daemon stays the layer that judges a host it knows. Applied once for the
|
|
1496
1494
|
// run rather than per turn: `agent run` reads its record once at boot.
|
|
@@ -1903,7 +1901,7 @@ export const performRun = ({
|
|
|
1903
1901
|
const memoryWithheld = Boolean(placement.reason);
|
|
1904
1902
|
const memoryLongTerm = memoryWithheld ? undefined : await readLongTerm(client, { onError });
|
|
1905
1903
|
|
|
1906
|
-
//
|
|
1904
|
+
// TASK-129: focus is a turn-start read, not an enqueue-time
|
|
1907
1905
|
// snapshot. Read through the authorized runtime context route immediately
|
|
1908
1906
|
// before spawn so queued events observe the current revision. The helper
|
|
1909
1907
|
// disables pod-skill synthesis and throws on failure; the surrounding
|
|
@@ -1997,8 +1995,8 @@ export const performRun = ({
|
|
|
1997
1995
|
try {
|
|
1998
1996
|
opened = await lease.open();
|
|
1999
1997
|
} catch (err) {
|
|
2000
|
-
// A VERDICT IS NOT A CRASH, AND IT IS NOT A NO-OP
|
|
2001
|
-
//
|
|
1998
|
+
// A VERDICT IS NOT A CRASH, AND IT IS NOT A NO-OP.
|
|
1999
|
+
// Letting it escape as a bare throw left `turnResult` undefined,
|
|
2002
2000
|
// so `claimReleaseFor` returned the legacy holder-only DELETE — right for
|
|
2003
2001
|
// a transient failure, wrong for a refusal that recurs on every
|
|
2004
2002
|
// redelivery: the wake loops forever with one local log line as its only
|
|
@@ -2409,7 +2407,7 @@ export const performRun = ({
|
|
|
2409
2407
|
// its batch and chose silence; one that printed nothing may have crashed,
|
|
2410
2408
|
// timed out or lost its route without saying so. The bare `no_action`
|
|
2411
2409
|
// stays the sentinel's alone, because a digest counts as read only on
|
|
2412
|
-
// that (
|
|
2410
|
+
// that (#2753). Release, hand-off and spawn counting treat
|
|
2413
2411
|
// `empty-output` exactly as they treat a bare decline.
|
|
2414
2412
|
if (!delivered && !replyText) return { outcome: 'no_action', reason: EMPTY_OUTPUT };
|
|
2415
2413
|
return { outcome: delivered ? 'posted' : 'no_action' };
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { claudeRuntimeInventory } from '../native-runtime-inventory.js';
|
|
2
2
|
import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
firstOutputWindowMs, killStallSurvivors, physicalPath, spawnStalledError, STALL_KILL_GRACE_MS, watchFirstOutput,
|
|
5
|
+
} from '../first-output-watchdog.js';
|
|
6
|
+
import {
|
|
7
|
+
descendantsOf, killProcessTree, readProcessTable, spawnInOwnGroup, wantsOwnProcessGroup,
|
|
8
|
+
} from '@commonlyai/seats/process-group';
|
|
4
9
|
/**
|
|
5
10
|
* claude adapter — wraps the local `claude` CLI as a Commonly agent.
|
|
6
11
|
*
|
|
@@ -88,7 +93,7 @@ import {
|
|
|
88
93
|
prepareMcpSpawn, PUBLIC_MCP_UNAVAILABLE_CODE, publicMcpUnavailableError, selectedMcpVersionDir,
|
|
89
94
|
} from '../mcp-home.js';
|
|
90
95
|
import { buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
|
|
91
|
-
import { claudeConfigDirFor, claudeContextTokens } from '../session-recycle.js';
|
|
96
|
+
import { claudeConfigDirFor, claudeContextTokens, claudeTranscriptPath } from '../session-recycle.js';
|
|
92
97
|
import { skillsAsText } from '../skill-prompt.js';
|
|
93
98
|
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
94
99
|
|
|
@@ -105,8 +110,8 @@ const DEFAULT_TIMEOUT_MS = (() => {
|
|
|
105
110
|
|
|
106
111
|
const buildPrompt = buildMemoryPreamble;
|
|
107
112
|
|
|
108
|
-
// The working habits a private claude seat is taught on a fresh session
|
|
109
|
-
// TASK-545). Parallel reads live here because only this CLI has a subagent
|
|
113
|
+
// The working habits a private claude seat is taught on a fresh session
|
|
114
|
+
// (TASK-545). Parallel reads live here because only this CLI has a subagent
|
|
110
115
|
// tool; codex is passed row slicing alone. A public seat is passed neither: its
|
|
111
116
|
// argv allows Read and the MCP under dontAsk, so it has no subagent to spawn,
|
|
112
117
|
// and it answers strangers rather than working board rows.
|
|
@@ -484,7 +489,11 @@ const publicSkillPromptArgs = async (skillSources) => {
|
|
|
484
489
|
|
|
485
490
|
// `ownGroup` is set only under the run supervisor, which kills the child's
|
|
486
491
|
// whole group; unsupervised, the spawn options are exactly what they were.
|
|
487
|
-
const runClaude = ({
|
|
492
|
+
const runClaude = ({
|
|
493
|
+
cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
|
|
494
|
+
transcriptPath = null, firstOutputMs = firstOutputWindowMs(), lockProbe, stallKillGraceMs = STALL_KILL_GRACE_MS,
|
|
495
|
+
processTable = readProcessTable, survivorIo,
|
|
496
|
+
}) => new Promise((resolve, reject) => {
|
|
488
497
|
const proc = spawnInOwnGroup(spawnImpl, cmd, args, {
|
|
489
498
|
cwd,
|
|
490
499
|
env,
|
|
@@ -499,15 +508,57 @@ const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl
|
|
|
499
508
|
timedOut = true;
|
|
500
509
|
proc.kill('SIGTERM');
|
|
501
510
|
}, timeoutMs);
|
|
511
|
+
// A turn that never starts is killed long before the run timeout.
|
|
512
|
+
let stalled = null;
|
|
513
|
+
let stallKill = null;
|
|
514
|
+
let stallSnapshot = [];
|
|
515
|
+
// SIGKILL snapshotted descendants that are still alive. Safe to repeat. It
|
|
516
|
+
// runs when the spawn exits, at the grace deadline and on close: a survivor
|
|
517
|
+
// still holding the spawn's stdout or stderr keeps close from ever firing,
|
|
518
|
+
// and once the spawn is reaped killProcessTree can no longer reach it.
|
|
519
|
+
const sweepStallSurvivors = () => {
|
|
520
|
+
if (stalled) killStallSurvivors(stallSnapshot, survivorIo);
|
|
521
|
+
};
|
|
522
|
+
const treeIo = (table) => ({ table, ...(survivorIo?.read ? { read: survivorIo.read } : {}) });
|
|
523
|
+
const watchdog = watchFirstOutput({
|
|
524
|
+
proc,
|
|
525
|
+
windowMs: firstOutputMs,
|
|
526
|
+
transcriptPath,
|
|
527
|
+
lockProbe,
|
|
528
|
+
onStall: (reason) => {
|
|
529
|
+
stalled = spawnStalledError({ label: 'claude', windowMs: firstOutputMs, reason });
|
|
530
|
+
// Snapshot the descendants first: once the spawn exits they are
|
|
531
|
+
// reparented and can no longer be found from it.
|
|
532
|
+
const table = Number.isInteger(proc.pid) ? processTable() : null;
|
|
533
|
+
if (table) stallSnapshot = descendantsOf(table, proc.pid);
|
|
534
|
+
killProcessTree(proc, 'SIGTERM', treeIo(() => table));
|
|
535
|
+
// A process parked in a system call may not act on SIGTERM. SIGKILL the
|
|
536
|
+
// child, its group when it leads one, and its descendants.
|
|
537
|
+
stallKill = setTimeout(() => {
|
|
538
|
+
killProcessTree(proc, 'SIGKILL', treeIo(processTable));
|
|
539
|
+
sweepStallSurvivors();
|
|
540
|
+
}, stallKillGraceMs);
|
|
541
|
+
stallKill.unref?.();
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
proc.on('exit', sweepStallSurvivors);
|
|
502
545
|
|
|
503
546
|
proc.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
504
547
|
proc.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
505
548
|
proc.on('error', (err) => {
|
|
506
549
|
clearTimeout(timer);
|
|
550
|
+
clearTimeout(stallKill);
|
|
551
|
+
watchdog.stop();
|
|
507
552
|
reject(err);
|
|
508
553
|
});
|
|
509
554
|
proc.on('close', (code) => {
|
|
510
555
|
clearTimeout(timer);
|
|
556
|
+
clearTimeout(stallKill);
|
|
557
|
+
watchdog.stop();
|
|
558
|
+
if (stalled) {
|
|
559
|
+
sweepStallSurvivors();
|
|
560
|
+
return reject(stalled);
|
|
561
|
+
}
|
|
511
562
|
if (timedOut) return reject(new Error(`claude timed out after ${timeoutMs}ms`));
|
|
512
563
|
if (code !== 0) {
|
|
513
564
|
// Report stdout too, not just stderr. In `-p` mode claude writes terminal
|
|
@@ -841,7 +892,7 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
841
892
|
// A public trust is ONE contract on both platforms: the jail bounds the
|
|
842
893
|
// filesystem and the policy bounds the tools. The bwrap branch below used to
|
|
843
894
|
// get the jail only — a derived Linux seat kept Bash/Write/WebFetch and ran on
|
|
844
|
-
// the operator's own Claude settings inside the namespace
|
|
895
|
+
// the operator's own Claude settings inside the namespace.
|
|
845
896
|
const publicBwrapSandbox = sandboxTrust === 'public' && sandboxMode === 'bwrap';
|
|
846
897
|
// A workspace seat on Linux: the same jail, with the workspace tier's policy
|
|
847
898
|
// and a HOME of its own outside the project.
|
|
@@ -867,7 +918,7 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
867
918
|
// above; anything else that lands here (the literal 'none', a typo, a
|
|
868
919
|
// non-string) used to fall through to the bare `claude` spawn below — an
|
|
869
920
|
// unconfined seat with a public record, which is how a record can claim
|
|
870
|
-
// confinement it never applies
|
|
921
|
+
// confinement it never applies.
|
|
871
922
|
if (isConfinedTrust(sandboxTrust) && !publicNativeSandbox && sandboxMode !== 'bwrap') {
|
|
872
923
|
throw new Error(
|
|
873
924
|
`${sandboxTrust} Claude agents require an enforced sandbox mode `
|
|
@@ -929,7 +980,7 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
929
980
|
if (publicBwrapSandbox || workspaceBwrapSandbox) {
|
|
930
981
|
// The mode is chosen here, so the mechanism is verified here: a missing
|
|
931
982
|
// bwrap must fail the seat's derivation with an actionable message, not
|
|
932
|
-
// every one of its spawns with a bare ENOENT
|
|
983
|
+
// every one of its spawns with a bare ENOENT. On a non-Linux
|
|
933
984
|
// host this reports the macOS-only message, same as the wrapper would.
|
|
934
985
|
const bwrap = (ctx._detectBwrap || detectBwrap)();
|
|
935
986
|
if (!bwrap.available) {
|
|
@@ -1043,7 +1094,7 @@ export default {
|
|
|
1043
1094
|
const seatModel = requireSeatModel(rawCtx.environment, 'claude');
|
|
1044
1095
|
// `sandbox.trust: 'internal'` is refused for new declarations and read as
|
|
1045
1096
|
// `public` for a record that already carries it; such a seat is confined
|
|
1046
|
-
// where it can be and refuses to derive where it cannot
|
|
1097
|
+
// where it can be and refuses to derive where it cannot.
|
|
1047
1098
|
// Resolved ONCE here and threaded to BOTH consumers — the public-state
|
|
1048
1099
|
// preparation below and the argv builder — because two independent reads of
|
|
1049
1100
|
// one declaration is exactly how this seat crashed with "public Claude
|
|
@@ -1091,9 +1142,9 @@ export default {
|
|
|
1091
1142
|
// present in one but not the other means a retry silently runs a different
|
|
1092
1143
|
// model than the turn it is replacing — the same drifting-copy shape that
|
|
1093
1144
|
// has bitten this codebase repeatedly.
|
|
1094
|
-
// `effort` rides in the same array for the same reason:
|
|
1095
|
-
//
|
|
1096
|
-
// effort"
|
|
1145
|
+
// `effort` rides in the same array for the same reason: an order
|
|
1146
|
+
// like "flip all fable agents into fable 5.1 with high or above
|
|
1147
|
+
// effort" needs both facts to reach every spawn, including the
|
|
1097
1148
|
// session-recovery retry, or a retry runs at a different effort than the
|
|
1098
1149
|
// turn it replaces.
|
|
1099
1150
|
const modelArgs = [
|
|
@@ -1133,7 +1184,7 @@ export default {
|
|
|
1133
1184
|
// Trust is normalized at entry (legacy `internal` reads as public). The
|
|
1134
1185
|
// MCP policy follows trust alone, whatever confines the seat: a public
|
|
1135
1186
|
// bwrap seat needs a host-prepared build exactly as a Seatbelt one does
|
|
1136
|
-
// (
|
|
1187
|
+
// (#2247).
|
|
1137
1188
|
if (publicNativeSandbox) {
|
|
1138
1189
|
publicClaudeState = await preparePublicClaudeState(ctx);
|
|
1139
1190
|
} else if (workspaceBwrapSandbox) {
|
|
@@ -1200,6 +1251,13 @@ export default {
|
|
|
1200
1251
|
credentials: spawnCredentials(spawnCtx),
|
|
1201
1252
|
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }), // test seam only — do not use in production
|
|
1202
1253
|
ownGroup: wantsOwnProcessGroup(ctx),
|
|
1254
|
+
transcriptPath: claudeTranscriptPath({
|
|
1255
|
+
cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
|
|
1256
|
+
}),
|
|
1257
|
+
lockProbe: ctx._lockProbe, // test seams only
|
|
1258
|
+
stallKillGraceMs: ctx._stallKillGraceMs,
|
|
1259
|
+
processTable: ctx._processTable,
|
|
1260
|
+
survivorIo: ctx._survivorIo,
|
|
1203
1261
|
});
|
|
1204
1262
|
// TASK-440: the context this turn left, read from the transcript the
|
|
1205
1263
|
// child wrote under its own env (an isolated HOME for public seats).
|
|
@@ -1210,7 +1268,7 @@ export default {
|
|
|
1210
1268
|
} catch (err) {
|
|
1211
1269
|
// A public seat's refusal is not a session-store failure, whatever its
|
|
1212
1270
|
// message happens to contain: it must reach the run loop, never the
|
|
1213
|
-
// fresh-session retry below (
|
|
1271
|
+
// fresh-session retry below (#2247).
|
|
1214
1272
|
if (err?.code === PUBLIC_MCP_UNAVAILABLE_CODE) throw err;
|
|
1215
1273
|
// Self-heal against a corrupted session store. If the persisted id is
|
|
1216
1274
|
// already in use (or claude has forgotten about it), discard it and
|
|
@@ -1256,6 +1314,14 @@ export default {
|
|
|
1256
1314
|
credentials: spawnCredentials(retryCtx),
|
|
1257
1315
|
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }),
|
|
1258
1316
|
ownGroup: wantsOwnProcessGroup(ctx),
|
|
1317
|
+
transcriptPath: claudeTranscriptPath({
|
|
1318
|
+
cwd: physicalPath(ctx.cwd), sessionId: freshId,
|
|
1319
|
+
configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
|
|
1320
|
+
}),
|
|
1321
|
+
lockProbe: ctx._lockProbe,
|
|
1322
|
+
stallKillGraceMs: ctx._stallKillGraceMs,
|
|
1323
|
+
processTable: ctx._processTable,
|
|
1324
|
+
survivorIo: ctx._survivorIo,
|
|
1259
1325
|
});
|
|
1260
1326
|
const contextTokens = claudeContextTokens({
|
|
1261
1327
|
cwd: ctx.cwd, sessionId: freshId,
|
|
@@ -115,7 +115,7 @@ const buildPrompt = buildMemoryPreamble;
|
|
|
115
115
|
// when they also carry a `command`. `auditDeclaredMcp` classifies by
|
|
116
116
|
// `transport` and never judges the command of an entry that declared an
|
|
117
117
|
// http one, so emitting it here executes a command the guard did not
|
|
118
|
-
// approve —
|
|
118
|
+
// approve — a case measured on 2026-09-18.
|
|
119
119
|
// - Token-bearing values ride through `env_vars`, never a `-c ...env=...`
|
|
120
120
|
// argv override. Command lines are visible to other same-user processes
|
|
121
121
|
// unless the OS sandbox blocks process inspection; keeping bearer tokens
|
|
@@ -661,7 +661,7 @@ export default {
|
|
|
661
661
|
// undefined and '' as absence itself; it does not need a guard, it needs
|
|
662
662
|
// the value.
|
|
663
663
|
//
|
|
664
|
-
// Codex has no subagent tool, so of
|
|
664
|
+
// Codex has no subagent tool, so of the two habits (TASK-545) it is
|
|
665
665
|
// taught row slicing alone; claude.js adds parallel reads. A public seat
|
|
666
666
|
// answers strangers rather than working board rows, so it is taught neither.
|
|
667
667
|
const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, {
|
package/src/lib/adapters/dsh.js
CHANGED
|
@@ -59,7 +59,7 @@ export const assertPrivateDsh = (environment = {}) => { isConfinedDsh(environmen
|
|
|
59
59
|
* The macOS developer tools a confined writer needs for git, granted read-only.
|
|
60
60
|
* /usr/bin/git is an xcrun shim: inside the sandbox it cannot read the tools it
|
|
61
61
|
* forwards to, so `git` fails with "No developer tools were found" and a writer
|
|
62
|
-
* cannot commit
|
|
62
|
+
* cannot commit. Returns null when no tools are installed; the seat then
|
|
63
63
|
* runs without git rather than failing.
|
|
64
64
|
*
|
|
65
65
|
* DEVELOPER_DIR and xcode-select are both settable by whoever starts the seat,
|
|
@@ -333,7 +333,7 @@ export default {
|
|
|
333
333
|
requiresModel: true,
|
|
334
334
|
name: 'dsh', runtimeType: 'dsh', supportsProvider: true,
|
|
335
335
|
|
|
336
|
-
// The attach gate defers to this for dsh's one confined shape
|
|
336
|
+
// The attach gate defers to this for dsh's one confined shape.
|
|
337
337
|
confinesOwnSandbox: (environment) => isConfinedDsh(environment),
|
|
338
338
|
|
|
339
339
|
async validateEnvironment(environment, workspacePath) {
|
package/src/lib/adapters/pi.js
CHANGED
|
@@ -100,7 +100,7 @@ const substitutePlaceholders = (value, ctx) => {
|
|
|
100
100
|
* deliberately not friendlier than the guard's — a normalized `'HTTP'` or a
|
|
101
101
|
* padded `' http '` is a shape the guard refuses as an unknown transport, and an
|
|
102
102
|
* adapter that accepted one would be running something the guard never judged
|
|
103
|
-
*
|
|
103
|
+
* The schema admits `http`/`stdio`/`sse`
|
|
104
104
|
* (environment.js:236) and admits an ABSENT transport, which the guard reads as
|
|
105
105
|
* stdio; that is what this reads too.
|
|
106
106
|
*/
|
|
@@ -129,8 +129,7 @@ const originOf = (value) => {
|
|
|
129
129
|
* environment is not usable for this: the kernel keeps the copy the process was
|
|
130
130
|
* started with, so a same-user child could read the token back with
|
|
131
131
|
* `ps eww $PPID` / `/proc/$PPID/environ` even after the bridge deleted its own
|
|
132
|
-
* copy — which is what the previous channel did, and the row this closes
|
|
133
|
-
* (Vera, Connectors).
|
|
132
|
+
* copy — which is what the previous channel did, and the row this closes.
|
|
134
133
|
*
|
|
135
134
|
* WHICH shape an entry becomes is decided by `transport` — the same field, read
|
|
136
135
|
* with the same default and the same exact comparison the daemon's
|
|
@@ -141,7 +140,7 @@ const originOf = (value) => {
|
|
|
141
140
|
* `${COMMONLY_AGENT_TOKEN}` in the command is one of the known placeholders), and
|
|
142
141
|
* a presence-classifier then ran that command as stdio with the real token
|
|
143
142
|
* substituted. The guard's judgement and the adapter's disagreed, and the adapter
|
|
144
|
-
* is what executes
|
|
143
|
+
* is what executes.
|
|
145
144
|
*
|
|
146
145
|
* The http half also enforces the guard's own origin rule, so the HTTP half does
|
|
147
146
|
* not depend on a guard that may not be on the machine: `auditDeclaredMcp`
|
|
@@ -153,7 +152,7 @@ const originOf = (value) => {
|
|
|
153
152
|
*
|
|
154
153
|
* The grant broker is refused outright, by PATH rather than by entry name: it is
|
|
155
154
|
* the one http entry that carries authority rather than data, and pi confines on
|
|
156
|
-
* no host.
|
|
155
|
+
* no host. The ruling for TASK-063 (`isGrantBrokerUrl` in pi-mcp-client.mjs),
|
|
157
156
|
* the daemon-side half of the same refusal the server makes at the projection —
|
|
158
157
|
* this is the half that holds on deploy skew, when the row names no adapter for
|
|
159
158
|
* the server to key on, and in any backend older than the refusal.
|
|
@@ -162,7 +161,7 @@ const originOf = (value) => {
|
|
|
162
161
|
* a declared stdio command is executed with no allowlist check, here and in both
|
|
163
162
|
* sibling adapters, so it depends entirely on the guard — `auditDeclaredMcp`
|
|
164
163
|
* admits only the shipped commonly MCP server or a command already present in the
|
|
165
|
-
* local record, and that rule exists nowhere else (
|
|
164
|
+
* local record, and that rule exists nowhere else (TASK-069).
|
|
166
165
|
*/
|
|
167
166
|
export const resolveMcpServers = (mcpServers, ctx = {}) => {
|
|
168
167
|
const carried = [];
|
|
@@ -295,7 +294,7 @@ export const sessionExists = async (sessionDir, sessionId) => {
|
|
|
295
294
|
* one does not start.
|
|
296
295
|
*
|
|
297
296
|
* The trust is read THROUGH the legacy table (`effectiveSandboxTrust`), not
|
|
298
|
-
* raw. A stored `internal` means `public`
|
|
297
|
+
* raw. A stored `internal` means `public` and every
|
|
299
298
|
* other gate in this package resolves it that way: the attach path
|
|
300
299
|
* (`agent.js`), the mode resolver (`sandbox/mode.js`) and the grant-broker
|
|
301
300
|
* guard. Reading it raw here made this gate the one site that failed OPEN — a
|
|
@@ -238,7 +238,7 @@ export const createDaemonSupervisor = ({
|
|
|
238
238
|
// seat RUNS under, and `withholdGrantBroker` asks the same question the
|
|
239
239
|
// server asks (`can this seat confine a granter's authority?`) for the cases
|
|
240
240
|
// the server cannot see — a row naming no adapter, a backend older than the
|
|
241
|
-
// refusal, a record written by hand. Entry-level
|
|
241
|
+
// refusal, a record written by hand. Entry-level: the broker
|
|
242
242
|
// entry is withheld and the seat still starts, because it was never promised
|
|
243
243
|
// confinement. `record.instanceUrl` is what makes the injected url
|
|
244
244
|
// identifiable at all — the record holds the UNRESOLVED
|
package/src/lib/enforcement.js
CHANGED
|
@@ -635,7 +635,7 @@ export const splitForChat = (text, { limit = 400 } = {}) => {
|
|
|
635
635
|
* text and post one message — the reply's
|
|
636
636
|
* own opening plus the file card.
|
|
637
637
|
*
|
|
638
|
-
* The thread rung is new
|
|
638
|
+
* The thread rung is new and it replaces attachment as the answer
|
|
639
639
|
* for PROSE overflow. Before threads existed, a long analysis had nowhere to
|
|
640
640
|
* go but a file, and that was the right workaround. It is now the wrong one:
|
|
641
641
|
* an attachment is un-quotable, un-followable, and an all-or-nothing read,
|
|
@@ -678,7 +678,7 @@ export const deliverChatReply = async ({
|
|
|
678
678
|
//
|
|
679
679
|
// TASK-535 — the send-time freshness hold. The backend answers 409
|
|
680
680
|
// `unseen_messages` when other members posted after this seat's last
|
|
681
|
-
// recorded tail read. A turn that ran ~43 minutes
|
|
681
|
+
// recorded tail read. A turn that ran ~43 minutes
|
|
682
682
|
// hit it on the thread headline, the attach card and the split fallback in
|
|
683
683
|
// turn, so the reply was lost and its 10 events re-ran. A retry without an
|
|
684
684
|
// override cannot succeed — every rung posts to the same guarded route.
|
|
@@ -771,7 +771,7 @@ export const deliverChatReply = async ({
|
|
|
771
771
|
// its rendering. The tone contract's own rule covers it: over ~800 chars of
|
|
772
772
|
// ONE indivisible thing is a document, not a message — attach it. Without
|
|
773
773
|
// this check a 659-char fence rode the single/split branches straight past
|
|
774
|
-
// the gate (found by the fleet's implementation audit
|
|
774
|
+
// the gate (found by the fleet's implementation audit).
|
|
775
775
|
const hasIndivisibleOversize = chunks.some((c) => c.length > attachThreshold);
|
|
776
776
|
if (chunks.length <= 1 && !hasIndivisibleOversize) {
|
|
777
777
|
const response = await postMessage({ content: chunks[0] ?? text });
|
package/src/lib/environment.js
CHANGED
|
@@ -75,9 +75,9 @@ const findInlineProviderCredentialField = (value, path) => {
|
|
|
75
75
|
// daemon path it was silently inert — claude fell through to a bare unconfined
|
|
76
76
|
// spawn and codex took `--dangerously-bypass-approvals-and-sandbox` — so a
|
|
77
77
|
// declaration that reads as "confine me, not as a public agent" meant the
|
|
78
|
-
// opposite of what it said
|
|
78
|
+
// opposite of what it said. It is refused for new declarations
|
|
79
79
|
// above, and a record that already carries it is resolved TOWARD confinement,
|
|
80
|
-
// never toward the bare spawn
|
|
80
|
+
// never toward the bare spawn: `internal` is read as `public`, so
|
|
81
81
|
// such a seat is confined where it can be and refuses to derive where it
|
|
82
82
|
// cannot. A real middle trust arrives as its own adapter that reads it — and
|
|
83
83
|
// `workspace` (above) is that trust, which is why it is NOT a legacy alias.
|
|
@@ -100,7 +100,7 @@ export const normalizeSandboxTrust = (sandbox) => (
|
|
|
100
100
|
* compares the trust and must not write anything back. The server spells the
|
|
101
101
|
* same predicate `effectiveSandboxTrust` (`grantBrokerConfinement.ts`) — the two
|
|
102
102
|
* sides have to read `internal` the same way or one of them confines a seat the
|
|
103
|
-
* other withholds from
|
|
103
|
+
* other withholds from.
|
|
104
104
|
*/
|
|
105
105
|
export const effectiveSandboxTrust = (trust) => (
|
|
106
106
|
typeof trust === 'string' && Object.prototype.hasOwnProperty.call(LEGACY_SANDBOX_TRUST, trust)
|
|
@@ -112,7 +112,7 @@ export const effectiveSandboxTrust = (trust) => (
|
|
|
112
112
|
* Throw when an adapter that does not implement the `workspace` tier is handed
|
|
113
113
|
* one (TASK-294). Only claude reads it. Every other adapter branches on
|
|
114
114
|
* `trust === 'public'`, so an unread `workspace` would fall into its
|
|
115
|
-
* non-public branch — the same silent widening `internal` was
|
|
115
|
+
* non-public branch — the same silent widening `internal` was.
|
|
116
116
|
* Called at each adapter's spawn entry, before any other sandbox read.
|
|
117
117
|
*/
|
|
118
118
|
export const refuseWorkspaceTrust = (sandbox, adapterLabel) => {
|
|
@@ -391,8 +391,8 @@ export const validateEnvironmentSpec = (spec) => {
|
|
|
391
391
|
}
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
-
// A TRANSPORT DECIDES THE ENTRY, so the fields must agree with it (TASK-071
|
|
395
|
-
//
|
|
394
|
+
// A TRANSPORT DECIDES THE ENTRY, so the fields must agree with it (TASK-071).
|
|
395
|
+
// This block is MIRRORED, not shared: the other
|
|
396
396
|
// writer-side check is the backend's, in
|
|
397
397
|
// backend/utils/environmentSpecValidation.ts, called from the
|
|
398
398
|
// `PATCH /api/registry/pods/:podId/agents/:name` handler that stores
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-output watchdog for a seat's model CLI spawn.
|
|
3
|
+
*
|
|
4
|
+
* On 2026-10-30 every launchd seat's `claude -p` turn parked while the Mac's
|
|
5
|
+
* console was locked: no transcript row, no stdout, no stderr, for the full
|
|
6
|
+
* 45-minute run timeout. Each one held its delivery the whole time, the retry
|
|
7
|
+
* respawned it into the same hang, and the log said only "claude timed out
|
|
8
|
+
* after 2700000ms", which names nothing.
|
|
9
|
+
*
|
|
10
|
+
* The signal is not stdout. A seat runs `claude -p --output-format text`,
|
|
11
|
+
* which writes stdout once, at the end, so a healthy 20-minute turn has zero
|
|
12
|
+
* stdout bytes for 20 minutes. What a healthy turn does write is its session
|
|
13
|
+
* transcript: measured on the dart seat, the first row landed 0.7 s after the
|
|
14
|
+
* spawn, and the hung spawns that night wrote no row at all. So a spawn counts
|
|
15
|
+
* as started when it writes any stdout or stderr byte OR its transcript grows.
|
|
16
|
+
* A spawn that does neither inside the window is killed with a reason. When
|
|
17
|
+
* the transcript is missing altogether, the path may simply be wrong, so the
|
|
18
|
+
* spawn is killed only if the console is also locked.
|
|
19
|
+
*/
|
|
20
|
+
import { realpathSync, statSync } from 'fs';
|
|
21
|
+
import { readProcess } from '@commonlyai/seats/process-group';
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_FIRST_OUTPUT_WINDOW_MS = 180_000;
|
|
24
|
+
|
|
25
|
+
// 0 turns the watchdog off; anything unparseable keeps the default.
|
|
26
|
+
export const firstOutputWindowMs = (env = process.env) => {
|
|
27
|
+
const raw = env?.COMMONLY_FIRST_OUTPUT_WINDOW_MS;
|
|
28
|
+
if (raw === undefined || raw === '') return DEFAULT_FIRST_OUTPUT_WINDOW_MS;
|
|
29
|
+
const parsed = Number(raw);
|
|
30
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_FIRST_OUTPUT_WINDOW_MS;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const SPAWN_STALLED_CODE = 'spawn_stalled';
|
|
34
|
+
|
|
35
|
+
// The message carries the class word spawn-retry.js matches, so the stall gets
|
|
36
|
+
// its own backoff rather than RUNTIME's 5-second retry.
|
|
37
|
+
export const spawnStalledError = ({ label, windowMs, reason }) => {
|
|
38
|
+
const error = new Error(
|
|
39
|
+
`${label} produced no output in ${Math.round(windowMs / 1000)}s (${reason}); killed before the run timeout`,
|
|
40
|
+
);
|
|
41
|
+
error.code = SPAWN_STALLED_CODE;
|
|
42
|
+
error.stallReason = reason;
|
|
43
|
+
return error;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// `ioreg -n Root -d1` prints `"IOConsoleLocked" = Yes` while the screen is
|
|
47
|
+
// locked. True, false, or null when it cannot tell (not darwin, ioreg failed).
|
|
48
|
+
// child_process is loaded on demand: suites that mock it with only `spawn`
|
|
49
|
+
// would otherwise fail to import every adapter that uses this module.
|
|
50
|
+
export const consoleLocked = async ({ platform = process.platform, execFileImpl } = {}) => {
|
|
51
|
+
if (platform !== 'darwin') return null;
|
|
52
|
+
const run = execFileImpl || (await import('child_process')).execFile;
|
|
53
|
+
if (typeof run !== 'function') return null;
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
run('/usr/sbin/ioreg', ['-n', 'Root', '-d1'], { timeout: 5000 }, (error, stdout) => {
|
|
56
|
+
if (error) return resolve(null);
|
|
57
|
+
const match = String(stdout).match(/"IOConsoleLocked"\s*=\s*(Yes|No)/);
|
|
58
|
+
return resolve(match ? match[1] === 'Yes' : null);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Claude names its project folder after process.cwd(), which on macOS is the
|
|
64
|
+
// physical path (/tmp is /private/tmp), so the transcript is looked for there.
|
|
65
|
+
export const physicalPath = (dir) => {
|
|
66
|
+
if (!dir) return dir;
|
|
67
|
+
try {
|
|
68
|
+
return realpathSync(dir);
|
|
69
|
+
} catch {
|
|
70
|
+
return dir;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// How long a stalled spawn gets to exit after SIGTERM before SIGKILL.
|
|
75
|
+
export const STALL_KILL_GRACE_MS = 10_000;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* SIGKILL each snapshotted descendant of a stalled spawn that is still alive
|
|
79
|
+
* once the spawn itself has exited. Its parent may have exited, so it may now
|
|
80
|
+
* belong to launchd: only the pid and start time are matched, which still
|
|
81
|
+
* rules out a reused pid. Returns the pids signalled.
|
|
82
|
+
*/
|
|
83
|
+
export const killStallSurvivors = (snapshot, { read = readProcess, kill = process.kill.bind(process) } = {}) => {
|
|
84
|
+
const killed = [];
|
|
85
|
+
for (const row of snapshot || []) {
|
|
86
|
+
const now = read(row.pid);
|
|
87
|
+
if (!now || now.started !== row.started) continue;
|
|
88
|
+
try { kill(row.pid, 'SIGKILL'); killed.push(row.pid); } catch { /* gone */ }
|
|
89
|
+
}
|
|
90
|
+
return killed;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const sizeOf = (file) => {
|
|
94
|
+
if (!file) return -1;
|
|
95
|
+
try {
|
|
96
|
+
return statSync(file).size;
|
|
97
|
+
} catch {
|
|
98
|
+
return -1;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Watch `proc` for its first sign of life. Returns `{ stop }`; call it when the
|
|
104
|
+
* process closes. `onStall(reason)` fires at most once, after the caller should
|
|
105
|
+
* kill the process.
|
|
106
|
+
*/
|
|
107
|
+
export const watchFirstOutput = ({
|
|
108
|
+
proc,
|
|
109
|
+
windowMs,
|
|
110
|
+
transcriptPath = null,
|
|
111
|
+
onStall,
|
|
112
|
+
lockProbe = consoleLocked,
|
|
113
|
+
setTimeoutImpl = setTimeout,
|
|
114
|
+
clearTimeoutImpl = clearTimeout,
|
|
115
|
+
}) => {
|
|
116
|
+
if (!windowMs) return { stop: () => {} };
|
|
117
|
+
let started = false;
|
|
118
|
+
let stopped = false;
|
|
119
|
+
const markStarted = () => { started = true; };
|
|
120
|
+
proc.stdout?.on('data', markStarted);
|
|
121
|
+
proc.stderr?.on('data', markStarted);
|
|
122
|
+
// A missing file counts as empty, so creating an empty one is not growth.
|
|
123
|
+
const baseline = Math.max(0, sizeOf(transcriptPath));
|
|
124
|
+
const timer = setTimeoutImpl(async () => {
|
|
125
|
+
const size = sizeOf(transcriptPath);
|
|
126
|
+
if (started || size > baseline) return;
|
|
127
|
+
const locked = await lockProbe().catch(() => null);
|
|
128
|
+
if (stopped) return;
|
|
129
|
+
// No transcript where one was expected may only mean the path is wrong, so
|
|
130
|
+
// that alone never kills a turn: it takes a locked console as well.
|
|
131
|
+
if (size < 0 && locked !== true) return;
|
|
132
|
+
onStall(locked ? 'console locked' : 'no transcript, stdout or stderr');
|
|
133
|
+
}, windowMs);
|
|
134
|
+
timer?.unref?.();
|
|
135
|
+
return {
|
|
136
|
+
stop: () => {
|
|
137
|
+
stopped = true;
|
|
138
|
+
clearTimeoutImpl(timer);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
};
|
package/src/lib/memory-bridge.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
export const SOURCE_RUNTIME = 'local-cli';
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Row slicing, the runtime-agnostic half of
|
|
26
|
+
* Row slicing, the runtime-agnostic half of TASK-545. A row too large
|
|
27
27
|
* for one turn drifts when one seat carries it whole, so the seat splits it on
|
|
28
28
|
* the board before any code. It names the exact call and field for the reason
|
|
29
29
|
* the memory cue below names `long_term`: a seat adopts a habit through a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Which failed Commonly tool calls a public seat's model may answer in plain
|
|
3
|
-
* words. Everything else withholds the turn (#2374
|
|
3
|
+
* words. Everything else withholds the turn (#2374).
|
|
4
4
|
*
|
|
5
5
|
* The rule is inverted on purpose. Matching known connection failures let an
|
|
6
6
|
* API 5xx through: the shipped MCP turns a non-2xx into a tool error carrying
|
package/src/lib/sandbox/mode.js
CHANGED
|
@@ -5,15 +5,14 @@
|
|
|
5
5
|
* derives `sandbox: { trust: 'public' }` with NO mode (lib/default-environment.js)
|
|
6
6
|
* because `mode: 'workspace'` is Seatbelt on macOS and is refused on Linux,
|
|
7
7
|
* while `mode: 'bwrap'` is meaningless on macOS — writing either into the row
|
|
8
|
-
* moves a host fact into the database and breaks the day the seat is re-homed
|
|
9
|
-
* (Wren 69545).
|
|
8
|
+
* moves a host fact into the database and breaks the day the seat is re-homed.
|
|
10
9
|
*
|
|
11
10
|
* So the mode is resolved here, at the point that knows the host:
|
|
12
11
|
* darwin → 'workspace' (the claude adapter maps it to Seatbelt)
|
|
13
12
|
* else → 'bwrap' (bubblewrap; attach checks it is installed)
|
|
14
13
|
*
|
|
15
14
|
* Resolution is PUBLIC-only on purpose, and PUBLIC IS THE EFFECTIVE TRUST: a
|
|
16
|
-
* legacy `internal` declaration reads as `public` (environment.js
|
|
15
|
+
* legacy `internal` declaration reads as `public` (environment.js),
|
|
17
16
|
* so the mapping is applied before this comparison. Comparing the raw value
|
|
18
17
|
* made `{ trust: 'internal' }` resolve to no mode at all — the mapping was
|
|
19
18
|
* applied in `confinementReason` and not here, which is half a rule, and the
|
|
@@ -22,7 +21,7 @@
|
|
|
22
21
|
* whatever mode it wrote, and an adapter that cannot enforce it is expected to
|
|
23
22
|
* refuse rather than quietly spawn unconfined. An explicit mode on a public
|
|
24
23
|
* declaration still wins — including a wrong one, which the caller then refuses
|
|
25
|
-
* (
|
|
24
|
+
* (an unresolvable public mode must not fall through to a bare,
|
|
26
25
|
* unconfined spawn).
|
|
27
26
|
*
|
|
28
27
|
* NOT used by the codex adapter. Codex's `mode` is read-vs-write access, not a
|
|
@@ -301,7 +301,7 @@ const OPEN_NETWORK = String.raw`
|
|
|
301
301
|
// Resolver is constructed. Denied, that lookup does not always fail: at
|
|
302
302
|
// times it blocks the main thread forever. dsh builds a Resolver as it loads,
|
|
303
303
|
// so confined turns ran to their 900 s timeout without sending a request
|
|
304
|
-
//
|
|
304
|
+
// Admitted, resolution fails fast, because mDNSResponder and port 53
|
|
305
305
|
// stay unreachable in a loopback profile. The service returns configuration
|
|
306
306
|
// and opens no channel out.
|
|
307
307
|
const RESOLVER_CONFIG = String.raw`
|
package/src/lib/spawn-retry.js
CHANGED
|
@@ -16,8 +16,16 @@ export const SPAWN_FAILURE_CLASS = Object.freeze({
|
|
|
16
16
|
RATE_LIMIT: 'rate_limit',
|
|
17
17
|
CONFIGURATION: 'configuration',
|
|
18
18
|
RUNTIME: 'runtime',
|
|
19
|
+
// The spawn never started: no transcript row, stdout or stderr inside the
|
|
20
|
+
// first-output window (lib/first-output-watchdog.js). A host condition such as
|
|
21
|
+
// a locked console, so the circuit opens at once and the probe waits.
|
|
22
|
+
STALLED: 'stalled',
|
|
19
23
|
});
|
|
20
24
|
|
|
25
|
+
// A stalled probe costs one first-output window, so the wait starts short and
|
|
26
|
+
// doubles to the usual ceiling: 2, 4, 8, then 15 minutes.
|
|
27
|
+
export const SPAWN_STALL_RETRY_BASE_MS = 2 * 60 * 1000;
|
|
28
|
+
|
|
21
29
|
export const SPAWN_CIRCUIT_THRESHOLD = 3;
|
|
22
30
|
// Base ceiling before the stable per-agent 0–20% anti-herd offset.
|
|
23
31
|
export const SPAWN_RETRY_MAX_MS = 15 * 60 * 1000;
|
|
@@ -116,6 +124,7 @@ export const classifySpawnFailure = (error) => {
|
|
|
116
124
|
// First: the refusal text carries none of the words below today, but a
|
|
117
125
|
// refusal must never be read as a transient condition and retried.
|
|
118
126
|
if (CONTENT_REFUSED_RE.test(text)) return SPAWN_FAILURE_CLASS.CONTENT_REFUSED;
|
|
127
|
+
if (error?.code === 'spawn_stalled') return SPAWN_FAILURE_CLASS.STALLED;
|
|
119
128
|
// Provider APIs commonly report an exhausted account quota as HTTP 429.
|
|
120
129
|
// Prefer the more specific body/message over the generic status code so a
|
|
121
130
|
// hard quota failure gets the long cooldown rather than a one-minute probe.
|
|
@@ -124,7 +133,7 @@ export const classifySpawnFailure = (error) => {
|
|
|
124
133
|
// and "limit", and its `(?!rate[ -]?limit)` lookahead only guards the word
|
|
125
134
|
// IMMEDIATELY after "your" — so "you've hit your API rate limit" walked past
|
|
126
135
|
// it into the 15-minute circuit, where main correctly gives the 60-second
|
|
127
|
-
// rate-limit probe (
|
|
136
|
+
// rate-limit probe (gate on #2491; it was a regression, not a gap).
|
|
128
137
|
// A throttle wording anywhere in the text therefore wins over the shape.
|
|
129
138
|
// QUOTA_RE is still tested first, so an explicit quota word keeps QUOTA even
|
|
130
139
|
// when the same message also mentions rate limits.
|
|
@@ -194,6 +203,17 @@ export const spawnRetryPolicy = ({
|
|
|
194
203
|
};
|
|
195
204
|
}
|
|
196
205
|
|
|
206
|
+
if (failureClass === SPAWN_FAILURE_CLASS.STALLED) {
|
|
207
|
+
return {
|
|
208
|
+
failureClass,
|
|
209
|
+
circuitOpen: true,
|
|
210
|
+
delayMs: applyJitter(
|
|
211
|
+
Math.min(SPAWN_RETRY_MAX_MS, SPAWN_STALL_RETRY_BASE_MS * (2 ** (failureCount - 1))),
|
|
212
|
+
jitterRatio,
|
|
213
|
+
),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
197
217
|
if (failureClass === SPAWN_FAILURE_CLASS.RATE_LIMIT) {
|
|
198
218
|
return {
|
|
199
219
|
failureClass,
|
|
@@ -73,7 +73,7 @@ export const startWebhookServer = ({
|
|
|
73
73
|
* The handler `agent connect` serves on its own port. It only logs what
|
|
74
74
|
* arrives, and no agent has read the event, so it states no outcome a read
|
|
75
75
|
* would count as: the wake still settles, and the pod digest stays unread
|
|
76
|
-
* (
|
|
76
|
+
* (#2753).
|
|
77
77
|
*/
|
|
78
78
|
export const logOnlyHandler = async (event) => {
|
|
79
79
|
console.log(`[local] ${event.type} from pod ${event.podId}`);
|
|
@@ -99,7 +99,7 @@ export const forwardToLocalWebhook = async (event, webhookUrl, secret = null) =>
|
|
|
99
99
|
if (!res.ok) throw new Error(`Local webhook returned HTTP ${res.status}`);
|
|
100
100
|
// Only a stated outcome counts. An empty or non-JSON body, or one without
|
|
101
101
|
// an outcome, is a handler that said nothing, the same as a CLI turn that
|
|
102
|
-
// printed nothing, so it must not read the pod digest (
|
|
102
|
+
// printed nothing, so it must not read the pod digest (#2753).
|
|
103
103
|
let body = null;
|
|
104
104
|
try { body = await res.json(); } catch { body = null; }
|
|
105
105
|
if (!body || typeof body !== 'object' || typeof body.outcome !== 'string') {
|