@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.
@@ -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),
@@ -28,6 +28,21 @@ export declare function mirrorOptions(questions: AgentQuestion[]): string[];
28
28
  export declare function answerValue(answer: AgentQuestionAnswer): string;
29
29
  /** One short line for the resolved card: «Postgres · Auth, Search». */
30
30
  export declare function answerSummary(answers: AgentQuestionAnswer[]): string;
31
+ /**
32
+ * The same answer as something to SAY, when the card that asked is gone (#401).
33
+ *
34
+ * Not `answerSummary`: that one is a label for a resolved card, so it is
35
+ * clipped to an option's width and drops the notes. This is the person's reply
36
+ * being handed to the agent as an ordinary message, and nothing they typed may
37
+ * be shortened away on the path. The questions themselves cannot be named —
38
+ * their text lived in the process that asked and is gone with it — so the
39
+ * answer is given as the words it was made of, which is what the person
40
+ * actually chose.
41
+ *
42
+ * Empty when there is nothing in it: the caller uses that to tell «the reply
43
+ * was lost» from «there was no reply to lose».
44
+ */
45
+ export declare function answersAsMessage(answers: AgentQuestionAnswer[]): string;
31
46
  /**
32
47
  * The «discuss instead» exit.
33
48
  *
@@ -52,6 +52,38 @@ export function answerValue(answer) {
52
52
  export function answerSummary(answers) {
53
53
  return clip(answers.map(answerValue).filter(Boolean).join(' · '), OPTION_TEXT_LIMIT);
54
54
  }
55
+ /**
56
+ * The same answer as something to SAY, when the card that asked is gone (#401).
57
+ *
58
+ * Not `answerSummary`: that one is a label for a resolved card, so it is
59
+ * clipped to an option's width and drops the notes. This is the person's reply
60
+ * being handed to the agent as an ordinary message, and nothing they typed may
61
+ * be shortened away on the path. The questions themselves cannot be named —
62
+ * their text lived in the process that asked and is gone with it — so the
63
+ * answer is given as the words it was made of, which is what the person
64
+ * actually chose.
65
+ *
66
+ * Empty when there is nothing in it: the caller uses that to tell «the reply
67
+ * was lost» from «there was no reply to lose».
68
+ */
69
+ export function answersAsMessage(answers) {
70
+ return clip(answers
71
+ .map((answer) => {
72
+ const value = answerValue(answer);
73
+ const notes = answer.notes?.trim();
74
+ if (value && notes)
75
+ return `${value} (${notes})`;
76
+ return value || notes || '';
77
+ })
78
+ .filter(Boolean)
79
+ .join('\n'),
80
+ // The same ceiling `discussMessage` uses, and for a harder reason: this text
81
+ // becomes a feed event, and an event over the API's size limit is replaced
82
+ // wholesale by a truncation marker. The frame this is built from allows four
83
+ // answers of sixteen 2 000-char values plus a 10 000-char custom field —
84
+ // ~176 KB — and the runner takes that frame straight off the socket.
85
+ 8_000);
86
+ }
55
87
  /**
56
88
  * The «discuss instead» exit.
57
89
  *
@@ -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