@bridge4dev/runner 0.57.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.
@@ -6,7 +6,7 @@ import { AsyncQueue } from '../async-queue.js';
6
6
  import { log } from '../log.js';
7
7
  import { mcpConfigPath } from '../paths.js';
8
8
  import { lowerPriority } from '../process-priority.js';
9
- import { cageSpawn, memoryDeathSentence, releaseSessionScope } from '../session-cage.js';
9
+ import { cageSpawn, noteSessionAgentPid, memoryDeathSentence, releaseSessionScope, sessionMemoryEnv, sessionMemoryPromptLine, } from '../session-cage.js';
10
10
  import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
11
11
  import { availableModes, cardDescription, DIRECT_BRANCH_RULE, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
12
12
  import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
@@ -100,11 +100,19 @@ export function scrubbedEnv() {
100
100
  * and there is nothing left for `IS_SANDBOX` to weaken. In every other mode the
101
101
  * scrub stands, which is what the comment on `ENV_ALLOWLIST` has always meant.
102
102
  */
103
- function agentEnv(mode) {
103
+ function agentEnv(mode, sessionId) {
104
104
  const env = scrubbedEnv();
105
105
  if (mode === 'full' && process.getuid?.() === 0)
106
106
  env['IS_SANDBOX'] = '1';
107
- return env;
107
+ /**
108
+ * The two numbers of this session's cage (#398 S5).
109
+ *
110
+ * Here and not in `scrubbedEnv()`, which is a FILTER over `process.env` and is
111
+ * shared with the one-shot commit-message run — that run has no session and no
112
+ * cage. `cageSpawn` merges its own variables LAST at the spawn, so nothing
113
+ * here can collide with them.
114
+ */
115
+ return { ...env, ...sessionMemoryEnv(sessionId) };
108
116
  }
109
117
  // Normalized mode → Claude permission mode (session-5 plan §2). `full` is the
110
118
  // owner's explicit call (2026-07-24): "same as Claude works now, we don't
@@ -161,6 +169,7 @@ const MODE_TO_PERMISSION = {
161
169
  function systemAppendFor(spec) {
162
170
  const pushBanned = spec.gitPolicy?.agentPushBan !== false;
163
171
  const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
172
+ const memoryLine = sessionMemoryPromptLine(spec.sessionId);
164
173
  return [
165
174
  'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
166
175
  'Rules:',
@@ -183,6 +192,11 @@ function systemAppendFor(spec) {
183
192
  '- The user is not in a terminal, but they DO answer: when you need a decision, use the AskUserQuestion tool. It is rendered as a card in the DevBridge dashboard and the call waits — however long it takes — until a human answers it. Only ask in plain text if the tool is unavailable.',
184
193
  '- Never decide for the user when you asked them a question. If the tool comes back saying the question was withdrawn, stop and wait rather than guessing.',
185
194
  '- Never print secrets (tokens, API keys, private keys) in your output.',
195
+ // #398 S5: the cage's numbers, said in words. Half of the incident this
196
+ // came from was an agent raising its own heap twice — 4 GB, then 6 GB,
197
+ // against a wall of 4296 MB — because nothing had ever told it there was a
198
+ // wall. Absent on a machine with no cage: there is nothing to promise.
199
+ ...(memoryLine === null ? [] : [memoryLine]),
186
200
  // #361 п. 5 — only where the folder is shared. Layer 1 asks about these
187
201
  // commands anyway; this is so the agent learns the rule before a card.
188
202
  ...(spec.workMode === 'DIRECT' ? [DIRECT_BRANCH_RULE] : []),
@@ -517,7 +531,7 @@ class ClaudeSession {
517
531
  this.mcpConfigFile = mcpConfigFile;
518
532
  const options = {
519
533
  cwd: spec.cwd,
520
- env: agentEnv(this.mode),
534
+ env: agentEnv(this.mode, spec.sessionId),
521
535
  // Empty while `USE_BUNDLED_CLAUDE` — the SDK keeps resolving its own
522
536
  // bundled binary, exactly as before. After C3 this pins the system
523
537
  // `claude`, which is the file the card measures and the button installs.
@@ -683,6 +697,11 @@ class ClaudeSession {
683
697
  // `systemd-run --scope` execs into the same pid and nice survives `exec`,
684
698
  // so this still lands on the CLI itself.
685
699
  lowerPriority(child.pid);
700
+ // Which process in this cage is the agent (#403). The stall mechanism may
701
+ // stop a command; it may never stop the agent, and it tells them apart by
702
+ // this rather than by «its parent is outside the scope» — an MCP server
703
+ // whose launcher exited looks the same by that rule.
704
+ noteSessionAgentPid(this.spec.sessionId, child.pid);
686
705
  // Read `Result` and clear the unit once the process is gone. Only an
687
706
  // `exit` listener: stdout belongs to the SDK, and attaching a reader to it
688
707
  // here would put the stream in flowing mode and steal the conversation.
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { log } from '../log.js';
3
3
  import { lowerPriority } from '../process-priority.js';
4
- import { cageSpawn, killedBeforeExec, releaseSessionScope } from '../session-cage.js';
4
+ import { cageSpawn, killedBeforeExec, noteSessionAgentPid, releaseSessionScope, } from '../session-cage.js';
5
5
  export class RpcError extends Error {
6
6
  code;
7
7
  method;
@@ -59,6 +59,11 @@ export class AppServerClient {
59
59
  // renices the app-server it turns into. On a machine with no cage this is
60
60
  // the only containment there is.
61
61
  lowerPriority(this.child.pid);
62
+ // Which process in this cage is the agent (#403). The stall mechanism may
63
+ // stop a command; it may never stop the agent, and it tells them apart by
64
+ // this rather than by «its parent is outside the scope» — an MCP server
65
+ // whose launcher exited looks the same by that rule.
66
+ noteSessionAgentPid(opts.sessionId, this.child.pid);
62
67
  this.child.stdout.setEncoding('utf8');
63
68
  this.child.stdout.on('data', (chunk) => {
64
69
  this.sawOutput = true;
@@ -1,7 +1,7 @@
1
1
  import { AsyncQueue } from '../async-queue.js';
2
2
  import { log } from '../log.js';
3
3
  import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
4
- import { memoryDeathSentence } from '../session-cage.js';
4
+ import { memoryDeathSentence, sessionMemoryEnv, sessionMemoryPromptLine } from '../session-cage.js';
5
5
  import { RUNNER_VERSION } from '../version.js';
6
6
  import { repairCodexAuth } from './codex-home.js';
7
7
  import { AppServerClient, asRecord, num, RpcError, RpcTimeoutError, str, } from './codex-protocol.js';
@@ -72,6 +72,7 @@ const MODE_POLICY = {
72
72
  function systemAppendFor(spec) {
73
73
  const pushBanned = spec.gitPolicy?.agentPushBan !== false;
74
74
  const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
75
+ const memoryLine = sessionMemoryPromptLine(spec.sessionId);
75
76
  return [
76
77
  'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
77
78
  'Rules:',
@@ -84,6 +85,11 @@ function systemAppendFor(spec) {
84
85
  '- If DevBridge MCP tools are available and the task mentions tickets: fetch the ticket first, set its status to IN_PROGRESS when you start and READY_FOR_REVIEW when your implementation is complete, and leave a short summary comment.',
85
86
  '- The user is not in a terminal: if you need a decision, use your question tool or ask in plain text and end your turn.',
86
87
  '- Never print secrets (tokens, API keys, private keys) in your output.',
88
+ // #398 S5: the cage's numbers, said in words. Half of the incident this
89
+ // came from was an agent raising its own heap twice — 4 GB, then 6 GB,
90
+ // against a wall of 4296 MB — because nothing had ever told it there was a
91
+ // wall. Absent on a machine with no cage: there is nothing to promise.
92
+ ...(memoryLine === null ? [] : [memoryLine]),
87
93
  // #361 п. 5 — only where the folder is shared. Layer 1 asks about these
88
94
  // commands anyway; this is so the agent learns the rule before a card.
89
95
  ...(spec.workMode === 'DIRECT' ? [DIRECT_BRANCH_RULE] : []),
@@ -209,7 +215,10 @@ class CodexSession {
209
215
  this.effort = spec.effort;
210
216
  this.repairHome = deps.repairHome ?? (deps.codexHome ? null : repairCodexAuth);
211
217
  const wiring = {
212
- env: scrubbedEnv(home.path),
218
+ // The cage's two numbers ride along (#398 S5). Built here rather than
219
+ // inside `scrubbedEnv`, which has no access to the session — and merged
220
+ // BEFORE `cageSpawn`'s own variables, which win any collision.
221
+ env: { ...scrubbedEnv(home.path), ...sessionMemoryEnv(spec.sessionId) },
213
222
  onNotification: (method, params) => this.onNotification(method, params),
214
223
  onServerRequest: (request) => this.onServerRequest(request),
215
224
  onExit: (info) => this.onExit(info),
@@ -1,3 +1,4 @@
1
+ import { sessionMemoryFor } from '../session-cage.js';
1
2
  export const AGENT_MODES = ['ask', 'plan', 'auto', 'full'];
2
3
  export function isAgentMode(value) {
3
4
  return typeof value === 'string' && AGENT_MODES.includes(value);
@@ -61,6 +62,19 @@ export function policyContextFor(spec, mode) {
61
62
  // gives every absent field its safe reading, and an object assembled here
62
63
  // with three of the four would be a fourth place to get a polarity wrong.
63
64
  ...(spec.gitPolicy ?? {}),
65
+ /**
66
+ * This session's memory ceiling, read at the moment of the decision rather
67
+ * than captured at start (#398 S5): the wall moves with the machine, and a
68
+ * gate judging against a number from an hour ago would refuse a command the
69
+ * cage would now allow, or allow one it will now stop.
70
+ *
71
+ * Absent on a machine with no cage — and there the gate does not fire at
72
+ * all, because there is no ceiling to be over.
73
+ */
74
+ ...(() => {
75
+ const memory = sessionMemoryFor(spec.sessionId);
76
+ return memory === null ? {} : { sessionMemoryMaxBytes: memory.maxBytes };
77
+ })(),
64
78
  worktreePath: spec.cwd,
65
79
  };
66
80
  }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Who is allowed to change anything on this machine's systemd, and who is only
3
+ * allowed to look (#403).
4
+ *
5
+ * On 10.09.2026 a test run of this package stopped three live agent sessions
6
+ * belonging to other people. Nothing in it was malicious and nothing in it was
7
+ * unusual: `sweepOrphanSessionScopes` treats every scope that is not in its own
8
+ * register as litter, and in a process that is not the daemon that register is
9
+ * empty by construction — so every real cage on the machine looked like litter.
10
+ *
11
+ * A guard for exactly that existed (`supervisor.ts`, «would STOP THE LIVE
12
+ * SESSIONS OF WHOEVER IS WORKING ON IT») and it rested on an assumption about
13
+ * what tests happen to do: no test calls the real `initSessionCage()`. One new
14
+ * test did, and the assumption was gone — silently, because an assumption about
15
+ * other people's future code cannot fail loudly.
16
+ *
17
+ * This module replaces the assumption with a right that has to be taken. Every
18
+ * command that CHANGES something goes through here, the right is claimed once,
19
+ * explicitly, by the entry point that legitimately needs it, and a test process
20
+ * cannot claim it at all. Reading is not restricted to anybody: `doctor`, the
21
+ * machine card and every measurement keep working from any process.
22
+ *
23
+ * Two independent things, deliberately kept apart:
24
+ *
25
+ * - **Knowing** what the cage looks like. Tests need this — half of
26
+ * `session-cage.test.ts` is about the formulas, and they need a machine that
27
+ * answers `scope`. Knowledge is not restricted.
28
+ * - **Acting** on the machine. This is what is taken away, and taking it away
29
+ * from a test process is unconditional.
30
+ */
31
+ /** What a process is allowed to do to this machine's systemd. */
32
+ export type CageRole =
33
+ /** Look only. Every process starts here, including this daemon before it claims. */
34
+ 'none'
35
+ /** May run the cage probe and clean up after it: `pair`, `doctor`. */
36
+ | 'probe'
37
+ /** May also manage the runner's OWN service unit: `install-service`, `doctor --fix`. */
38
+ | 'install'
39
+ /** May also act on session cages: the daemon, and only the daemon. */
40
+ | 'daemon';
41
+ /** Thrown when a process asks for a right it cannot have. Never caught silently. */
42
+ export declare class CageAuthorityError extends Error {
43
+ constructor(message: string);
44
+ }
45
+ /**
46
+ * Take the right to change this machine, once, at a known entry point.
47
+ *
48
+ * Called from `index.ts` and nowhere else. A test process is refused
49
+ * unconditionally and loudly: the throw is the point, because a test that
50
+ * genuinely needs to reach the machine is a test that has to be rewritten, not
51
+ * a case to be accommodated.
52
+ */
53
+ export declare function claimCageAuthority(claimed: Exclude<CageRole, 'none'>): void;
54
+ /** What this process may do right now. */
55
+ export declare function cageAuthority(): CageRole;
56
+ /**
57
+ * Give the right back.
58
+ *
59
+ * Exists for the tests of THIS module and for a daemon that is shutting down;
60
+ * production has no other reason to call it. It cannot hand a right to anybody,
61
+ * so it is safe wherever it is called from.
62
+ */
63
+ export declare function releaseCageAuthority(): void;
64
+ /**
65
+ * May this exact call go through? Answers without doing anything, so callers
66
+ * that would rather degrade than throw can ask first.
67
+ */
68
+ export declare function maySystemctl(args: readonly string[], forRole?: CageRole): boolean;
69
+ /** May this process start a new cage (`systemd-run --scope`)? */
70
+ export declare function mayStartScope(unit: string, forRole?: CageRole): boolean;
71
+ /** May this process send signals to processes it did not spawn? */
72
+ export declare function maySignalProcesses(forRole?: CageRole): boolean;
73
+ /**
74
+ * May this process stop, reset or re-limit the cages of sessions?
75
+ *
76
+ * Asked BEFORE the work rather than left to the door below it, wherever a
77
+ * refusal would otherwise be swallowed: the orphan sweep catches every failed
78
+ * `systemctl` on purpose (a failed scope has nothing to stop), so without this
79
+ * it would report units as removed that it never touched.
80
+ */
81
+ export declare function mayActOnSessionCages(forRole?: CageRole): boolean;
82
+ /**
83
+ * Run `systemctl --user`, if this process is allowed to.
84
+ *
85
+ * The door is INSIDE the real executor rather than in front of the functions
86
+ * that call it, and that is deliberate: a guard in front of `sweepOrphan…` and
87
+ * `releaseSessionScope` would have to be repeated at every new call site, and
88
+ * the one that got forgotten would be the one that mattered.
89
+ */
90
+ export declare function runSystemctl(args: readonly string[], options?: {
91
+ timeout?: number;
92
+ }): Promise<{
93
+ stdout: string;
94
+ stderr: string;
95
+ }>;
96
+ /**
97
+ * Run `systemd-run --user`, if this process is allowed to.
98
+ *
99
+ * `--version` is a read and goes through unchecked; anything that would create
100
+ * a unit is checked against the name it would create.
101
+ */
102
+ export declare function runSystemdRun(args: readonly string[], options?: {
103
+ timeout?: number;
104
+ env?: NodeJS.ProcessEnv;
105
+ }): Promise<{
106
+ stdout: string;
107
+ stderr: string;
108
+ }>;
109
+ /**
110
+ * Signal a process, if this process is allowed to.
111
+ *
112
+ * The same door, for the other way of ending somebody's work. A stall that
113
+ * takes the biggest command out of a session is a `process.kill` and nothing
114
+ * else, so a test process reaching this line would be the incident again with a
115
+ * different verb.
116
+ */
117
+ export declare function killProcess(pid: number, signal: NodeJS.Signals): void;
118
+ //# sourceMappingURL=cage-authority.d.ts.map
@@ -0,0 +1,241 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { systemdUserEnv } from './environment.js';
4
+ import { log } from './log.js';
5
+ const execFileAsync = promisify(execFile);
6
+ /** Thrown when a process asks for a right it cannot have. Never caught silently. */
7
+ export class CageAuthorityError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = 'CageAuthorityError';
11
+ }
12
+ }
13
+ let role = 'none';
14
+ /**
15
+ * Unit-name prefixes each role may act on.
16
+ *
17
+ * A verb alone is not enough. `daemon` may stop a session cage and must not be
18
+ * able to stop `user@0.service`, and the difference is the target, not the
19
+ * verb — so the check is «this verb, on these units», and an unrecognised unit
20
+ * is refused even to the daemon.
21
+ */
22
+ const TARGETS = {
23
+ none: [],
24
+ probe: ['devbridge-cage-probe-'],
25
+ install: ['devbridge-cage-probe-', 'devbridge-runner'],
26
+ // `verify` needs no prefix of its own: it cages its run through the same
27
+ // `cageSpawn`, so its unit is a `devbridge-session-verify-…` too.
28
+ daemon: ['devbridge-cage-probe-', 'devbridge-runner', 'devbridge-session-'],
29
+ };
30
+ /**
31
+ * Verbs that only read. Everything else is a change, INCLUDING a verb this list
32
+ * has never heard of.
33
+ *
34
+ * The default direction is the whole point: a systemd that grows a new
35
+ * destructive verb, or a caller that reaches for one nobody thought about, must
36
+ * come out refused rather than allowed. The cost of the mistake is asymmetric —
37
+ * a refused read is a broken report, a permitted stop is somebody's work.
38
+ */
39
+ const READ_VERBS = new Set([
40
+ 'show',
41
+ 'show-environment',
42
+ 'cat',
43
+ 'status',
44
+ 'list-units',
45
+ 'list-unit-files',
46
+ 'list-dependencies',
47
+ 'list-jobs',
48
+ 'is-active',
49
+ 'is-enabled',
50
+ 'is-failed',
51
+ 'is-system-running',
52
+ ]);
53
+ /**
54
+ * Verbs that change something but name no unit — they act on the manager.
55
+ *
56
+ * `daemon-reload` is the one that matters: the runner ships a drop-in and has
57
+ * to make systemd read it. It is a change, so `none` may not do it, and it has
58
+ * no target to check.
59
+ */
60
+ const MANAGER_VERBS = new Set(['daemon-reload', 'daemon-reexec']);
61
+ /** Is this process one where acting on the machine can never be right? */
62
+ function isTestProcess() {
63
+ const env = process.env;
64
+ return (env['VITEST'] !== undefined ||
65
+ env['VITEST_WORKER_ID'] !== undefined ||
66
+ env['NODE_ENV'] === 'test' ||
67
+ env['DEVBRIDGE_NO_SYSTEMD'] === '1' ||
68
+ '__vitest_worker__' in globalThis);
69
+ }
70
+ /**
71
+ * Take the right to change this machine, once, at a known entry point.
72
+ *
73
+ * Called from `index.ts` and nowhere else. A test process is refused
74
+ * unconditionally and loudly: the throw is the point, because a test that
75
+ * genuinely needs to reach the machine is a test that has to be rewritten, not
76
+ * a case to be accommodated.
77
+ */
78
+ export function claimCageAuthority(claimed) {
79
+ if (isTestProcess()) {
80
+ throw new CageAuthorityError(`a test process may not claim systemd authority (asked for «${claimed}»). ` +
81
+ 'Inject the executor the code under test takes, or assert on the arguments instead.');
82
+ }
83
+ if (role !== 'none' && role !== claimed) {
84
+ log.warn('cage authority: the claimed role changed', { from: role, to: claimed });
85
+ }
86
+ role = claimed;
87
+ }
88
+ /** What this process may do right now. */
89
+ export function cageAuthority() {
90
+ return role;
91
+ }
92
+ /**
93
+ * Give the right back.
94
+ *
95
+ * Exists for the tests of THIS module and for a daemon that is shutting down;
96
+ * production has no other reason to call it. It cannot hand a right to anybody,
97
+ * so it is safe wherever it is called from.
98
+ */
99
+ export function releaseCageAuthority() {
100
+ role = 'none';
101
+ }
102
+ /** The verb of a `systemctl` call, ignoring the flags around it. */
103
+ function verbOf(args) {
104
+ for (const arg of args) {
105
+ if (arg.startsWith('-'))
106
+ continue;
107
+ return arg;
108
+ }
109
+ return null;
110
+ }
111
+ /**
112
+ * The units a call names, as opposed to the flags and property assignments
113
+ * around them.
114
+ *
115
+ * `set-property --runtime devbridge-session-x.scope MemoryHigh=123` names one
116
+ * unit; the two after it are a flag and a property. Anything with an `=` in it
117
+ * is an assignment, not a unit — systemd's own rule.
118
+ */
119
+ function targetsOf(args) {
120
+ const verb = verbOf(args);
121
+ if (verb === null)
122
+ return [];
123
+ const rest = args.slice(args.indexOf(verb) + 1);
124
+ return rest.filter((arg) => !arg.startsWith('-') && !arg.includes('='));
125
+ }
126
+ /** Does this unit name belong to something the role is allowed to touch? */
127
+ function allowedTarget(unit, forRole) {
128
+ return TARGETS[forRole].some((prefix) => unit.startsWith(prefix));
129
+ }
130
+ /**
131
+ * May this exact call go through? Answers without doing anything, so callers
132
+ * that would rather degrade than throw can ask first.
133
+ */
134
+ export function maySystemctl(args, forRole = role) {
135
+ const verb = verbOf(args);
136
+ // A call with no verb reads the manager's own state (`systemctl --version`).
137
+ if (verb === null)
138
+ return true;
139
+ if (READ_VERBS.has(verb))
140
+ return true;
141
+ if (forRole === 'none')
142
+ return false;
143
+ if (MANAGER_VERBS.has(verb))
144
+ return true;
145
+ const targets = targetsOf(args);
146
+ // A change with no named unit is a change to everything: `systemctl --user
147
+ // stop` with a glob that expanded to nothing still is not ours to make.
148
+ if (targets.length === 0)
149
+ return false;
150
+ return targets.every((unit) => allowedTarget(unit, forRole));
151
+ }
152
+ /** May this process start a new cage (`systemd-run --scope`)? */
153
+ export function mayStartScope(unit, forRole = role) {
154
+ if (forRole === 'none')
155
+ return false;
156
+ return allowedTarget(unit, forRole);
157
+ }
158
+ /** May this process send signals to processes it did not spawn? */
159
+ export function maySignalProcesses(forRole = role) {
160
+ return forRole === 'daemon';
161
+ }
162
+ /**
163
+ * May this process stop, reset or re-limit the cages of sessions?
164
+ *
165
+ * Asked BEFORE the work rather than left to the door below it, wherever a
166
+ * refusal would otherwise be swallowed: the orphan sweep catches every failed
167
+ * `systemctl` on purpose (a failed scope has nothing to stop), so without this
168
+ * it would report units as removed that it never touched.
169
+ */
170
+ export function mayActOnSessionCages(forRole = role) {
171
+ return forRole === 'daemon';
172
+ }
173
+ function refuse(what, detail) {
174
+ /**
175
+ * Loud, always, and at `error` — a refusal here means somebody built a path
176
+ * to the machine that was not meant to exist, and the whole value of this
177
+ * module is that such a path cannot pass unnoticed.
178
+ */
179
+ log.error('cage authority: refused a command that would change this machine', {
180
+ role,
181
+ what,
182
+ ...detail,
183
+ });
184
+ return new CageAuthorityError(`this process (role «${role}») may not run: ${what}. ` +
185
+ 'Only the daemon acts on session cages; everything else may read.');
186
+ }
187
+ /**
188
+ * Run `systemctl --user`, if this process is allowed to.
189
+ *
190
+ * The door is INSIDE the real executor rather than in front of the functions
191
+ * that call it, and that is deliberate: a guard in front of `sweepOrphan…` and
192
+ * `releaseSessionScope` would have to be repeated at every new call site, and
193
+ * the one that got forgotten would be the one that mattered.
194
+ */
195
+ export async function runSystemctl(args, options = {}) {
196
+ if (!maySystemctl(args)) {
197
+ throw refuse(`systemctl --user ${args.join(' ')}`, { args: [...args] });
198
+ }
199
+ const { stdout, stderr } = await execFileAsync('systemctl', ['--user', ...args], {
200
+ timeout: options.timeout ?? 15_000,
201
+ env: systemdUserEnv(),
202
+ });
203
+ return { stdout, stderr };
204
+ }
205
+ /**
206
+ * Run `systemd-run --user`, if this process is allowed to.
207
+ *
208
+ * `--version` is a read and goes through unchecked; anything that would create
209
+ * a unit is checked against the name it would create.
210
+ */
211
+ export async function runSystemdRun(args, options = {}) {
212
+ const versionOnly = args.length === 1 && args[0] === '--version';
213
+ if (!versionOnly) {
214
+ const unitFlag = args.find((arg) => arg.startsWith('--unit='));
215
+ const unit = unitFlag === undefined ? '' : unitFlag.slice('--unit='.length);
216
+ if (unit === '' || !mayStartScope(unit)) {
217
+ throw refuse(`systemd-run --user ${args.join(' ')}`, { unit });
218
+ }
219
+ }
220
+ const env = options.env ?? (versionOnly ? undefined : systemdUserEnv());
221
+ const { stdout, stderr } = await execFileAsync('systemd-run', [...args], {
222
+ timeout: options.timeout ?? 30_000,
223
+ ...(env === undefined ? {} : { env }),
224
+ });
225
+ return { stdout, stderr };
226
+ }
227
+ /**
228
+ * Signal a process, if this process is allowed to.
229
+ *
230
+ * The same door, for the other way of ending somebody's work. A stall that
231
+ * takes the biggest command out of a session is a `process.kill` and nothing
232
+ * else, so a test process reaching this line would be the incident again with a
233
+ * different verb.
234
+ */
235
+ export function killProcess(pid, signal) {
236
+ if (!maySignalProcesses()) {
237
+ throw refuse(`kill -${signal} ${pid}`, { pid, signal });
238
+ }
239
+ process.kill(pid, signal);
240
+ }
241
+ //# sourceMappingURL=cage-authority.js.map
package/dist/config.d.ts CHANGED
@@ -41,11 +41,79 @@ declare const ConfigSchema: z.ZodObject<{
41
41
  auth?: "link" | "own" | undefined;
42
42
  }>>;
43
43
  limits: z.ZodOptional<z.ZodObject<{
44
- max_sessions: z.ZodNumber;
44
+ /**
45
+ * OPTIONAL since 0.58.0, and the change is a bug fix rather than a
46
+ * loosening.
47
+ *
48
+ * `loadConfig()` uses `.parse`, not `safeParse`, and a ZodError from it
49
+ * reaches `main().catch` and exits 1 — under systemd that is a restart
50
+ * loop. So while this field was required, a machine owner who opened
51
+ * `config.toml` to add any OTHER key under `[limits]` and did not happen
52
+ * to have this one would have taken their dev server down, and `doctor`
53
+ * could not have told them why: it dies on the same parse.
54
+ *
55
+ * The default is unchanged — absence means «the API's number decides»,
56
+ * which is what `get maxSessions()` already did.
57
+ */
58
+ max_sessions: z.ZodOptional<z.ZodNumber>;
59
+ /**
60
+ * #398 S6: the emergency switch back to the pre-0.58.0 behaviour.
61
+ *
62
+ * `false` puts every session on the fixed `max(pot / 3, 2 GiB)` share
63
+ * that shipped in 0.55.0, computed once at daemon start and never moved.
64
+ * It exists because this formula travels to dev servers nobody here has
65
+ * ever seen, and the two previous memory rules were both reasonable on
66
+ * the author's machine and destructive somewhere else. A machine owner
67
+ * who has to get work done tonight needs a way back that does not involve
68
+ * downgrading the runner.
69
+ */
70
+ adaptive_memory: z.ZodOptional<z.ZodBoolean>;
71
+ /**
72
+ * The guarantee, in megabytes — what a session on this machine will not
73
+ * have taken away.
74
+ *
75
+ * The default is 2048, sized over the 1571 MB peak measured for a
76
+ * workspace `pnpm typecheck`. Lower it on a machine that runs lighter
77
+ * work and wants more sessions; raise it on one that runs heavier.
78
+ */
79
+ session_memory_min: z.ZodOptional<z.ZodNumber>;
80
+ /**
81
+ * A ceiling on the ceiling, in megabytes — the most any single session
82
+ * may be allowed to grow to, whatever the machine has free.
83
+ *
84
+ * For the owner who wants agents to leave room for something the runner
85
+ * cannot see (a build they run by hand, a database that spikes).
86
+ */
87
+ session_memory_max: z.ZodOptional<z.ZodNumber>;
88
+ /**
89
+ * How long a session that has stopped moving is given before its biggest
90
+ * command is stopped, in seconds. Default 180 (decision D6).
91
+ */
92
+ memory_stall_grace_sec: z.ZodOptional<z.ZodNumber>;
93
+ /**
94
+ * What happens when that time runs out: `stop-command` (the default) or
95
+ * `report-only`.
96
+ *
97
+ * `report-only` is for the owner who would rather have a session stand
98
+ * still than have a command stopped under it. It changes nothing else —
99
+ * the deadline still runs and the strip still shows it, so the person can
100
+ * act; only DevBridge stops acting on their behalf.
101
+ */
102
+ memory_stall_action: z.ZodOptional<z.ZodEnum<["stop-command", "report-only"]>>;
45
103
  }, "strip", z.ZodTypeAny, {
46
- max_sessions: number;
104
+ max_sessions?: number | undefined;
105
+ adaptive_memory?: boolean | undefined;
106
+ session_memory_min?: number | undefined;
107
+ session_memory_max?: number | undefined;
108
+ memory_stall_grace_sec?: number | undefined;
109
+ memory_stall_action?: "stop-command" | "report-only" | undefined;
47
110
  }, {
48
- max_sessions: number;
111
+ max_sessions?: number | undefined;
112
+ adaptive_memory?: boolean | undefined;
113
+ session_memory_min?: number | undefined;
114
+ session_memory_max?: number | undefined;
115
+ memory_stall_grace_sec?: number | undefined;
116
+ memory_stall_action?: "stop-command" | "report-only" | undefined;
49
117
  }>>;
50
118
  /**
51
119
  * Session 14: the machine owner's veto over running project recipes.
@@ -126,7 +194,12 @@ declare const ConfigSchema: z.ZodObject<{
126
194
  auth: "link" | "own";
127
195
  } | undefined;
128
196
  limits?: {
129
- max_sessions: number;
197
+ max_sessions?: number | undefined;
198
+ adaptive_memory?: boolean | undefined;
199
+ session_memory_min?: number | undefined;
200
+ session_memory_max?: number | undefined;
201
+ memory_stall_grace_sec?: number | undefined;
202
+ memory_stall_action?: "stop-command" | "report-only" | undefined;
130
203
  } | undefined;
131
204
  verify?: {
132
205
  enabled: boolean;
@@ -155,7 +228,12 @@ declare const ConfigSchema: z.ZodObject<{
155
228
  auth?: "link" | "own" | undefined;
156
229
  } | undefined;
157
230
  limits?: {
158
- max_sessions: number;
231
+ max_sessions?: number | undefined;
232
+ adaptive_memory?: boolean | undefined;
233
+ session_memory_min?: number | undefined;
234
+ session_memory_max?: number | undefined;
235
+ memory_stall_grace_sec?: number | undefined;
236
+ memory_stall_action?: "stop-command" | "report-only" | undefined;
159
237
  } | undefined;
160
238
  verify?: {
161
239
  enabled?: boolean | undefined;