@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/adapters/claude.js +23 -4
- package/dist/adapters/codex-protocol.js +6 -1
- package/dist/adapters/codex.js +11 -2
- package/dist/adapters/questions.d.ts +15 -0
- package/dist/adapters/questions.js +32 -0
- package/dist/adapters/types.js +14 -0
- package/dist/cage-authority.d.ts +118 -0
- package/dist/cage-authority.js +241 -0
- package/dist/config.d.ts +83 -5
- package/dist/config.js +59 -1
- package/dist/daemon-lock.d.ts +43 -0
- package/dist/daemon-lock.js +107 -0
- package/dist/host-load.d.ts +9 -0
- package/dist/host-load.js +9 -0
- package/dist/index.js +222 -20
- package/dist/policy.d.ts +9 -0
- package/dist/policy.js +68 -0
- package/dist/protocol.d.ts +43 -27
- package/dist/recipe-schema.d.ts +12 -12
- package/dist/self-update.js +22 -1
- package/dist/service-unit.d.ts +35 -3
- package/dist/service-unit.js +82 -5
- package/dist/session-allocator.d.ts +259 -0
- package/dist/session-allocator.js +492 -0
- package/dist/session-cage.d.ts +229 -2
- package/dist/session-cage.js +590 -40
- package/dist/session-limits.d.ts +71 -0
- package/dist/session-limits.js +93 -0
- package/dist/session-stall.d.ts +353 -0
- package/dist/session-stall.js +760 -0
- package/dist/supervisor.d.ts +235 -33
- package/dist/supervisor.js +1178 -265
- package/dist/systemd-memory.js +2 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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
|
-
|
|
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
|
*
|
package/dist/adapters/types.js
CHANGED
|
@@ -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
|