@ricsam/r5d-worker 0.0.122 → 0.0.123
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/README.md +6 -3
- package/dist/cjs/command-launcher.cjs +76 -2
- package/dist/cjs/control-command-policy.cjs +177 -0
- package/dist/cjs/main.cjs +53 -9
- package/dist/cjs/package.json +1 -1
- package/dist/mjs/command-launcher.mjs +75 -2
- package/dist/mjs/control-command-policy.mjs +146 -0
- package/dist/mjs/main.mjs +58 -9
- package/dist/mjs/package.json +1 -1
- package/dist/types/command-launcher.d.ts +42 -0
- package/dist/types/control-command-policy.d.ts +53 -0
- package/dist/types/main.d.ts +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,13 +81,16 @@ Operators may set `R5D_WORKER_COMMAND_LAUNCHER` to a JSON argv array, such as `[
|
|
|
81
81
|
|
|
82
82
|
The launcher owns admission, OS isolation, budgets, and diagnostic evidence. Its JSONL protocol uses `{requestId, method, ...fields}` requests and `{requestId, result}` or `{requestId, error}` replies:
|
|
83
83
|
|
|
84
|
-
- `initialize` returns `{}` once ready; failure prevents command admission.
|
|
85
|
-
- `acquire` receives `{id, kind, workspaceEffect?, sessionId?}` and returns `{argvPrefix: string[]}`. The worker prepends this argv to the command or terminal shell without interpreting it.
|
|
84
|
+
- `initialize` returns `{}` once ready; failure prevents command admission. A launcher that bounds concurrency may return `{capacity: {generalSlots, utilitySlots, controlSlots, controlRuntimeMs, queueTimeoutMs}}` so the worker can report lane totals.
|
|
85
|
+
- `acquire` receives `{id, kind, workspaceEffect?, commandClass?, sessionId?}` and returns `{argvPrefix: string[]}`. The worker prepends this argv to the command or terminal shell without interpreting it. `commandClass: "control"` is sent only for an `exec_start` whose argv is one plain `r5dctl` coordination command (validated against the worker's own allowlist, spawned without a shell, resolved from the worker's own `PATH`, with code-loading environment variables removed, and stopped by the worker after `controlRuntimeMs`); a launcher may give such commands a small reserved lane.
|
|
86
|
+
- `status` returns `{capacity, lanes: {general|utility|control: {slots, active: [{id, sessionId?, since}], queued: [...]}}}`; optional, for operator inspection.
|
|
86
87
|
- `cancel` receives `{id}` and irreversibly revokes queued or acquired work. `release` receives `{id}` and acknowledges only after cleanup. Both must be idempotent, including release before a delayed acquisition response.
|
|
87
88
|
- `diagnose` receives `{id}` and returns `{message?: string}` with launcher-specific evidence. The worker does not infer a memory failure from an exit signal.
|
|
88
89
|
|
|
89
90
|
Requests must be processed concurrently so cancellation and cleanup cannot wait behind queued admission. Controller RPCs are bounded (10 seconds, or 90 seconds for acquisition); external policy must reject excessive queue waits within that bound. Controller loss or protocol failure stops command admission, preserves uncertain outcomes for recovery, reaps ordinary children, and restarts the runtime through its supervisor. It never falls back to direct spawning. Shutdown closes controller stdin; the controller must reap its jobs before exiting.
|
|
90
91
|
|
|
91
|
-
Cancellation is checked again immediately before spawn. Transient disconnects preserve commands and terminals; expiry of the five-minute execution lease cancels agent work and closes terminals. File operations, communication recovery, and cancellation do not enter launcher admission. Internal worker maintenance subprocesses are also outside this hook. Deployment-specific limits and service protection are documented in [Worker deployment resources](../../docs/worker-deployment-resources.md).
|
|
92
|
+
Cancellation is checked again immediately before spawn. Transient disconnects preserve commands and terminals; expiry of the five-minute execution lease cancels agent work and closes terminals. File operations, communication recovery, and cancellation do not enter launcher admission. Internal worker maintenance subprocesses are also outside this hook. The worker reports its own view of every lane (holders and queue, by run id) to the server with `capacity_report` whenever an admission changes; the platform exposes it through `r5dctl ps list` and `r5dctl workspace status`. Deployment-specific limits and service protection are documented in [Worker deployment resources](../../docs/worker-deployment-resources.md).
|
|
92
93
|
|
|
93
94
|
When the connected `r5d-browser` requests a port forward, the worker opens each relayed connection only to `127.0.0.1` on the requested worker port. Browser-side and worker-side ports may differ. The worker never opens a public listener, and a disconnected worker leaves the browser's long-lived mapping unavailable until the same worker label reconnects.
|
|
95
|
+
|
|
96
|
+
Control execution requires an initialized launcher that explicitly declares its control capacity (or direct execution without an external launcher), an updated worker, and r5dctl installed on the worker's own PATH. Missing control support is an update error; a control request never falls back to a general shell. Deploy the app and worker support assets, update the CLI/worker, and reconnect before resuming paused work.
|
|
@@ -21,12 +21,20 @@ __export(command_launcher_exports, {
|
|
|
21
21
|
CommandLaunchCancelledError: () => CommandLaunchCancelledError,
|
|
22
22
|
CommandLauncherUnavailableError: () => CommandLauncherUnavailableError,
|
|
23
23
|
WorkerCommandLauncher: () => WorkerCommandLauncher,
|
|
24
|
+
commandLaunchLane: () => commandLaunchLane,
|
|
24
25
|
commandLauncherEnvironment: () => commandLauncherEnvironment,
|
|
25
26
|
createWorkerCommandLauncher: () => createWorkerCommandLauncher,
|
|
26
27
|
parseCommandLauncher: () => parseCommandLauncher
|
|
27
28
|
});
|
|
28
29
|
module.exports = __toCommonJS(command_launcher_exports);
|
|
29
30
|
var import_node_child_process = require("node:child_process");
|
|
31
|
+
function commandLaunchLane(request) {
|
|
32
|
+
if (request.commandClass === "control") {
|
|
33
|
+
if (request.kind !== "exec_start") throw new Error("Control commands must be exec_start launches");
|
|
34
|
+
return "control";
|
|
35
|
+
}
|
|
36
|
+
return request.kind === "exec" && request.workspaceEffect === "none" ? "utility" : "general";
|
|
37
|
+
}
|
|
30
38
|
class CommandLauncherUnavailableError extends Error {
|
|
31
39
|
constructor(message, cause) {
|
|
32
40
|
super(message, { cause });
|
|
@@ -114,12 +122,14 @@ class WorkerCommandLauncher {
|
|
|
114
122
|
initialized = false;
|
|
115
123
|
closePromise;
|
|
116
124
|
sequence = 0;
|
|
125
|
+
capacity;
|
|
117
126
|
async initialize() {
|
|
118
127
|
this.assertHealthy();
|
|
119
128
|
if (this.initialized) return;
|
|
120
129
|
if (this.child) {
|
|
121
130
|
try {
|
|
122
|
-
await this.rpc("initialize");
|
|
131
|
+
const result = await this.rpc("initialize");
|
|
132
|
+
this.capacity = parseCapacity(result.capacity);
|
|
123
133
|
} catch (error) {
|
|
124
134
|
this.fail(error instanceof Error ? error : new Error(String(error)));
|
|
125
135
|
await this.close();
|
|
@@ -131,19 +141,63 @@ class WorkerCommandLauncher {
|
|
|
131
141
|
get activeIds() {
|
|
132
142
|
return [...this.launches.keys()];
|
|
133
143
|
}
|
|
144
|
+
/** The lane totals the launcher declared at initialization; undefined for a direct worker or an older launcher. */
|
|
145
|
+
get declaredCapacity() {
|
|
146
|
+
return this.capacity;
|
|
147
|
+
}
|
|
148
|
+
get supportsControlLane() {
|
|
149
|
+
return this.initialized && (this.mode === "direct" || (this.capacity?.controlSlots ?? 0) > 0);
|
|
150
|
+
}
|
|
151
|
+
/** Current admissions per lane, for the worker's capacity report. */
|
|
152
|
+
capacityReport() {
|
|
153
|
+
const bounded = this.child !== void 0;
|
|
154
|
+
const lanes = {
|
|
155
|
+
general: { slots: bounded ? this.capacity?.generalSlots ?? null : null, active: [], queued: [] },
|
|
156
|
+
utility: { slots: bounded ? this.capacity?.utilitySlots ?? null : null, active: [], queued: [] },
|
|
157
|
+
control: { slots: bounded ? this.capacity?.controlSlots ?? null : null, active: [], queued: [] }
|
|
158
|
+
};
|
|
159
|
+
for (const record of this.launches.values()) {
|
|
160
|
+
if (record.released) continue;
|
|
161
|
+
const entry = {
|
|
162
|
+
id: record.request.id,
|
|
163
|
+
...record.request.sessionId ? { sessionId: record.request.sessionId } : {},
|
|
164
|
+
since: record.since
|
|
165
|
+
};
|
|
166
|
+
(record.phase === "active" ? lanes[record.lane].active : lanes[record.lane].queued).push(entry);
|
|
167
|
+
}
|
|
168
|
+
return { bounded, controlRuntimeMs: bounded ? this.capacity?.controlRuntimeMs ?? null : null, lanes };
|
|
169
|
+
}
|
|
170
|
+
notifyCapacityChange() {
|
|
171
|
+
try {
|
|
172
|
+
this.options.onCapacityChange?.();
|
|
173
|
+
} catch {
|
|
174
|
+
}
|
|
175
|
+
}
|
|
134
176
|
async acquire(request) {
|
|
135
177
|
this.assertHealthy();
|
|
136
178
|
if (!this.initialized) throw new Error("Command launcher is not initialized");
|
|
137
179
|
request.assertAdmission();
|
|
138
180
|
if (this.launches.has(request.id)) throw new Error(`Duplicate command launch identity: ${request.id}`);
|
|
181
|
+
const lane = commandLaunchLane(request);
|
|
182
|
+
if (lane === "control" && !this.supportsControlLane)
|
|
183
|
+
throw new Error("Update the worker command launcher before running control commands");
|
|
139
184
|
let rejectAdmission;
|
|
140
185
|
const cancellation = new Promise((_resolve, reject) => {
|
|
141
186
|
rejectAdmission = reject;
|
|
142
187
|
});
|
|
143
188
|
void cancellation.catch(() => {
|
|
144
189
|
});
|
|
145
|
-
const record = {
|
|
190
|
+
const record = {
|
|
191
|
+
request,
|
|
192
|
+
lane,
|
|
193
|
+
phase: this.child ? "queued" : "active",
|
|
194
|
+
since: (/* @__PURE__ */ new Date()).toISOString(),
|
|
195
|
+
revoked: false,
|
|
196
|
+
released: false,
|
|
197
|
+
rejectAdmission
|
|
198
|
+
};
|
|
146
199
|
this.launches.set(request.id, record);
|
|
200
|
+
this.notifyCapacityChange();
|
|
147
201
|
let prefix = [];
|
|
148
202
|
let admissionTimer;
|
|
149
203
|
try {
|
|
@@ -169,6 +223,9 @@ class WorkerCommandLauncher {
|
|
|
169
223
|
}
|
|
170
224
|
}, 100);
|
|
171
225
|
prefix = await Promise.race([acquisition, cancellation]);
|
|
226
|
+
record.phase = "active";
|
|
227
|
+
record.since = (/* @__PURE__ */ new Date()).toISOString();
|
|
228
|
+
this.notifyCapacityChange();
|
|
172
229
|
}
|
|
173
230
|
request.assertAdmission();
|
|
174
231
|
this.assertHealthy();
|
|
@@ -268,6 +325,7 @@ class WorkerCommandLauncher {
|
|
|
268
325
|
}
|
|
269
326
|
record.released = true;
|
|
270
327
|
this.launches.delete(record.request.id);
|
|
328
|
+
this.notifyCapacityChange();
|
|
271
329
|
})();
|
|
272
330
|
}
|
|
273
331
|
assertHealthy() {
|
|
@@ -329,6 +387,21 @@ class WorkerCommandLauncher {
|
|
|
329
387
|
return this.failure;
|
|
330
388
|
}
|
|
331
389
|
}
|
|
390
|
+
function parseCapacity(value) {
|
|
391
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
392
|
+
const candidate = value;
|
|
393
|
+
const numbers = ["generalSlots", "utilitySlots", "controlSlots", "controlRuntimeMs", "queueTimeoutMs"];
|
|
394
|
+
if (!numbers.every((key) => typeof candidate[key] === "number" && Number.isSafeInteger(candidate[key]) && candidate[key] >= 0)) {
|
|
395
|
+
throw new Error("Command launcher initialize returned an invalid capacity");
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
generalSlots: candidate.generalSlots,
|
|
399
|
+
utilitySlots: candidate.utilitySlots,
|
|
400
|
+
controlSlots: candidate.controlSlots,
|
|
401
|
+
controlRuntimeMs: candidate.controlRuntimeMs,
|
|
402
|
+
queueTimeoutMs: candidate.queueTimeoutMs
|
|
403
|
+
};
|
|
404
|
+
}
|
|
332
405
|
async function createWorkerCommandLauncher(options = {}) {
|
|
333
406
|
const launcher = new WorkerCommandLauncher({ ...options, argv: parseCommandLauncher(options.configuration) });
|
|
334
407
|
await launcher.initialize();
|
|
@@ -339,6 +412,7 @@ async function createWorkerCommandLauncher(options = {}) {
|
|
|
339
412
|
CommandLaunchCancelledError,
|
|
340
413
|
CommandLauncherUnavailableError,
|
|
341
414
|
WorkerCommandLauncher,
|
|
415
|
+
commandLaunchLane,
|
|
342
416
|
commandLauncherEnvironment,
|
|
343
417
|
createWorkerCommandLauncher,
|
|
344
418
|
parseCommandLauncher
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var control_command_policy_exports = {};
|
|
20
|
+
__export(control_command_policy_exports, {
|
|
21
|
+
CONTROL_COMMAND_MAX_ARGV: () => CONTROL_COMMAND_MAX_ARGV,
|
|
22
|
+
CONTROL_COMMAND_MAX_LENGTH: () => CONTROL_COMMAND_MAX_LENGTH,
|
|
23
|
+
CONTROL_COMMAND_PROGRAM: () => CONTROL_COMMAND_PROGRAM,
|
|
24
|
+
CONTROL_COMMAND_RUNTIME_MS: () => CONTROL_COMMAND_RUNTIME_MS,
|
|
25
|
+
assertControlCommandArgv: () => assertControlCommandArgv,
|
|
26
|
+
classifyControlCommand: () => classifyControlCommand,
|
|
27
|
+
controlCommandEnvironment: () => controlCommandEnvironment,
|
|
28
|
+
resolveControlCommandExecutable: () => resolveControlCommandExecutable
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(control_command_policy_exports);
|
|
31
|
+
const CONTROL_COMMAND_PROGRAM = "r5dctl";
|
|
32
|
+
const CONTROL_COMMAND_RUNTIME_MS = 12e4;
|
|
33
|
+
const CONTROL_COMMAND_MAX_ARGV = 64;
|
|
34
|
+
const CONTROL_COMMAND_MAX_LENGTH = 4096;
|
|
35
|
+
const CONTROL_GLOBAL_FLAGS_WITH_VALUE = /* @__PURE__ */ new Set(["-p", "--project", "-s", "--session", "-b", "--branch"]);
|
|
36
|
+
const CONTROL_GLOBAL_FLAGS = /* @__PURE__ */ new Set(["--json", "-h", "--help", "-v", "--version"]);
|
|
37
|
+
const REFUSED_ANYWHERE = /* @__PURE__ */ new Set(["-f", "--follow", "--watch", "--base-url", "--config", "--token", "--api-key"]);
|
|
38
|
+
const CONTROL_VERBS = {
|
|
39
|
+
session: /* @__PURE__ */ new Set(["status", "stop", "prompt", "claim", "claims", "process-log"]),
|
|
40
|
+
sessions: /* @__PURE__ */ new Set(["recent"]),
|
|
41
|
+
describe: /* @__PURE__ */ new Set(["project", "branch", "session", "branch-operation", "branch-deletion"]),
|
|
42
|
+
get: /* @__PURE__ */ new Set(["projects", "branches", "sessions", "envs", "env"]),
|
|
43
|
+
conversation: /* @__PURE__ */ new Set(["overview", "inspect-node", "inspect-work"]),
|
|
44
|
+
ps: /* @__PURE__ */ new Set(["list", "history", "inspect", "stop"]),
|
|
45
|
+
workspace: /* @__PURE__ */ new Set(["status"]),
|
|
46
|
+
auth: /* @__PURE__ */ new Set(["status"]),
|
|
47
|
+
create: /* @__PURE__ */ new Set(["session"]),
|
|
48
|
+
update: /* @__PURE__ */ new Set(["session"]),
|
|
49
|
+
delete: /* @__PURE__ */ new Set(["session"]),
|
|
50
|
+
"answer-questions": null,
|
|
51
|
+
"answer-env-request": null
|
|
52
|
+
};
|
|
53
|
+
const CODE_LOADING_ENV_PATTERN = /^(NODE_OPTIONS|NODE_PATH|NODE_REPL_EXTERNAL_MODULE|BUN_OPTIONS|BUN_CONFIG_[A-Z_]+|LD_PRELOAD|LD_LIBRARY_PATH|LD_AUDIT|DYLD_[A-Z_]+|PATH|PATHEXT|PYTHONPATH|PERL5OPT|RUBYOPT|SHELL|ENV|BASH_ENV)$/i;
|
|
54
|
+
const BARE_WORD = /^[A-Za-z0-9_@%+=:,./-]+$/;
|
|
55
|
+
const DOUBLE_QUOTED_FORBIDDEN = /[$`\\!]/;
|
|
56
|
+
function tokenizePlainInvocation(command) {
|
|
57
|
+
if (command.length > CONTROL_COMMAND_MAX_LENGTH) return { reason: "command is longer than the control lane allows" };
|
|
58
|
+
if (/[\r\n]/.test(command)) return { reason: "multi-line commands are shell scripts" };
|
|
59
|
+
const argv = [];
|
|
60
|
+
let index = 0;
|
|
61
|
+
while (index < command.length) {
|
|
62
|
+
const char = command[index];
|
|
63
|
+
if (char === " " || char === " ") {
|
|
64
|
+
index += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
let word = "";
|
|
68
|
+
let quotedParts = 0;
|
|
69
|
+
let bareParts = 0;
|
|
70
|
+
while (index < command.length && command[index] !== " " && command[index] !== " ") {
|
|
71
|
+
const current = command[index];
|
|
72
|
+
if (current === "'") {
|
|
73
|
+
const end = command.indexOf("'", index + 1);
|
|
74
|
+
if (end < 0) return { reason: "unterminated single quote" };
|
|
75
|
+
word += command.slice(index + 1, end);
|
|
76
|
+
quotedParts += 1;
|
|
77
|
+
index = end + 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (current === '"') {
|
|
81
|
+
const end = command.indexOf('"', index + 1);
|
|
82
|
+
if (end < 0) return { reason: "unterminated double quote" };
|
|
83
|
+
const inner = command.slice(index + 1, end);
|
|
84
|
+
if (DOUBLE_QUOTED_FORBIDDEN.test(inner)) return { reason: "double-quoted text would be expanded by the shell" };
|
|
85
|
+
word += inner;
|
|
86
|
+
quotedParts += 1;
|
|
87
|
+
index = end + 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
let bare = "";
|
|
91
|
+
while (index < command.length && !/[\s'"]/.test(command[index])) {
|
|
92
|
+
bare += command[index];
|
|
93
|
+
index += 1;
|
|
94
|
+
}
|
|
95
|
+
if (!BARE_WORD.test(bare)) return { reason: `shell syntax in "${bare}" is not part of a plain invocation` };
|
|
96
|
+
word += bare;
|
|
97
|
+
bareParts += 1;
|
|
98
|
+
}
|
|
99
|
+
if (quotedParts + bareParts > 1) return { reason: "adjacent quoted and bare text is shell concatenation" };
|
|
100
|
+
argv.push(word);
|
|
101
|
+
if (argv.length > CONTROL_COMMAND_MAX_ARGV) return { reason: "too many arguments for a control command" };
|
|
102
|
+
}
|
|
103
|
+
if (argv.length === 0) return { reason: "empty command" };
|
|
104
|
+
return { argv };
|
|
105
|
+
}
|
|
106
|
+
function assertControlCommandArgv(argv) {
|
|
107
|
+
if (argv.length === 0 || argv.length > CONTROL_COMMAND_MAX_ARGV) throw new Error("Control commands are a single r5dctl invocation");
|
|
108
|
+
if (argv[0] !== CONTROL_COMMAND_PROGRAM) throw new Error(`Control commands must start with ${CONTROL_COMMAND_PROGRAM}`);
|
|
109
|
+
if (argv.reduce((length, argument) => length + (typeof argument === "string" ? argument.length : 0) + 1, 0) > CONTROL_COMMAND_MAX_LENGTH)
|
|
110
|
+
throw new Error("Control command arguments exceed the length limit");
|
|
111
|
+
for (const argument of argv) {
|
|
112
|
+
if (typeof argument !== "string" || argument.includes("\0") || /[\r\n]/.test(argument)) throw new Error("Control command arguments must be plain text");
|
|
113
|
+
if (REFUSED_ANYWHERE.has(argument.split("=", 1)[0])) throw new Error(`Control command option is not allowed (${argument.split("=", 1)[0]})`);
|
|
114
|
+
if (argument === "--") throw new Error("Control commands cannot use `--`");
|
|
115
|
+
}
|
|
116
|
+
let position = 1;
|
|
117
|
+
while (position < argv.length) {
|
|
118
|
+
const argument = argv[position];
|
|
119
|
+
if (CONTROL_GLOBAL_FLAGS.has(argument)) {
|
|
120
|
+
position += 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (CONTROL_GLOBAL_FLAGS_WITH_VALUE.has(argument)) {
|
|
124
|
+
if (position + 1 >= argv.length) throw new Error(`Missing value for ${argument}`);
|
|
125
|
+
position += 2;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (argument.startsWith("-")) throw new Error(`Global flag ${argument} is not allowed in a control command`);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
const verb = argv[position];
|
|
132
|
+
if (verb === void 0) throw new Error("Control commands need a coordination verb (for example `r5dctl ps list`)");
|
|
133
|
+
const allowed = Object.hasOwn(CONTROL_VERBS, verb) ? CONTROL_VERBS[verb] : void 0;
|
|
134
|
+
if (allowed === void 0) throw new Error(`r5dctl ${verb} is not a coordination command`);
|
|
135
|
+
if (allowed === null) return;
|
|
136
|
+
const subject = argv[position + 1];
|
|
137
|
+
if (subject === void 0 || !allowed.has(subject)) {
|
|
138
|
+
throw new Error(`r5dctl ${verb}${subject ? ` ${subject}` : ""} is not a coordination command`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function resolveControlCommandExecutable(trustedPath, which, program = CONTROL_COMMAND_PROGRAM) {
|
|
142
|
+
const resolved = trustedPath ? which(program, { PATH: trustedPath }) : null;
|
|
143
|
+
if (!resolved) throw new Error(`${program} is not installed on the worker's trusted PATH; control commands are unavailable until it is`);
|
|
144
|
+
return resolved;
|
|
145
|
+
}
|
|
146
|
+
function controlCommandEnvironment(environment, trustedPath) {
|
|
147
|
+
const result = {};
|
|
148
|
+
for (const [key, value] of Object.entries(environment)) {
|
|
149
|
+
if (value === void 0 || CODE_LOADING_ENV_PATTERN.test(key)) continue;
|
|
150
|
+
result[key] = value;
|
|
151
|
+
}
|
|
152
|
+
if (trustedPath !== void 0) result.PATH = trustedPath;
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
function classifyControlCommand(command) {
|
|
156
|
+
const tokenized = tokenizePlainInvocation(command.trim());
|
|
157
|
+
if ("reason" in tokenized) return { control: false, reason: tokenized.reason };
|
|
158
|
+
const { argv } = tokenized;
|
|
159
|
+
if (argv[0] !== CONTROL_COMMAND_PROGRAM) return { control: false, reason: "not an r5dctl invocation" };
|
|
160
|
+
try {
|
|
161
|
+
assertControlCommandArgv(argv);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return { control: false, reason: error instanceof Error ? error.message : String(error) };
|
|
164
|
+
}
|
|
165
|
+
return { control: true, argv };
|
|
166
|
+
}
|
|
167
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
168
|
+
0 && (module.exports = {
|
|
169
|
+
CONTROL_COMMAND_MAX_ARGV,
|
|
170
|
+
CONTROL_COMMAND_MAX_LENGTH,
|
|
171
|
+
CONTROL_COMMAND_PROGRAM,
|
|
172
|
+
CONTROL_COMMAND_RUNTIME_MS,
|
|
173
|
+
assertControlCommandArgv,
|
|
174
|
+
classifyControlCommand,
|
|
175
|
+
controlCommandEnvironment,
|
|
176
|
+
resolveControlCommandExecutable
|
|
177
|
+
});
|
package/dist/cjs/main.cjs
CHANGED
|
@@ -75,6 +75,7 @@ var import_cli_update = require("./cli-update.cjs");
|
|
|
75
75
|
var import_git_process_environment = require("./git-process-environment.cjs");
|
|
76
76
|
var import_process_tree = require("./process-tree.cjs");
|
|
77
77
|
var import_project_checkout_garbage = require("./project-checkout-garbage.cjs");
|
|
78
|
+
var import_control_command_policy = require("./control-command-policy.cjs");
|
|
78
79
|
var import_pty_output_coalescer = require("./pty-output-coalescer.cjs");
|
|
79
80
|
var import_port_forward_client = require("./port-forward-client.cjs");
|
|
80
81
|
var import_registry_auth = require("./registry-auth.cjs");
|
|
@@ -158,6 +159,26 @@ const PTY_FOREGROUND_POLL_MS = 1e3;
|
|
|
158
159
|
const PTY_FOREGROUND_IDLE_ENABLED = process.env.R5D_PTY_FOREGROUND_IDLE !== "0";
|
|
159
160
|
const PTY_TMP_PATH_PREFIX = "r5d-worker-tmp://";
|
|
160
161
|
let workerCommandLauncher;
|
|
162
|
+
const trustedControlPath = process.env.PATH;
|
|
163
|
+
let capacityReportTimer;
|
|
164
|
+
let capacityReportSocket = null;
|
|
165
|
+
let lastCapacityReport;
|
|
166
|
+
function scheduleCapacityReport(force = false) {
|
|
167
|
+
if (force) lastCapacityReport = void 0;
|
|
168
|
+
if (capacityReportTimer) return;
|
|
169
|
+
capacityReportTimer = setTimeout(() => {
|
|
170
|
+
capacityReportTimer = void 0;
|
|
171
|
+
const ws = capacityReportSocket;
|
|
172
|
+
if (!ws || ws.readyState !== WebSocket.OPEN || !workerCommandLauncher) return;
|
|
173
|
+
const capacity = { ...workerCommandLauncher.capacityReport(), reportedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
174
|
+
const { reportedAt: _reportedAt, ...comparable } = capacity;
|
|
175
|
+
const serialized = JSON.stringify(comparable);
|
|
176
|
+
if (serialized === lastCapacityReport) return;
|
|
177
|
+
lastCapacityReport = serialized;
|
|
178
|
+
sendWorkerMessage(ws, { type: "capacity_report", capacity });
|
|
179
|
+
}, 100);
|
|
180
|
+
capacityReportTimer.unref?.();
|
|
181
|
+
}
|
|
161
182
|
const activeProcesses = /* @__PURE__ */ new Map();
|
|
162
183
|
const credentialBearingProcessGroups = /* @__PURE__ */ new Map();
|
|
163
184
|
const credentialBearingProcessGroupTargets = /* @__PURE__ */ new Map();
|
|
@@ -2775,6 +2796,10 @@ function cancelWorkerCommandLaunch(resources) {
|
|
|
2775
2796
|
});
|
|
2776
2797
|
}
|
|
2777
2798
|
async function acquireWorkerCommandLaunch(ws, message, assertAdmission) {
|
|
2799
|
+
if (message.type === "exec_start" && message.commandClass === "control") {
|
|
2800
|
+
(0, import_control_command_policy.assertControlCommandArgv)(message.argv);
|
|
2801
|
+
if (message.interactive) throw new Error("Control commands cannot be interactive");
|
|
2802
|
+
}
|
|
2778
2803
|
const id = message.type === "pty_open" ? `pty:${message.ptyId}` : message.runId;
|
|
2779
2804
|
const operationId = message.type === "pty_open" ? id : `exec:${id}`;
|
|
2780
2805
|
sendWorkerMessage(ws, { type: "heartbeat_lease", operationId, active: true });
|
|
@@ -2783,6 +2808,7 @@ async function acquireWorkerCommandLaunch(ws, message, assertAdmission) {
|
|
|
2783
2808
|
id,
|
|
2784
2809
|
kind: message.type,
|
|
2785
2810
|
..."workspaceEffect" in message && message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {},
|
|
2811
|
+
...message.type === "exec_start" && message.commandClass === "control" ? { commandClass: "control" } : {},
|
|
2786
2812
|
..."sessionId" in message && message.sessionId ? { sessionId: message.sessionId } : {},
|
|
2787
2813
|
assertAdmission: () => {
|
|
2788
2814
|
assertAdmission();
|
|
@@ -2930,8 +2956,17 @@ async function executeStreamingCommand(input) {
|
|
|
2930
2956
|
});
|
|
2931
2957
|
const cwd = resolveCommandCwd(input.resolvedTarget.rootPath, input.message.cwd);
|
|
2932
2958
|
const interactive = input.message.interactive === true;
|
|
2959
|
+
const control = input.message.commandClass === "control";
|
|
2960
|
+
let argv = input.message.argv;
|
|
2961
|
+
let environment = workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, targetProcessEnv2]);
|
|
2962
|
+
if (control) {
|
|
2963
|
+
(0, import_control_command_policy.assertControlCommandArgv)(argv);
|
|
2964
|
+
if (interactive) throw new Error("Control commands cannot be interactive");
|
|
2965
|
+
argv = [(0, import_control_command_policy.resolveControlCommandExecutable)(trustedControlPath, (program, options) => Bun.which(program, options)), ...argv.slice(1)];
|
|
2966
|
+
environment = (0, import_control_command_policy.controlCommandEnvironment)(environment, trustedControlPath);
|
|
2967
|
+
}
|
|
2933
2968
|
input.assertAdmission();
|
|
2934
|
-
const subprocess = Bun.spawn(input.resources.wrap(
|
|
2969
|
+
const subprocess = Bun.spawn(input.resources.wrap(argv), {
|
|
2935
2970
|
cwd,
|
|
2936
2971
|
// Without an explicit stdin the process reads /dev/null and interactive
|
|
2937
2972
|
// prompts see immediate EOF; "pipe" keeps stdin open for exec_stdin.
|
|
@@ -2939,7 +2974,7 @@ async function executeStreamingCommand(input) {
|
|
|
2939
2974
|
stdout: "pipe",
|
|
2940
2975
|
stderr: "pipe",
|
|
2941
2976
|
detached: true,
|
|
2942
|
-
env:
|
|
2977
|
+
env: environment
|
|
2943
2978
|
});
|
|
2944
2979
|
spawnedProcess = subprocess;
|
|
2945
2980
|
credentialBearingProcessGroups.set(subprocess.pid, subprocess);
|
|
@@ -2954,12 +2989,13 @@ async function executeStreamingCommand(input) {
|
|
|
2954
2989
|
pid: subprocess.pid,
|
|
2955
2990
|
processGroupId: process.platform === "win32" ? void 0 : subprocess.pid,
|
|
2956
2991
|
credentialId: input.message.credentialId,
|
|
2957
|
-
argv
|
|
2992
|
+
argv,
|
|
2958
2993
|
command: input.message.command,
|
|
2959
2994
|
cwd,
|
|
2960
2995
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2961
2996
|
...interactive ? { interactive: true, stdin: subprocess.stdin } : {},
|
|
2962
|
-
...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
|
|
2997
|
+
...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {},
|
|
2998
|
+
...control ? { commandClass: "control" } : {}
|
|
2963
2999
|
});
|
|
2964
3000
|
settlePreparation();
|
|
2965
3001
|
started = true;
|
|
@@ -2971,7 +3007,8 @@ async function executeStreamingCommand(input) {
|
|
|
2971
3007
|
pid: subprocess.pid,
|
|
2972
3008
|
...process.platform === "win32" ? {} : { processGroupId: subprocess.pid }
|
|
2973
3009
|
});
|
|
2974
|
-
|
|
3010
|
+
const timeoutMs = control ? Math.min(input.message.timeoutMs ?? import_control_command_policy.CONTROL_COMMAND_RUNTIME_MS, import_control_command_policy.CONTROL_COMMAND_RUNTIME_MS) : input.message.timeoutMs;
|
|
3011
|
+
if (timeoutMs) {
|
|
2975
3012
|
timeout = setTimeout(() => {
|
|
2976
3013
|
timedOut = true;
|
|
2977
3014
|
cancelWorkerCommandLaunch(input.resources);
|
|
@@ -2982,7 +3019,7 @@ async function executeStreamingCommand(input) {
|
|
|
2982
3019
|
`
|
|
2983
3020
|
);
|
|
2984
3021
|
});
|
|
2985
|
-
},
|
|
3022
|
+
}, timeoutMs);
|
|
2986
3023
|
}
|
|
2987
3024
|
const [exitCode] = await Promise.all([
|
|
2988
3025
|
subprocess.exited,
|
|
@@ -3145,7 +3182,8 @@ function buildActiveProcessReports() {
|
|
|
3145
3182
|
...active.cwd ? { cwd: active.cwd } : {},
|
|
3146
3183
|
startedAt: active.startedAt,
|
|
3147
3184
|
...active.interactive ? { interactive: true } : {},
|
|
3148
|
-
...active.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
|
|
3185
|
+
...active.workspaceEffect === "none" ? { workspaceEffect: "none" } : {},
|
|
3186
|
+
...active.commandClass ? { commandClass: active.commandClass } : {}
|
|
3149
3187
|
}));
|
|
3150
3188
|
}
|
|
3151
3189
|
function sendActiveProcessReport(ws) {
|
|
@@ -3667,6 +3705,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
3667
3705
|
validateLabel(label);
|
|
3668
3706
|
if (!workerCommandLauncher) {
|
|
3669
3707
|
const launcher = await (0, import_command_launcher.createWorkerCommandLauncher)({
|
|
3708
|
+
onCapacityChange: () => scheduleCapacityReport(),
|
|
3670
3709
|
configuration: process.env.R5D_WORKER_COMMAND_LAUNCHER,
|
|
3671
3710
|
env: (0, import_command_launcher.commandLauncherEnvironment)(),
|
|
3672
3711
|
onFailure: handleWorkerCommandLauncherFailure
|
|
@@ -5854,6 +5893,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
5854
5893
|
workspaceConfigResetToCanonicalV1: true,
|
|
5855
5894
|
projectBranchDeletionFastAckV1: true,
|
|
5856
5895
|
projectBranchDeletionStagedRemovalV1: true,
|
|
5896
|
+
commandControlLaneV1: workerCommandLauncher?.supportsControlLane === true,
|
|
5857
5897
|
projectMirrorLeaseV1: true,
|
|
5858
5898
|
projectMirrorRefsTokensV1: true,
|
|
5859
5899
|
projectBranchWorkingTreeModeV1: true
|
|
@@ -5980,6 +6020,8 @@ async function startWorker(options, projectRuntime = {
|
|
|
5980
6020
|
});
|
|
5981
6021
|
};
|
|
5982
6022
|
if (message.type === "connected") {
|
|
6023
|
+
capacityReportSocket = ws;
|
|
6024
|
+
scheduleCapacityReport(true);
|
|
5983
6025
|
return;
|
|
5984
6026
|
}
|
|
5985
6027
|
if (message.type === "project_mirror_refs_tokens") {
|
|
@@ -6735,8 +6777,10 @@ async function startWorker(options, projectRuntime = {
|
|
|
6735
6777
|
const runCommand = async () => {
|
|
6736
6778
|
try {
|
|
6737
6779
|
const resolvedTarget = resolveMessageTarget(message.target);
|
|
6738
|
-
process.stdout.write(
|
|
6739
|
-
`)
|
|
6780
|
+
process.stdout.write(
|
|
6781
|
+
`[r5d-worker] exec_start ${message.runId}${message.commandClass === "control" ? " (control)" : ""}: ${message.argv.join(" ")}
|
|
6782
|
+
`
|
|
6783
|
+
);
|
|
6740
6784
|
await executeStreamingCommand({
|
|
6741
6785
|
resources,
|
|
6742
6786
|
ws,
|
package/dist/cjs/package.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
function commandLaunchLane(request) {
|
|
3
|
+
if (request.commandClass === "control") {
|
|
4
|
+
if (request.kind !== "exec_start") throw new Error("Control commands must be exec_start launches");
|
|
5
|
+
return "control";
|
|
6
|
+
}
|
|
7
|
+
return request.kind === "exec" && request.workspaceEffect === "none" ? "utility" : "general";
|
|
8
|
+
}
|
|
2
9
|
class CommandLauncherUnavailableError extends Error {
|
|
3
10
|
constructor(message, cause) {
|
|
4
11
|
super(message, { cause });
|
|
@@ -86,12 +93,14 @@ class WorkerCommandLauncher {
|
|
|
86
93
|
initialized = false;
|
|
87
94
|
closePromise;
|
|
88
95
|
sequence = 0;
|
|
96
|
+
capacity;
|
|
89
97
|
async initialize() {
|
|
90
98
|
this.assertHealthy();
|
|
91
99
|
if (this.initialized) return;
|
|
92
100
|
if (this.child) {
|
|
93
101
|
try {
|
|
94
|
-
await this.rpc("initialize");
|
|
102
|
+
const result = await this.rpc("initialize");
|
|
103
|
+
this.capacity = parseCapacity(result.capacity);
|
|
95
104
|
} catch (error) {
|
|
96
105
|
this.fail(error instanceof Error ? error : new Error(String(error)));
|
|
97
106
|
await this.close();
|
|
@@ -103,19 +112,63 @@ class WorkerCommandLauncher {
|
|
|
103
112
|
get activeIds() {
|
|
104
113
|
return [...this.launches.keys()];
|
|
105
114
|
}
|
|
115
|
+
/** The lane totals the launcher declared at initialization; undefined for a direct worker or an older launcher. */
|
|
116
|
+
get declaredCapacity() {
|
|
117
|
+
return this.capacity;
|
|
118
|
+
}
|
|
119
|
+
get supportsControlLane() {
|
|
120
|
+
return this.initialized && (this.mode === "direct" || (this.capacity?.controlSlots ?? 0) > 0);
|
|
121
|
+
}
|
|
122
|
+
/** Current admissions per lane, for the worker's capacity report. */
|
|
123
|
+
capacityReport() {
|
|
124
|
+
const bounded = this.child !== void 0;
|
|
125
|
+
const lanes = {
|
|
126
|
+
general: { slots: bounded ? this.capacity?.generalSlots ?? null : null, active: [], queued: [] },
|
|
127
|
+
utility: { slots: bounded ? this.capacity?.utilitySlots ?? null : null, active: [], queued: [] },
|
|
128
|
+
control: { slots: bounded ? this.capacity?.controlSlots ?? null : null, active: [], queued: [] }
|
|
129
|
+
};
|
|
130
|
+
for (const record of this.launches.values()) {
|
|
131
|
+
if (record.released) continue;
|
|
132
|
+
const entry = {
|
|
133
|
+
id: record.request.id,
|
|
134
|
+
...record.request.sessionId ? { sessionId: record.request.sessionId } : {},
|
|
135
|
+
since: record.since
|
|
136
|
+
};
|
|
137
|
+
(record.phase === "active" ? lanes[record.lane].active : lanes[record.lane].queued).push(entry);
|
|
138
|
+
}
|
|
139
|
+
return { bounded, controlRuntimeMs: bounded ? this.capacity?.controlRuntimeMs ?? null : null, lanes };
|
|
140
|
+
}
|
|
141
|
+
notifyCapacityChange() {
|
|
142
|
+
try {
|
|
143
|
+
this.options.onCapacityChange?.();
|
|
144
|
+
} catch {
|
|
145
|
+
}
|
|
146
|
+
}
|
|
106
147
|
async acquire(request) {
|
|
107
148
|
this.assertHealthy();
|
|
108
149
|
if (!this.initialized) throw new Error("Command launcher is not initialized");
|
|
109
150
|
request.assertAdmission();
|
|
110
151
|
if (this.launches.has(request.id)) throw new Error(`Duplicate command launch identity: ${request.id}`);
|
|
152
|
+
const lane = commandLaunchLane(request);
|
|
153
|
+
if (lane === "control" && !this.supportsControlLane)
|
|
154
|
+
throw new Error("Update the worker command launcher before running control commands");
|
|
111
155
|
let rejectAdmission;
|
|
112
156
|
const cancellation = new Promise((_resolve, reject) => {
|
|
113
157
|
rejectAdmission = reject;
|
|
114
158
|
});
|
|
115
159
|
void cancellation.catch(() => {
|
|
116
160
|
});
|
|
117
|
-
const record = {
|
|
161
|
+
const record = {
|
|
162
|
+
request,
|
|
163
|
+
lane,
|
|
164
|
+
phase: this.child ? "queued" : "active",
|
|
165
|
+
since: (/* @__PURE__ */ new Date()).toISOString(),
|
|
166
|
+
revoked: false,
|
|
167
|
+
released: false,
|
|
168
|
+
rejectAdmission
|
|
169
|
+
};
|
|
118
170
|
this.launches.set(request.id, record);
|
|
171
|
+
this.notifyCapacityChange();
|
|
119
172
|
let prefix = [];
|
|
120
173
|
let admissionTimer;
|
|
121
174
|
try {
|
|
@@ -141,6 +194,9 @@ class WorkerCommandLauncher {
|
|
|
141
194
|
}
|
|
142
195
|
}, 100);
|
|
143
196
|
prefix = await Promise.race([acquisition, cancellation]);
|
|
197
|
+
record.phase = "active";
|
|
198
|
+
record.since = (/* @__PURE__ */ new Date()).toISOString();
|
|
199
|
+
this.notifyCapacityChange();
|
|
144
200
|
}
|
|
145
201
|
request.assertAdmission();
|
|
146
202
|
this.assertHealthy();
|
|
@@ -240,6 +296,7 @@ class WorkerCommandLauncher {
|
|
|
240
296
|
}
|
|
241
297
|
record.released = true;
|
|
242
298
|
this.launches.delete(record.request.id);
|
|
299
|
+
this.notifyCapacityChange();
|
|
243
300
|
})();
|
|
244
301
|
}
|
|
245
302
|
assertHealthy() {
|
|
@@ -301,6 +358,21 @@ class WorkerCommandLauncher {
|
|
|
301
358
|
return this.failure;
|
|
302
359
|
}
|
|
303
360
|
}
|
|
361
|
+
function parseCapacity(value) {
|
|
362
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
363
|
+
const candidate = value;
|
|
364
|
+
const numbers = ["generalSlots", "utilitySlots", "controlSlots", "controlRuntimeMs", "queueTimeoutMs"];
|
|
365
|
+
if (!numbers.every((key) => typeof candidate[key] === "number" && Number.isSafeInteger(candidate[key]) && candidate[key] >= 0)) {
|
|
366
|
+
throw new Error("Command launcher initialize returned an invalid capacity");
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
generalSlots: candidate.generalSlots,
|
|
370
|
+
utilitySlots: candidate.utilitySlots,
|
|
371
|
+
controlSlots: candidate.controlSlots,
|
|
372
|
+
controlRuntimeMs: candidate.controlRuntimeMs,
|
|
373
|
+
queueTimeoutMs: candidate.queueTimeoutMs
|
|
374
|
+
};
|
|
375
|
+
}
|
|
304
376
|
async function createWorkerCommandLauncher(options = {}) {
|
|
305
377
|
const launcher = new WorkerCommandLauncher({ ...options, argv: parseCommandLauncher(options.configuration) });
|
|
306
378
|
await launcher.initialize();
|
|
@@ -310,6 +382,7 @@ export {
|
|
|
310
382
|
CommandLaunchCancelledError,
|
|
311
383
|
CommandLauncherUnavailableError,
|
|
312
384
|
WorkerCommandLauncher,
|
|
385
|
+
commandLaunchLane,
|
|
313
386
|
commandLauncherEnvironment,
|
|
314
387
|
createWorkerCommandLauncher,
|
|
315
388
|
parseCommandLauncher
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const CONTROL_COMMAND_PROGRAM = "r5dctl";
|
|
2
|
+
const CONTROL_COMMAND_RUNTIME_MS = 12e4;
|
|
3
|
+
const CONTROL_COMMAND_MAX_ARGV = 64;
|
|
4
|
+
const CONTROL_COMMAND_MAX_LENGTH = 4096;
|
|
5
|
+
const CONTROL_GLOBAL_FLAGS_WITH_VALUE = /* @__PURE__ */ new Set(["-p", "--project", "-s", "--session", "-b", "--branch"]);
|
|
6
|
+
const CONTROL_GLOBAL_FLAGS = /* @__PURE__ */ new Set(["--json", "-h", "--help", "-v", "--version"]);
|
|
7
|
+
const REFUSED_ANYWHERE = /* @__PURE__ */ new Set(["-f", "--follow", "--watch", "--base-url", "--config", "--token", "--api-key"]);
|
|
8
|
+
const CONTROL_VERBS = {
|
|
9
|
+
session: /* @__PURE__ */ new Set(["status", "stop", "prompt", "claim", "claims", "process-log"]),
|
|
10
|
+
sessions: /* @__PURE__ */ new Set(["recent"]),
|
|
11
|
+
describe: /* @__PURE__ */ new Set(["project", "branch", "session", "branch-operation", "branch-deletion"]),
|
|
12
|
+
get: /* @__PURE__ */ new Set(["projects", "branches", "sessions", "envs", "env"]),
|
|
13
|
+
conversation: /* @__PURE__ */ new Set(["overview", "inspect-node", "inspect-work"]),
|
|
14
|
+
ps: /* @__PURE__ */ new Set(["list", "history", "inspect", "stop"]),
|
|
15
|
+
workspace: /* @__PURE__ */ new Set(["status"]),
|
|
16
|
+
auth: /* @__PURE__ */ new Set(["status"]),
|
|
17
|
+
create: /* @__PURE__ */ new Set(["session"]),
|
|
18
|
+
update: /* @__PURE__ */ new Set(["session"]),
|
|
19
|
+
delete: /* @__PURE__ */ new Set(["session"]),
|
|
20
|
+
"answer-questions": null,
|
|
21
|
+
"answer-env-request": null
|
|
22
|
+
};
|
|
23
|
+
const CODE_LOADING_ENV_PATTERN = /^(NODE_OPTIONS|NODE_PATH|NODE_REPL_EXTERNAL_MODULE|BUN_OPTIONS|BUN_CONFIG_[A-Z_]+|LD_PRELOAD|LD_LIBRARY_PATH|LD_AUDIT|DYLD_[A-Z_]+|PATH|PATHEXT|PYTHONPATH|PERL5OPT|RUBYOPT|SHELL|ENV|BASH_ENV)$/i;
|
|
24
|
+
const BARE_WORD = /^[A-Za-z0-9_@%+=:,./-]+$/;
|
|
25
|
+
const DOUBLE_QUOTED_FORBIDDEN = /[$`\\!]/;
|
|
26
|
+
function tokenizePlainInvocation(command) {
|
|
27
|
+
if (command.length > CONTROL_COMMAND_MAX_LENGTH) return { reason: "command is longer than the control lane allows" };
|
|
28
|
+
if (/[\r\n]/.test(command)) return { reason: "multi-line commands are shell scripts" };
|
|
29
|
+
const argv = [];
|
|
30
|
+
let index = 0;
|
|
31
|
+
while (index < command.length) {
|
|
32
|
+
const char = command[index];
|
|
33
|
+
if (char === " " || char === " ") {
|
|
34
|
+
index += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
let word = "";
|
|
38
|
+
let quotedParts = 0;
|
|
39
|
+
let bareParts = 0;
|
|
40
|
+
while (index < command.length && command[index] !== " " && command[index] !== " ") {
|
|
41
|
+
const current = command[index];
|
|
42
|
+
if (current === "'") {
|
|
43
|
+
const end = command.indexOf("'", index + 1);
|
|
44
|
+
if (end < 0) return { reason: "unterminated single quote" };
|
|
45
|
+
word += command.slice(index + 1, end);
|
|
46
|
+
quotedParts += 1;
|
|
47
|
+
index = end + 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (current === '"') {
|
|
51
|
+
const end = command.indexOf('"', index + 1);
|
|
52
|
+
if (end < 0) return { reason: "unterminated double quote" };
|
|
53
|
+
const inner = command.slice(index + 1, end);
|
|
54
|
+
if (DOUBLE_QUOTED_FORBIDDEN.test(inner)) return { reason: "double-quoted text would be expanded by the shell" };
|
|
55
|
+
word += inner;
|
|
56
|
+
quotedParts += 1;
|
|
57
|
+
index = end + 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
let bare = "";
|
|
61
|
+
while (index < command.length && !/[\s'"]/.test(command[index])) {
|
|
62
|
+
bare += command[index];
|
|
63
|
+
index += 1;
|
|
64
|
+
}
|
|
65
|
+
if (!BARE_WORD.test(bare)) return { reason: `shell syntax in "${bare}" is not part of a plain invocation` };
|
|
66
|
+
word += bare;
|
|
67
|
+
bareParts += 1;
|
|
68
|
+
}
|
|
69
|
+
if (quotedParts + bareParts > 1) return { reason: "adjacent quoted and bare text is shell concatenation" };
|
|
70
|
+
argv.push(word);
|
|
71
|
+
if (argv.length > CONTROL_COMMAND_MAX_ARGV) return { reason: "too many arguments for a control command" };
|
|
72
|
+
}
|
|
73
|
+
if (argv.length === 0) return { reason: "empty command" };
|
|
74
|
+
return { argv };
|
|
75
|
+
}
|
|
76
|
+
function assertControlCommandArgv(argv) {
|
|
77
|
+
if (argv.length === 0 || argv.length > CONTROL_COMMAND_MAX_ARGV) throw new Error("Control commands are a single r5dctl invocation");
|
|
78
|
+
if (argv[0] !== CONTROL_COMMAND_PROGRAM) throw new Error(`Control commands must start with ${CONTROL_COMMAND_PROGRAM}`);
|
|
79
|
+
if (argv.reduce((length, argument) => length + (typeof argument === "string" ? argument.length : 0) + 1, 0) > CONTROL_COMMAND_MAX_LENGTH)
|
|
80
|
+
throw new Error("Control command arguments exceed the length limit");
|
|
81
|
+
for (const argument of argv) {
|
|
82
|
+
if (typeof argument !== "string" || argument.includes("\0") || /[\r\n]/.test(argument)) throw new Error("Control command arguments must be plain text");
|
|
83
|
+
if (REFUSED_ANYWHERE.has(argument.split("=", 1)[0])) throw new Error(`Control command option is not allowed (${argument.split("=", 1)[0]})`);
|
|
84
|
+
if (argument === "--") throw new Error("Control commands cannot use `--`");
|
|
85
|
+
}
|
|
86
|
+
let position = 1;
|
|
87
|
+
while (position < argv.length) {
|
|
88
|
+
const argument = argv[position];
|
|
89
|
+
if (CONTROL_GLOBAL_FLAGS.has(argument)) {
|
|
90
|
+
position += 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (CONTROL_GLOBAL_FLAGS_WITH_VALUE.has(argument)) {
|
|
94
|
+
if (position + 1 >= argv.length) throw new Error(`Missing value for ${argument}`);
|
|
95
|
+
position += 2;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (argument.startsWith("-")) throw new Error(`Global flag ${argument} is not allowed in a control command`);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
const verb = argv[position];
|
|
102
|
+
if (verb === void 0) throw new Error("Control commands need a coordination verb (for example `r5dctl ps list`)");
|
|
103
|
+
const allowed = Object.hasOwn(CONTROL_VERBS, verb) ? CONTROL_VERBS[verb] : void 0;
|
|
104
|
+
if (allowed === void 0) throw new Error(`r5dctl ${verb} is not a coordination command`);
|
|
105
|
+
if (allowed === null) return;
|
|
106
|
+
const subject = argv[position + 1];
|
|
107
|
+
if (subject === void 0 || !allowed.has(subject)) {
|
|
108
|
+
throw new Error(`r5dctl ${verb}${subject ? ` ${subject}` : ""} is not a coordination command`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function resolveControlCommandExecutable(trustedPath, which, program = CONTROL_COMMAND_PROGRAM) {
|
|
112
|
+
const resolved = trustedPath ? which(program, { PATH: trustedPath }) : null;
|
|
113
|
+
if (!resolved) throw new Error(`${program} is not installed on the worker's trusted PATH; control commands are unavailable until it is`);
|
|
114
|
+
return resolved;
|
|
115
|
+
}
|
|
116
|
+
function controlCommandEnvironment(environment, trustedPath) {
|
|
117
|
+
const result = {};
|
|
118
|
+
for (const [key, value] of Object.entries(environment)) {
|
|
119
|
+
if (value === void 0 || CODE_LOADING_ENV_PATTERN.test(key)) continue;
|
|
120
|
+
result[key] = value;
|
|
121
|
+
}
|
|
122
|
+
if (trustedPath !== void 0) result.PATH = trustedPath;
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
function classifyControlCommand(command) {
|
|
126
|
+
const tokenized = tokenizePlainInvocation(command.trim());
|
|
127
|
+
if ("reason" in tokenized) return { control: false, reason: tokenized.reason };
|
|
128
|
+
const { argv } = tokenized;
|
|
129
|
+
if (argv[0] !== CONTROL_COMMAND_PROGRAM) return { control: false, reason: "not an r5dctl invocation" };
|
|
130
|
+
try {
|
|
131
|
+
assertControlCommandArgv(argv);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return { control: false, reason: error instanceof Error ? error.message : String(error) };
|
|
134
|
+
}
|
|
135
|
+
return { control: true, argv };
|
|
136
|
+
}
|
|
137
|
+
export {
|
|
138
|
+
CONTROL_COMMAND_MAX_ARGV,
|
|
139
|
+
CONTROL_COMMAND_MAX_LENGTH,
|
|
140
|
+
CONTROL_COMMAND_PROGRAM,
|
|
141
|
+
CONTROL_COMMAND_RUNTIME_MS,
|
|
142
|
+
assertControlCommandArgv,
|
|
143
|
+
classifyControlCommand,
|
|
144
|
+
controlCommandEnvironment,
|
|
145
|
+
resolveControlCommandExecutable
|
|
146
|
+
};
|
package/dist/mjs/main.mjs
CHANGED
|
@@ -26,6 +26,12 @@ import { installCliUpdate, readInstalledCliVersion } from "./cli-update.mjs";
|
|
|
26
26
|
import { gitTransportSecurityArgs, workerGitProcessEnvironment } from "./git-process-environment.mjs";
|
|
27
27
|
import { terminateProcessTree } from "./process-tree.mjs";
|
|
28
28
|
import { ProjectCheckoutGarbageCollector } from "./project-checkout-garbage.mjs";
|
|
29
|
+
import {
|
|
30
|
+
assertControlCommandArgv,
|
|
31
|
+
CONTROL_COMMAND_RUNTIME_MS,
|
|
32
|
+
controlCommandEnvironment,
|
|
33
|
+
resolveControlCommandExecutable
|
|
34
|
+
} from "./control-command-policy.mjs";
|
|
29
35
|
import { createPtyOutputCoalescer } from "./pty-output-coalescer.mjs";
|
|
30
36
|
import { openWorkerPortForwardRelay } from "./port-forward-client.mjs";
|
|
31
37
|
import {
|
|
@@ -177,6 +183,26 @@ const PTY_FOREGROUND_POLL_MS = 1e3;
|
|
|
177
183
|
const PTY_FOREGROUND_IDLE_ENABLED = process.env.R5D_PTY_FOREGROUND_IDLE !== "0";
|
|
178
184
|
const PTY_TMP_PATH_PREFIX = "r5d-worker-tmp://";
|
|
179
185
|
let workerCommandLauncher;
|
|
186
|
+
const trustedControlPath = process.env.PATH;
|
|
187
|
+
let capacityReportTimer;
|
|
188
|
+
let capacityReportSocket = null;
|
|
189
|
+
let lastCapacityReport;
|
|
190
|
+
function scheduleCapacityReport(force = false) {
|
|
191
|
+
if (force) lastCapacityReport = void 0;
|
|
192
|
+
if (capacityReportTimer) return;
|
|
193
|
+
capacityReportTimer = setTimeout(() => {
|
|
194
|
+
capacityReportTimer = void 0;
|
|
195
|
+
const ws = capacityReportSocket;
|
|
196
|
+
if (!ws || ws.readyState !== WebSocket.OPEN || !workerCommandLauncher) return;
|
|
197
|
+
const capacity = { ...workerCommandLauncher.capacityReport(), reportedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
198
|
+
const { reportedAt: _reportedAt, ...comparable } = capacity;
|
|
199
|
+
const serialized = JSON.stringify(comparable);
|
|
200
|
+
if (serialized === lastCapacityReport) return;
|
|
201
|
+
lastCapacityReport = serialized;
|
|
202
|
+
sendWorkerMessage(ws, { type: "capacity_report", capacity });
|
|
203
|
+
}, 100);
|
|
204
|
+
capacityReportTimer.unref?.();
|
|
205
|
+
}
|
|
180
206
|
const activeProcesses = /* @__PURE__ */ new Map();
|
|
181
207
|
const credentialBearingProcessGroups = /* @__PURE__ */ new Map();
|
|
182
208
|
const credentialBearingProcessGroupTargets = /* @__PURE__ */ new Map();
|
|
@@ -2794,6 +2820,10 @@ function cancelWorkerCommandLaunch(resources) {
|
|
|
2794
2820
|
});
|
|
2795
2821
|
}
|
|
2796
2822
|
async function acquireWorkerCommandLaunch(ws, message, assertAdmission) {
|
|
2823
|
+
if (message.type === "exec_start" && message.commandClass === "control") {
|
|
2824
|
+
assertControlCommandArgv(message.argv);
|
|
2825
|
+
if (message.interactive) throw new Error("Control commands cannot be interactive");
|
|
2826
|
+
}
|
|
2797
2827
|
const id = message.type === "pty_open" ? `pty:${message.ptyId}` : message.runId;
|
|
2798
2828
|
const operationId = message.type === "pty_open" ? id : `exec:${id}`;
|
|
2799
2829
|
sendWorkerMessage(ws, { type: "heartbeat_lease", operationId, active: true });
|
|
@@ -2802,6 +2832,7 @@ async function acquireWorkerCommandLaunch(ws, message, assertAdmission) {
|
|
|
2802
2832
|
id,
|
|
2803
2833
|
kind: message.type,
|
|
2804
2834
|
..."workspaceEffect" in message && message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {},
|
|
2835
|
+
...message.type === "exec_start" && message.commandClass === "control" ? { commandClass: "control" } : {},
|
|
2805
2836
|
..."sessionId" in message && message.sessionId ? { sessionId: message.sessionId } : {},
|
|
2806
2837
|
assertAdmission: () => {
|
|
2807
2838
|
assertAdmission();
|
|
@@ -2949,8 +2980,17 @@ async function executeStreamingCommand(input) {
|
|
|
2949
2980
|
});
|
|
2950
2981
|
const cwd = resolveCommandCwd(input.resolvedTarget.rootPath, input.message.cwd);
|
|
2951
2982
|
const interactive = input.message.interactive === true;
|
|
2983
|
+
const control = input.message.commandClass === "control";
|
|
2984
|
+
let argv = input.message.argv;
|
|
2985
|
+
let environment = workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, targetProcessEnv2]);
|
|
2986
|
+
if (control) {
|
|
2987
|
+
assertControlCommandArgv(argv);
|
|
2988
|
+
if (interactive) throw new Error("Control commands cannot be interactive");
|
|
2989
|
+
argv = [resolveControlCommandExecutable(trustedControlPath, (program, options) => Bun.which(program, options)), ...argv.slice(1)];
|
|
2990
|
+
environment = controlCommandEnvironment(environment, trustedControlPath);
|
|
2991
|
+
}
|
|
2952
2992
|
input.assertAdmission();
|
|
2953
|
-
const subprocess = Bun.spawn(input.resources.wrap(
|
|
2993
|
+
const subprocess = Bun.spawn(input.resources.wrap(argv), {
|
|
2954
2994
|
cwd,
|
|
2955
2995
|
// Without an explicit stdin the process reads /dev/null and interactive
|
|
2956
2996
|
// prompts see immediate EOF; "pipe" keeps stdin open for exec_stdin.
|
|
@@ -2958,7 +2998,7 @@ async function executeStreamingCommand(input) {
|
|
|
2958
2998
|
stdout: "pipe",
|
|
2959
2999
|
stderr: "pipe",
|
|
2960
3000
|
detached: true,
|
|
2961
|
-
env:
|
|
3001
|
+
env: environment
|
|
2962
3002
|
});
|
|
2963
3003
|
spawnedProcess = subprocess;
|
|
2964
3004
|
credentialBearingProcessGroups.set(subprocess.pid, subprocess);
|
|
@@ -2973,12 +3013,13 @@ async function executeStreamingCommand(input) {
|
|
|
2973
3013
|
pid: subprocess.pid,
|
|
2974
3014
|
processGroupId: process.platform === "win32" ? void 0 : subprocess.pid,
|
|
2975
3015
|
credentialId: input.message.credentialId,
|
|
2976
|
-
argv
|
|
3016
|
+
argv,
|
|
2977
3017
|
command: input.message.command,
|
|
2978
3018
|
cwd,
|
|
2979
3019
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2980
3020
|
...interactive ? { interactive: true, stdin: subprocess.stdin } : {},
|
|
2981
|
-
...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
|
|
3021
|
+
...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {},
|
|
3022
|
+
...control ? { commandClass: "control" } : {}
|
|
2982
3023
|
});
|
|
2983
3024
|
settlePreparation();
|
|
2984
3025
|
started = true;
|
|
@@ -2990,7 +3031,8 @@ async function executeStreamingCommand(input) {
|
|
|
2990
3031
|
pid: subprocess.pid,
|
|
2991
3032
|
...process.platform === "win32" ? {} : { processGroupId: subprocess.pid }
|
|
2992
3033
|
});
|
|
2993
|
-
|
|
3034
|
+
const timeoutMs = control ? Math.min(input.message.timeoutMs ?? CONTROL_COMMAND_RUNTIME_MS, CONTROL_COMMAND_RUNTIME_MS) : input.message.timeoutMs;
|
|
3035
|
+
if (timeoutMs) {
|
|
2994
3036
|
timeout = setTimeout(() => {
|
|
2995
3037
|
timedOut = true;
|
|
2996
3038
|
cancelWorkerCommandLaunch(input.resources);
|
|
@@ -3001,7 +3043,7 @@ async function executeStreamingCommand(input) {
|
|
|
3001
3043
|
`
|
|
3002
3044
|
);
|
|
3003
3045
|
});
|
|
3004
|
-
},
|
|
3046
|
+
}, timeoutMs);
|
|
3005
3047
|
}
|
|
3006
3048
|
const [exitCode] = await Promise.all([
|
|
3007
3049
|
subprocess.exited,
|
|
@@ -3164,7 +3206,8 @@ function buildActiveProcessReports() {
|
|
|
3164
3206
|
...active.cwd ? { cwd: active.cwd } : {},
|
|
3165
3207
|
startedAt: active.startedAt,
|
|
3166
3208
|
...active.interactive ? { interactive: true } : {},
|
|
3167
|
-
...active.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
|
|
3209
|
+
...active.workspaceEffect === "none" ? { workspaceEffect: "none" } : {},
|
|
3210
|
+
...active.commandClass ? { commandClass: active.commandClass } : {}
|
|
3168
3211
|
}));
|
|
3169
3212
|
}
|
|
3170
3213
|
function sendActiveProcessReport(ws) {
|
|
@@ -3686,6 +3729,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
3686
3729
|
validateLabel(label);
|
|
3687
3730
|
if (!workerCommandLauncher) {
|
|
3688
3731
|
const launcher = await createWorkerCommandLauncher({
|
|
3732
|
+
onCapacityChange: () => scheduleCapacityReport(),
|
|
3689
3733
|
configuration: process.env.R5D_WORKER_COMMAND_LAUNCHER,
|
|
3690
3734
|
env: commandLauncherEnvironment(),
|
|
3691
3735
|
onFailure: handleWorkerCommandLauncherFailure
|
|
@@ -5873,6 +5917,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
5873
5917
|
workspaceConfigResetToCanonicalV1: true,
|
|
5874
5918
|
projectBranchDeletionFastAckV1: true,
|
|
5875
5919
|
projectBranchDeletionStagedRemovalV1: true,
|
|
5920
|
+
commandControlLaneV1: workerCommandLauncher?.supportsControlLane === true,
|
|
5876
5921
|
projectMirrorLeaseV1: true,
|
|
5877
5922
|
projectMirrorRefsTokensV1: true,
|
|
5878
5923
|
projectBranchWorkingTreeModeV1: true
|
|
@@ -5999,6 +6044,8 @@ async function startWorker(options, projectRuntime = {
|
|
|
5999
6044
|
});
|
|
6000
6045
|
};
|
|
6001
6046
|
if (message.type === "connected") {
|
|
6047
|
+
capacityReportSocket = ws;
|
|
6048
|
+
scheduleCapacityReport(true);
|
|
6002
6049
|
return;
|
|
6003
6050
|
}
|
|
6004
6051
|
if (message.type === "project_mirror_refs_tokens") {
|
|
@@ -6754,8 +6801,10 @@ async function startWorker(options, projectRuntime = {
|
|
|
6754
6801
|
const runCommand = async () => {
|
|
6755
6802
|
try {
|
|
6756
6803
|
const resolvedTarget = resolveMessageTarget(message.target);
|
|
6757
|
-
process.stdout.write(
|
|
6758
|
-
`)
|
|
6804
|
+
process.stdout.write(
|
|
6805
|
+
`[r5d-worker] exec_start ${message.runId}${message.commandClass === "control" ? " (control)" : ""}: ${message.argv.join(" ")}
|
|
6806
|
+
`
|
|
6807
|
+
);
|
|
6759
6808
|
await executeStreamingCommand({
|
|
6760
6809
|
resources,
|
|
6761
6810
|
ws,
|
package/dist/mjs/package.json
CHANGED
|
@@ -2,9 +2,42 @@ export type CommandLaunchRequest = {
|
|
|
2
2
|
id: string;
|
|
3
3
|
kind: "exec" | "exec_start" | "pty_open";
|
|
4
4
|
workspaceEffect?: "none";
|
|
5
|
+
/** An explicitly classified short coordination command; the launcher keeps a bounded lane for these. */
|
|
6
|
+
commandClass?: "control";
|
|
5
7
|
sessionId?: string;
|
|
6
8
|
assertAdmission: () => void;
|
|
7
9
|
};
|
|
10
|
+
export type CommandLaunchLane = "general" | "utility" | "control";
|
|
11
|
+
export type CommandLaunchCapacity = {
|
|
12
|
+
generalSlots: number;
|
|
13
|
+
utilitySlots: number;
|
|
14
|
+
controlSlots: number;
|
|
15
|
+
controlRuntimeMs: number;
|
|
16
|
+
queueTimeoutMs: number;
|
|
17
|
+
};
|
|
18
|
+
export type CommandLaunchLaneReport = {
|
|
19
|
+
/** Null when no launcher bounds this lane (direct execution). */
|
|
20
|
+
slots: number | null;
|
|
21
|
+
active: Array<{
|
|
22
|
+
id: string;
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
since: string;
|
|
25
|
+
}>;
|
|
26
|
+
queued: Array<{
|
|
27
|
+
id: string;
|
|
28
|
+
sessionId?: string;
|
|
29
|
+
since: string;
|
|
30
|
+
}>;
|
|
31
|
+
};
|
|
32
|
+
/** Who holds and who waits for each lane, as the worker sees its own admissions. */
|
|
33
|
+
export type CommandLaunchCapacityReport = {
|
|
34
|
+
/** False for a direct worker: nothing is bounded and nothing can be starved. */
|
|
35
|
+
bounded: boolean;
|
|
36
|
+
controlRuntimeMs: number | null;
|
|
37
|
+
lanes: Record<CommandLaunchLane, CommandLaunchLaneReport>;
|
|
38
|
+
};
|
|
39
|
+
/** The same lane rule the managed launcher applies; kept here so reports match admissions. */
|
|
40
|
+
export declare function commandLaunchLane(request: Pick<CommandLaunchRequest, "kind" | "workspaceEffect" | "commandClass">): CommandLaunchLane;
|
|
8
41
|
export type CommandLaunchLease = {
|
|
9
42
|
wrap(argv: string[]): string[];
|
|
10
43
|
cancel(): Promise<void>;
|
|
@@ -33,6 +66,7 @@ export declare class WorkerCommandLauncher {
|
|
|
33
66
|
private initialized;
|
|
34
67
|
private closePromise?;
|
|
35
68
|
private sequence;
|
|
69
|
+
private capacity?;
|
|
36
70
|
constructor(options?: {
|
|
37
71
|
argv?: string[];
|
|
38
72
|
env?: NodeJS.ProcessEnv;
|
|
@@ -40,9 +74,17 @@ export declare class WorkerCommandLauncher {
|
|
|
40
74
|
onStderr?: (text: string) => void;
|
|
41
75
|
requestTimeoutMs?: number;
|
|
42
76
|
acquisitionTimeoutMs?: number;
|
|
77
|
+
/** Called after any admission changes lane, phase, or is released. */
|
|
78
|
+
onCapacityChange?: () => void;
|
|
43
79
|
});
|
|
44
80
|
initialize(): Promise<void>;
|
|
45
81
|
get activeIds(): string[];
|
|
82
|
+
/** The lane totals the launcher declared at initialization; undefined for a direct worker or an older launcher. */
|
|
83
|
+
get declaredCapacity(): CommandLaunchCapacity | undefined;
|
|
84
|
+
get supportsControlLane(): boolean;
|
|
85
|
+
/** Current admissions per lane, for the worker's capacity report. */
|
|
86
|
+
capacityReport(): CommandLaunchCapacityReport;
|
|
87
|
+
private notifyCapacityChange;
|
|
46
88
|
acquire(request: CommandLaunchRequest): Promise<CommandLaunchLease>;
|
|
47
89
|
cancel(id: string): Promise<void>;
|
|
48
90
|
cancelSession(sessionId: string): Promise<void>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which agent shell invocations may run in the managed launcher's bounded
|
|
3
|
+
* `control` lane: exactly one plain `r5dctl` coordination/diagnosis command.
|
|
4
|
+
*
|
|
5
|
+
* The lane exists so an agent can inspect capacity, message another session,
|
|
6
|
+
* or stop its own runs while every general slot is held by long work
|
|
7
|
+
* (incident 9). It is bounded (small memory, few PIDs, a runtime limit), so
|
|
8
|
+
* only commands that are short HTTP calls to the platform qualify. The
|
|
9
|
+
* classifier fails closed: anything the shell would interpret (operators,
|
|
10
|
+
* substitutions, expansions, quoting the tokenizer cannot prove literal,
|
|
11
|
+
* environment assignments, wrapper programs, paths) is refused and keeps its
|
|
12
|
+
* ordinary general-lane shell semantics. The resulting argv is validated a
|
|
13
|
+
* second time by the worker before it is spawned without a shell.
|
|
14
|
+
*/
|
|
15
|
+
export declare const CONTROL_COMMAND_PROGRAM = "r5dctl";
|
|
16
|
+
/** Worker-side runtime bound for a control run; the launcher enforces its own limit as a backstop. */
|
|
17
|
+
export declare const CONTROL_COMMAND_RUNTIME_MS = 120000;
|
|
18
|
+
export declare const CONTROL_COMMAND_MAX_ARGV = 64;
|
|
19
|
+
export declare const CONTROL_COMMAND_MAX_LENGTH = 4096;
|
|
20
|
+
export type ControlCommandClassification = {
|
|
21
|
+
control: true;
|
|
22
|
+
argv: string[];
|
|
23
|
+
} | {
|
|
24
|
+
control: false;
|
|
25
|
+
reason: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Validate an argv as an allowed r5dctl coordination invocation. Used on the
|
|
29
|
+
* server after tokenizing, and again on the worker before spawning.
|
|
30
|
+
*/
|
|
31
|
+
export declare function assertControlCommandArgv(argv: readonly string[]): void;
|
|
32
|
+
/**
|
|
33
|
+
* Locate the installed CLI through the worker's own, operator-controlled
|
|
34
|
+
* search path. The command's cwd, its environment, and any checkout-local
|
|
35
|
+
* shim play no part, so a project cannot substitute the executable the
|
|
36
|
+
* reserved lane runs.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveControlCommandExecutable(trustedPath: string | undefined, which: (program: string, options: {
|
|
39
|
+
PATH: string;
|
|
40
|
+
}) => string | null, program?: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* The environment a control spawn receives: everything the platform sets for
|
|
43
|
+
* the run (credentials, session identity, declared project values) minus any
|
|
44
|
+
* variable that could load code into the CLI process or redirect which
|
|
45
|
+
* executable runs, with the worker's own PATH restored.
|
|
46
|
+
*/
|
|
47
|
+
export declare function controlCommandEnvironment(environment: NodeJS.ProcessEnv, trustedPath: string | undefined): Record<string, string>;
|
|
48
|
+
/**
|
|
49
|
+
* Classify an agent shell command. Only a plain `r5dctl` coordination
|
|
50
|
+
* invocation becomes a control command; the returned argv is spawned without a
|
|
51
|
+
* shell. Everything else keeps its ordinary shell semantics and lane.
|
|
52
|
+
*/
|
|
53
|
+
export declare function classifyControlCommand(command: string): ControlCommandClassification;
|
package/dist/types/main.d.ts
CHANGED
|
@@ -137,6 +137,11 @@ export type WorkerSessionTarget = {
|
|
|
137
137
|
rootProfile: "visible_projects" | "canonical_sync";
|
|
138
138
|
};
|
|
139
139
|
type WorkerClientMessage = WorkerRecoveryClientMessage | {
|
|
140
|
+
type: "capacity_report";
|
|
141
|
+
capacity: import("./command-launcher").CommandLaunchCapacityReport & {
|
|
142
|
+
reportedAt: string;
|
|
143
|
+
};
|
|
144
|
+
} | {
|
|
140
145
|
type: "hello";
|
|
141
146
|
resumableProtocol: typeof WORKER_RESUMABLE_PROTOCOL;
|
|
142
147
|
runtimeId: string;
|
|
@@ -172,6 +177,7 @@ type WorkerClientMessage = WorkerRecoveryClientMessage | {
|
|
|
172
177
|
startedAt: string;
|
|
173
178
|
interactive?: boolean;
|
|
174
179
|
workspaceEffect?: "none";
|
|
180
|
+
commandClass?: "control";
|
|
175
181
|
}>;
|
|
176
182
|
} | {
|
|
177
183
|
type: "pty_opened";
|
|
@@ -397,6 +403,7 @@ type WorkerServerMessage = WorkerRecoveryServerMessage | {
|
|
|
397
403
|
timeoutMs?: number;
|
|
398
404
|
interactive?: boolean;
|
|
399
405
|
workspaceEffect?: "none";
|
|
406
|
+
commandClass?: "control";
|
|
400
407
|
} | {
|
|
401
408
|
type: "exec_stdin";
|
|
402
409
|
requestId: string;
|