@bridge4dev/runner 0.56.0 → 0.58.1

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/index.js CHANGED
@@ -9,6 +9,8 @@ import { ClaudeAdapter } from './adapters/claude.js';
9
9
  import { CodexAdapter } from './adapters/codex.js';
10
10
  import { ensureCodexHome } from './adapters/codex-home.js';
11
11
  import { sessionClaudePath } from './agent-binary.js';
12
+ import { claimCageAuthority, runSystemctl } from './cage-authority.js';
13
+ import { acquireDaemonLock, isHeldByAnother } from './daemon-lock.js';
12
14
  import { loadConfig, mergeIntoPairedConfig, requireConfig, saveConfig, } from './config.js';
13
15
  import { log } from './log.js';
14
16
  import { installIsWritable, installPrefixFor, isSupervisedProcess, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
@@ -17,12 +19,14 @@ import { Supervisor } from './supervisor.js';
17
19
  import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
18
20
  import { RunnerWsClient } from './ws-client.js';
19
21
  import { RUNNER_VERSION } from './version.js';
20
- import { buildUnit, cpuQuotaPercent, devbridgeSliceOverridePath, limitsOverrideIsOutdated, limitsOverridePath, memoryPolicy, readMemoryFacts, sessionsSliceOverridePath, unitExecTarget, unitPath, writeLimitsOverride, DEVBRIDGE_SLICE, LIMITS_VERSION, SESSION_CPU_WEIGHT, SESSIONS_SLICE, } from './service-unit.js';
21
- import { initSessionCage, listSessionScopes, sessionCage, sweepOrphanSessionScopes, SESSION_TASKS_MAX, } from './session-cage.js';
22
+ import { SESSION_GUARANTEE_BYTES } from './session-allocator.js';
23
+ import { STALL_GRACE_MS } from './session-stall.js';
24
+ import { buildUnit, cpuQuotaPercent, devbridgeSliceOverridePath, limitsOverrideIsOutdated, limitsOverridePath, memoryPolicy, readMemoryFacts, sessionsSliceOverridePath, unitExecTarget, unitPath, writeLimitsOverride, DEVBRIDGE_SLICE, LIMITS_VERSION, SESSION_CPU_WEIGHT, SESSIONS_SLICE, readSwapTotalBytes, } from './service-unit.js';
25
+ import { defaultCageProbe, initSessionCage, readSliceLimits, listSessionScopes, sessionCage, sweepOrphanSessionScopes, SESSION_TASKS_MAX, } from './session-cage.js';
22
26
  import { SEARCH_GUARD_ENABLED, claudeSettingsPath, installSearchGuard, removeSearchGuard, searchGuardCommand, searchGuardHome, searchGuardHookPath, searchGuardStatus, } from './claude-settings.js';
23
27
  import { readOomKills, recordCrash, takeLastExit } from './crash-note.js';
24
28
  import { agentAuthStatuses } from './auth-relay.js';
25
- import { addSafeDirectory, agentConfigContour, dockerCheck, ensureAgentPath, firstUnreachableAncestor, hasSafeDirectory, inspectPath, knownWorkspacePaths, lingerEnabled, nodeCheck, otherHomeWithAgents, runnerIdentity, safeDirectoryCommand, systemctlHint, systemdUserBusReachable, systemdUserEnv, } from './environment.js';
29
+ import { addSafeDirectory, agentConfigContour, dockerCheck, ensureAgentPath, firstUnreachableAncestor, hasSafeDirectory, inspectPath, knownWorkspacePaths, lingerEnabled, nodeCheck, otherHomeWithAgents, runnerIdentity, safeDirectoryCommand, systemctlHint, systemdUserBusReachable, } from './environment.js';
26
30
  import { mcpConfigDir } from './paths.js';
27
31
  import { readMemoryFactsFromSystemd } from './systemd-memory.js';
28
32
  const execFileAsync = promisify(execFile);
@@ -536,6 +540,11 @@ async function cmdPair(args) {
536
540
  // Probed here as well as at daemon start, so the very first record of this
537
541
  // server already carries the truth about its containment instead of the safe
538
542
  // default. Costs one throwaway process, once.
543
+ //
544
+ // `probe` and not `daemon` (#403): pairing may run its own throwaway unit and
545
+ // clean it up, and it has no business anywhere near a session's cage — there
546
+ // may be a daemon running sessions on this machine right now.
547
+ claimCageAuthority('probe');
539
548
  await initSessionCage();
540
549
  const capabilities = runnerCapabilities(apiUrl);
541
550
  const response = await fetch(`${apiUrl}/api/v1/dev/servers/claim`, {
@@ -607,7 +616,91 @@ const RESTART_DELAY_MS = 1_500;
607
616
  * a runner that refuses to start because it could not improve its own limits is
608
617
  * strictly worse than one that starts without the improvement.
609
618
  */
610
- async function repairResourceLimits() {
619
+ /**
620
+ * The machine as the cage last measured it — see {@link reMeasureMachine}.
621
+ */
622
+ let lastMachineFingerprint = null;
623
+ /**
624
+ * Re-measure the machine on the hourly tick, and re-probe the cage when it has
625
+ * actually changed (#398 S3, work 11).
626
+ *
627
+ * `initSessionCage()` probes the machine EXACTLY ONCE, at daemon start, and
628
+ * remembers the answer; the hourly `repairResourceLimits` rewrites the slice's
629
+ * drop-in from fresh facts but never touches that cache. A live example, on the
630
+ * day of the incident: a swap file appeared on vmi3219930, and every session
631
+ * there went on being told `MemorySwapMax=0` — so the brake kept stopping dead
632
+ * instead of slowing down, on the one machine that had by then acquired
633
+ * somewhere to push pages. Until this, such a machine was cured only by
634
+ * restarting the runner.
635
+ *
636
+ * The probe costs a throwaway scope, so it is not done unconditionally: it runs
637
+ * only when the machine's fingerprint moved. New numbers reach LIVE sessions
638
+ * through the allocator's own tick, which reads the slice off cgroupfs and
639
+ * writes what has changed.
640
+ *
641
+ * The card is deliberately NOT promised here: `capabilities` are assembled once
642
+ * in `cmdDaemon` and travel only inside `hello`, so there is nothing to reissue
643
+ * them with short of a reconnect.
644
+ */
645
+ async function reMeasureMachine(maxSessions) {
646
+ await repairResourceLimits(maxSessions);
647
+ try {
648
+ const slice = readSliceLimits();
649
+ const fingerprint = JSON.stringify({
650
+ swap: readSwapTotalBytes() ?? 0,
651
+ pot: slice?.potBytes ?? null,
652
+ brake: slice?.collectiveBrakeBytes ?? null,
653
+ });
654
+ if (lastMachineFingerprint === null) {
655
+ lastMachineFingerprint = fingerprint;
656
+ return;
657
+ }
658
+ if (fingerprint === lastMachineFingerprint)
659
+ return;
660
+ log.warn('daemon: the machine changed under us — re-measuring the session cage', {
661
+ was: lastMachineFingerprint,
662
+ now: fingerprint,
663
+ });
664
+ /**
665
+ * A re-probe that comes back WORSE does not take the cage away.
666
+ *
667
+ * `initSessionCage` replaces what the daemon knows, and its live probe is a
668
+ * `systemd-run` — one transient failure (a busy bus, a momentary
669
+ * `Failed to connect`) would answer `nice-only`, and from that moment every
670
+ * new session on that machine would start with no cage at all, until
671
+ * somebody restarted the runner. An hourly job must not be able to do that.
672
+ * Found by the independent review of 10.09.2026.
673
+ *
674
+ * Losing the cage for real is possible — someone remounts cgroups, the user
675
+ * bus goes — and it is not silent: the line below says so, and the next
676
+ * daemon start settles it.
677
+ */
678
+ // `keepCageIfWorse`: an hourly job must not be able to uncage a healthy
679
+ // machine on one transient `systemd-run` failure — see `initSessionCage`.
680
+ const facts = await initSessionCage(defaultCageProbe, { keepCageIfWorse: true });
681
+ /**
682
+ * Remembered only when the measurement actually SUCCEEDED (#398 S7, B4).
683
+ *
684
+ * The fingerprint used to be stored before the probe, so one transient
685
+ * failure — the very failure `keepCageIfWorse` exists to survive — left the
686
+ * next hour seeing an unchanged fingerprint and skipping the re-probe
687
+ * forever. The cage was kept, and the FACTS behind it stayed yesterday's:
688
+ * `swapMaxBytes` above all, which is the vmi3219930 bug this work was
689
+ * written for. Cured only by restarting the runner, which is what the whole
690
+ * job was supposed to stop needing.
691
+ */
692
+ if (facts.mode === 'scope')
693
+ lastMachineFingerprint = fingerprint;
694
+ }
695
+ catch (error) {
696
+ // Never fatal: a runner that dies because it could not re-measure is worse
697
+ // than one that keeps yesterday's numbers for another hour.
698
+ log.warn('daemon: could not re-measure the machine', {
699
+ error: String(error instanceof Error ? error.message : error),
700
+ });
701
+ }
702
+ }
703
+ async function repairResourceLimits(maxSessions) {
611
704
  try {
612
705
  const { facts, sessionsUsageBytes } = await readMemoryFactsFromSystemd();
613
706
  if (facts === null) {
@@ -628,17 +721,14 @@ async function repairResourceLimits() {
628
721
  totalMB: Math.round(facts.totalBytes / 1048576),
629
722
  });
630
723
  }
631
- if (!writeLimitsOverride(false, undefined, facts, sessionsUsageBytes))
724
+ if (!writeLimitsOverride(false, undefined, facts, sessionsUsageBytes, maxSessions))
632
725
  return;
633
726
  log.warn('daemon: resource limits drop-in written — reloading systemd', {
634
727
  path: limitsOverridePath(),
635
728
  version: LIMITS_VERSION,
636
729
  ...memoryPolicy(facts),
637
730
  });
638
- await execFileAsync('systemctl', ['--user', 'daemon-reload'], {
639
- timeout: 15_000,
640
- env: systemdUserEnv(),
641
- });
731
+ await runSystemctl(['daemon-reload'], { timeout: 15_000 });
642
732
  // Deliberately no restart: `daemon-reload` alone is enough for these
643
733
  // directives — verified live twice, in both directions: 2026-07-30 removing a
644
734
  // ceiling (MemoryMax 2G→infinity, OOMPolicy stop→continue) and 2026-08-12
@@ -757,13 +847,51 @@ async function cmdDaemon() {
757
847
  const config = requireConfig();
758
848
  log.info('daemon starting', { version: RUNNER_VERSION, server: config.server.name });
759
849
  sweepOrphanedMcpConfigs();
850
+ /**
851
+ * One daemon per user manager, checked BEFORE the sweep below (#403).
852
+ *
853
+ * The sweep stops every session scope this process does not know about, and
854
+ * at start-up that is all of them. A second daemon — `pnpm dev` of this
855
+ * package beside a paired runner, or a hand-started `devbridge-runner daemon`
856
+ * — therefore ends every live session the first one is supervising, and no
857
+ * guard inside the test suite covers that path.
858
+ *
859
+ * The escape hatch is deliberate and narrow: `DEVBRIDGE_ALLOW_SECOND_DAEMON=1`
860
+ * starts a second daemon with the `probe` grade, so it can run and be
861
+ * developed against but cannot touch anybody's cage.
862
+ */
863
+ const lock = acquireDaemonLock();
864
+ const second = isHeldByAnother(lock);
865
+ if (second) {
866
+ if (process.env['DEVBRIDGE_ALLOW_SECOND_DAEMON'] !== '1') {
867
+ fail(`another devbridge-runner daemon is already running on this user (pid ${lock.heldBy}). ` +
868
+ "Two daemons stop each other's sessions — see the lock at " +
869
+ `${lock.path}. Set DEVBRIDGE_ALLOW_SECOND_DAEMON=1 to start one that cannot touch cages.`);
870
+ }
871
+ log.warn('daemon: a second daemon, started without the right to act on session cages', {
872
+ heldBy: lock.heldBy,
873
+ });
874
+ }
875
+ else {
876
+ process.once('exit', () => lock.release());
877
+ }
878
+ /**
879
+ * The daemon is the ONE process on this machine allowed to act on session
880
+ * cages (#403), and it says so here, once, before the first sweep.
881
+ *
882
+ * Everything below — the sweep, the drop-ins, the probe, and later every
883
+ * limit written to a live scope — goes through `cage-authority.ts` and would
884
+ * be refused without this line. A test process cannot reach it at all: the
885
+ * claim throws there rather than granting anything.
886
+ */
887
+ claimCageAuthority(second ? 'probe' : 'daemon');
760
888
  // Nothing of ours is running yet, so every `devbridge-session-*.scope` on this
761
889
  // machine belongs to a process that is gone. Stopping the scope takes the
762
890
  // whole tree under it — which is the answer to the `ugrep` that outlived its
763
891
  // session by 10 h 51 min on 16.08, and to every scope the OOM killer left in
764
892
  // `failed` (a name systemd will otherwise refuse to reuse).
765
893
  await sweepOrphanSessionScopes();
766
- await repairResourceLimits();
894
+ await repairResourceLimits(config.limits?.max_sessions);
767
895
  // After the drop-ins are on disk and reloaded, never before: the probe below
768
896
  // creates `devbridge-sessions.slice`, and a slice first loaded without its
769
897
  // policy would hold no ceiling until the next daemon-reload.
@@ -785,6 +913,29 @@ async function cmdDaemon() {
785
913
  },
786
914
  ...(config.mcp ? { mcp: { url: config.mcp.url, token: config.mcp.token } } : {}),
787
915
  ...(config.limits?.max_sessions ? { maxSessionsLimit: config.limits.max_sessions } : {}),
916
+ /**
917
+ * #398 S6: the machine owner's memory knobs, read once here and passed
918
+ * down. Nothing outside this file imports `config.js`, and a module that
919
+ * read the file itself would be the first place for two answers about one
920
+ * setting to appear.
921
+ */
922
+ memoryKnobs: {
923
+ ...(config.limits?.adaptive_memory === undefined
924
+ ? {}
925
+ : { adaptive: config.limits.adaptive_memory }),
926
+ ...(config.limits?.session_memory_min === undefined
927
+ ? {}
928
+ : { guaranteeBytes: config.limits.session_memory_min * 1024 * 1024 }),
929
+ ...(config.limits?.session_memory_max === undefined
930
+ ? {}
931
+ : { sessionMaxBytes: config.limits.session_memory_max * 1024 * 1024 }),
932
+ },
933
+ ...(config.limits?.memory_stall_grace_sec === undefined
934
+ ? {}
935
+ : { stallGraceMs: config.limits.memory_stall_grace_sec * 1000 }),
936
+ ...(config.limits?.memory_stall_action === undefined
937
+ ? {}
938
+ : { memoryStallAction: config.limits.memory_stall_action }),
788
939
  // The same veto the capability list honours — announced AND enforced, so a
789
940
  // frame from an API that has not noticed still cannot start a run.
790
941
  verifyEnabled: config.verify?.enabled !== false,
@@ -836,7 +987,7 @@ async function cmdDaemon() {
836
987
  pruneTimer.unref();
837
988
  // Re-measure the machine — see `LIMITS_RECHECK_MS`. Writes nothing in the
838
989
  // normal case, so this is a file read and some arithmetic once an hour.
839
- const limitsTimer = setInterval(() => void repairResourceLimits(), LIMITS_RECHECK_MS);
990
+ const limitsTimer = setInterval(() => void reMeasureMachine(config.limits?.max_sessions), LIMITS_RECHECK_MS);
840
991
  limitsTimer.unref();
841
992
  const shutdown = (signal) => {
842
993
  log.info(`daemon: ${signal} received, shutting down`);
@@ -931,6 +1082,9 @@ async function cmdInstallService() {
931
1082
  requireConfig(); // fail early if not paired
932
1083
  if (process.platform !== 'linux')
933
1084
  fail('install-service supports Linux/systemd only');
1085
+ // Installing the service writes the runner's own unit and starts it; it never
1086
+ // touches a session's cage, so `install` and not `daemon` (#403).
1087
+ claimCageAuthority('install');
934
1088
  const target = unitPath();
935
1089
  fs.mkdirSync(path.dirname(target), { recursive: true });
936
1090
  const exec = unitExecTarget();
@@ -979,10 +1133,8 @@ async function cmdInstallService() {
979
1133
  print(`note: the service runs ${exec.execStart} directly — re-run install-service after reinstalling the package.`);
980
1134
  }
981
1135
  try {
982
- await execFileAsync('systemctl', ['--user', 'daemon-reload'], { env: systemdUserEnv() });
983
- await execFileAsync('systemctl', ['--user', 'enable', '--now', 'devbridge-runner'], {
984
- env: systemdUserEnv(),
985
- });
1136
+ await runSystemctl(['daemon-reload']);
1137
+ await runSystemctl(['enable', '--now', 'devbridge-runner']);
986
1138
  print('Service enabled and started (systemctl --user).');
987
1139
  }
988
1140
  catch (error) {
@@ -1008,7 +1160,9 @@ function printCheck(check) {
1008
1160
  /** One property of the user service, or null when systemd cannot answer. */
1009
1161
  async function systemctlProperty(name) {
1010
1162
  try {
1011
- const { stdout } = await execFileAsync('systemctl', ['--user', 'show', 'devbridge-runner', '-p', name, '--value'], { timeout: 10_000, env: systemdUserEnv() });
1163
+ const { stdout } = await runSystemctl(['show', 'devbridge-runner', '-p', name, '--value'], {
1164
+ timeout: 10_000,
1165
+ });
1012
1166
  const value = stdout.trim();
1013
1167
  return value.length > 0 ? value : null;
1014
1168
  }
@@ -1378,6 +1532,9 @@ async function reportAgentReadiness(paths, fix) {
1378
1532
  }
1379
1533
  async function cmdDoctor(args) {
1380
1534
  const fix = args.includes('--fix');
1535
+ // Reading needs no right at all; `--fix` writes the runner's own drop-in and
1536
+ // reloads systemd, which is the `install` grade and nothing above it (#403).
1537
+ claimCageAuthority(fix ? 'install' : 'probe');
1381
1538
  const config = loadConfig();
1382
1539
  print(`devbridge-runner ${RUNNER_VERSION}`);
1383
1540
  print(config ? `Paired with: ${config.server.name} (${config.api.url})` : 'Not paired');
@@ -1397,6 +1554,52 @@ async function cmdDoctor(args) {
1397
1554
  // build, so this is the honest ceiling regardless of what the dashboard says.
1398
1555
  const advised = Math.max(1, Math.floor(os.totalmem() / 1024 / 1024 / 1536));
1399
1556
  print(` fits sessions ~${advised} (at ~1.5 GB per session under load)`);
1557
+ /**
1558
+ * #398 S6. Every memory number `doctor` printed before this block came off
1559
+ * systemd or off `os` — it never said a word about `[limits]` in the machine
1560
+ * owner's own `config.toml`, so «I set a guarantee and nothing changed» had no
1561
+ * answer anywhere on the machine. Each line names the value in force AND
1562
+ * where it came from.
1563
+ */
1564
+ print('');
1565
+ print('Session memory (#398)');
1566
+ {
1567
+ const limits = loadConfig()?.limits;
1568
+ const shipped = (value) => `${value} (shipped default)`;
1569
+ const chosen = (value, unit) => `${value}${unit} (config.toml)`;
1570
+ print(` adaptive ${limits?.adaptive_memory === false
1571
+ ? 'OFF (config.toml) — sessions get the fixed pre-0.58.0 third'
1572
+ : 'on (shipped default) — the share follows what is actually free'}`);
1573
+ print(` guarantee ${limits?.session_memory_min === undefined
1574
+ ? shipped(Math.round(SESSION_GUARANTEE_BYTES / 1024 / 1024)) + ' MB'
1575
+ : chosen(limits.session_memory_min, ' MB')}`);
1576
+ print(` session ceiling ${limits?.session_memory_max === undefined
1577
+ ? 'whatever the machine has free (shipped default)'
1578
+ : chosen(limits.session_memory_max, ' MB')}`);
1579
+ print(` stall grace ${limits?.memory_stall_grace_sec === undefined
1580
+ ? shipped(Math.round(STALL_GRACE_MS / 1000)) + ' s'
1581
+ : chosen(limits.memory_stall_grace_sec, ' s')}`);
1582
+ print(` when it stalls ${limits?.memory_stall_action === 'report-only'
1583
+ ? 'report only (config.toml) — DevBridge stops nothing'
1584
+ : 'stop the biggest command (shipped default)'}`);
1585
+ print(` seats ${limits?.max_sessions ?? 'the API decides (no max_sessions set)'}`);
1586
+ const slice = readSliceLimits();
1587
+ /**
1588
+ * Named precisely, because «MISSING» alone sent people looking for a fault.
1589
+ *
1590
+ * The brake arrives with `LIMITS_VERSION` 6, and a machine whose runner has
1591
+ * not restarted since the update simply does not have it yet — that is a
1592
+ * pending update, not a broken machine. Found by the independent review of
1593
+ * 10.09.2026.
1594
+ */
1595
+ const brakeInForce = slice?.collectiveBrakeBytes ?? null;
1596
+ const dropInIsOld = limitsOverrideIsOutdated();
1597
+ print(` collective brake ${brakeInForce === null
1598
+ ? dropInIsOld
1599
+ ? 'not applied yet — the drop-in on this machine is older than this runner; `doctor --fix` writes it'
1600
+ : 'MISSING — run `devbridge-runner doctor --fix`; until then sessions get a smaller share each'
1601
+ : `${Math.round(brakeInForce / 1024 / 1024)} MB in force on the slice`}`);
1602
+ }
1400
1603
  print('');
1401
1604
  print('Service limits');
1402
1605
  const { facts: memFacts, sessionsUsageBytes: memSessionsUsage } = await readMemoryFactsFromSystemd();
@@ -1476,8 +1679,7 @@ async function cmdDoctor(args) {
1476
1679
  }
1477
1680
  let effective;
1478
1681
  try {
1479
- const { stdout } = await execFileAsync('systemctl', [
1480
- '--user',
1682
+ const { stdout } = await runSystemctl([
1481
1683
  'show',
1482
1684
  'devbridge-runner',
1483
1685
  '-p',
@@ -1490,7 +1692,7 @@ async function cmdDoctor(args) {
1490
1692
  'OOMPolicy',
1491
1693
  '-p',
1492
1694
  'NRestarts',
1493
- ], { env: systemdUserEnv() });
1695
+ ]);
1494
1696
  effective = stdout.trim().split('\n').filter(Boolean);
1495
1697
  }
1496
1698
  catch {
@@ -1629,7 +1831,7 @@ async function cmdDoctor(args) {
1629
1831
  }
1630
1832
  }
1631
1833
  try {
1632
- await execFileAsync('systemctl', ['--user', 'daemon-reload'], { env: systemdUserEnv() });
1834
+ await runSystemctl(['daemon-reload']);
1633
1835
  print('systemctl --user daemon-reload — done.');
1634
1836
  print('Restart when sessions are idle: systemctl --user restart devbridge-runner');
1635
1837
  }
package/dist/policy.d.ts CHANGED
@@ -71,6 +71,14 @@ export interface PolicyContext extends AgentGitPolicy {
71
71
  * than this release — and there the answer stays what it has always been.
72
72
  */
73
73
  agentAutoCommit?: boolean;
74
+ /**
75
+ * This session's memory ceiling in bytes, or `undefined` on a machine with no
76
+ * cage (#398 S5).
77
+ *
78
+ * Passed in rather than read here, so the pure policy stays pure and a test
79
+ * can put a session on a machine of any size.
80
+ */
81
+ sessionMemoryMaxBytes?: number;
74
82
  /**
75
83
  * Absolute path of the project's own prompt file, when this session was given
76
84
  * one (session 17).
@@ -226,5 +234,6 @@ export declare function evaluateRecipeCommand(command: string, ctx?: RecipeComma
226
234
  * workspace trust unchanged, which is exactly the old behaviour.
227
235
  */
228
236
  export declare function effectiveTrust(trustMode: TrustMode, mode?: AgentMode): TrustMode;
237
+ export declare function requestedHeapBytes(command: string): number | null;
229
238
  export declare function evaluateToolUse(toolName: string, input: Record<string, unknown>, ctx: PolicyContext): PolicyDecision;
230
239
  //# sourceMappingURL=policy.d.ts.map
package/dist/policy.js CHANGED
@@ -1173,6 +1173,46 @@ export function effectiveTrust(trustMode, mode) {
1173
1173
  // ask / plan — at most NORMAL, never AUTO.
1174
1174
  return trustMode === 'AUTO' ? 'NORMAL' : trustMode;
1175
1175
  }
1176
+ /**
1177
+ * The largest Node heap this command line asks for, in bytes — or null when it
1178
+ * asks for none.
1179
+ *
1180
+ * Both spellings systemd-era Node accepts (`--max-old-space-size=N` and
1181
+ * `--max_old_space_size N`), anywhere on the line: the flag arrives through
1182
+ * `NODE_OPTIONS=`, through `node --…`, and through a `pnpm` script that passes
1183
+ * it on, and it is the NUMBER that matters, not who wrote it.
1184
+ *
1185
+ * The unit is megabytes, which is V8's own unit for the flag — a plain number,
1186
+ * never a suffix.
1187
+ */
1188
+ /**
1189
+ * Commands that RUN node, as opposed to commands that merely mention it.
1190
+ *
1191
+ * The gate reads the heap flag out of a shell line, and a shell line is not one
1192
+ * command: `sed -i 's/--max-old-space-size=8192/…=3000/' package.json` contains
1193
+ * the flag and asks Node for nothing at all. Denying that was worse than a
1194
+ * nuisance — LOWERING the flag is exactly what the refusal tells the agent to
1195
+ * do, so the gate forbade its own remedy (#398 S7, B10).
1196
+ */
1197
+ const NODE_RUNNERS = /(^|[\s;&|(])(sudo\s+)?(env\s+)?([A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(node|nodejs|npm|npx|pnpm|yarn|bun|deno|tsx|ts-node|vitest|jest)([\s;&|)]|$)/;
1198
+ export function requestedHeapBytes(command) {
1199
+ let largest = null;
1200
+ for (const segment of commandSegments(command)) {
1201
+ // `NODE_OPTIONS=` carries the flag into whatever the segment starts, so it
1202
+ // counts on its own; otherwise the segment has to actually start a runtime.
1203
+ if (!/NODE_OPTIONS\s*=/.test(segment) && !NODE_RUNNERS.test(segment))
1204
+ continue;
1205
+ for (const match of segment.matchAll(/--?max[-_]old[-_]space[-_]size(?:\s*=\s*|\s+)(\d+)/gi)) {
1206
+ const mb = Number(match[1]);
1207
+ if (!Number.isSafeInteger(mb) || mb <= 0)
1208
+ continue;
1209
+ const bytes = mb * 1024 * 1024;
1210
+ if (largest === null || bytes > largest)
1211
+ largest = bytes;
1212
+ }
1213
+ }
1214
+ return largest;
1215
+ }
1176
1216
  export function evaluateToolUse(toolName, input, ctx) {
1177
1217
  // Read ONCE, here, and never `ctx.trustMode` again below: the branches that
1178
1218
  // follow are the whole of layer 1, and a single one still reading the raw
@@ -1227,6 +1267,34 @@ export function evaluateToolUse(toolName, input, ctx) {
1227
1267
  }
1228
1268
  if (gitAsk)
1229
1269
  return gitAsk;
1270
+ /**
1271
+ * A heap the cage cannot honour (#398 S5).
1272
+ *
1273
+ * Same position as everything above it and for the same reason: under AUTO
1274
+ * the Bash branch returns `allow` two lines down, and a rule placed below
1275
+ * that line is dead in the one mode most sessions run in. That mistake cost
1276
+ * a whole release once (session 13).
1277
+ *
1278
+ * From the incident this plan comes from: the agent raised its own heap to
1279
+ * 4 GB, then to 6 GB, against a wall of 4296 MB, and each time made things
1280
+ * worse — the ceiling is enforced OUTSIDE the process, so asking for more
1281
+ * only means being stopped sooner. The reason is written for the model to
1282
+ * read: it names the number and the way out.
1283
+ */
1284
+ if (ctx.sessionMemoryMaxBytes !== undefined && ctx.sessionMemoryMaxBytes > 0) {
1285
+ for (const variant of [command, dequote(command)]) {
1286
+ const asked = requestedHeapBytes(variant);
1287
+ if (asked !== null && asked > ctx.sessionMemoryMaxBytes) {
1288
+ const mb = (bytes) => Math.round(bytes / (1024 * 1024));
1289
+ return {
1290
+ decision: 'deny',
1291
+ reason: `this command asks Node for a ${mb(asked)} MB heap, and this session cannot exceed ` +
1292
+ `${mb(ctx.sessionMemoryMaxBytes)} MB — the ceiling is enforced outside the process, so a ` +
1293
+ 'bigger heap only means being stopped sooner. Lower --max-old-space-size, or split the work up',
1294
+ };
1295
+ }
1296
+ }
1297
+ }
1230
1298
  if (trust === 'STRICT')
1231
1299
  return { decision: 'ask', reason: 'strict mode' };
1232
1300
  if (trust === 'AUTO')