@ricsam/r5d-worker 0.0.122 → 0.0.124
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 +541 -247
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/project-checkout-garbage.cjs +32 -3
- package/dist/cjs/project-workspace-state.cjs +51 -35
- package/dist/cjs/project-worktrees.cjs +79 -34
- package/dist/cjs/recovery-journal-protocol.cjs +56 -0
- package/dist/cjs/recovery-journal-runtime.cjs +133 -0
- package/dist/cjs/recovery-journal-thread.cjs +9 -0
- package/dist/cjs/recovery-journal.cjs +735 -0
- package/dist/cjs/recovery-store.cjs +8 -0
- package/dist/cjs/session-file-mutations.cjs +61 -0
- package/dist/cjs/working-tree-mirror.cjs +1 -0
- package/dist/cjs/workspace-command-sync-policy.cjs +37 -8
- package/dist/cjs/workspace-filesystem-executor-thread.cjs +36 -0
- package/dist/cjs/workspace-filesystem-executor.cjs +327 -0
- package/dist/cjs/workspace-filesystem-job-types.cjs +134 -0
- package/dist/cjs/workspace-filesystem-jobs.cjs +57 -0
- package/dist/cjs/workspace-git-sync.cjs +275 -201
- package/dist/cjs/workspace-mount-hold-fence.cjs +120 -0
- package/dist/mjs/command-launcher.mjs +75 -2
- package/dist/mjs/control-command-policy.mjs +146 -0
- package/dist/mjs/main.mjs +549 -253
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/project-checkout-garbage.mjs +30 -2
- package/dist/mjs/project-workspace-state.mjs +51 -35
- package/dist/mjs/project-worktrees.mjs +74 -34
- package/dist/mjs/recovery-journal-protocol.mjs +30 -0
- package/dist/mjs/recovery-journal-runtime.mjs +112 -0
- package/dist/mjs/recovery-journal-thread.mjs +8 -0
- package/dist/mjs/recovery-journal.mjs +687 -0
- package/dist/mjs/recovery-store.mjs +8 -0
- package/dist/mjs/session-file-mutations.mjs +37 -0
- package/dist/mjs/working-tree-mirror.mjs +1 -0
- package/dist/mjs/workspace-command-sync-policy.mjs +37 -8
- package/dist/mjs/workspace-filesystem-executor-thread.mjs +38 -0
- package/dist/mjs/workspace-filesystem-executor.mjs +287 -0
- package/dist/mjs/workspace-filesystem-job-types.mjs +106 -0
- package/dist/mjs/workspace-filesystem-jobs.mjs +51 -0
- package/dist/mjs/workspace-git-sync.mjs +264 -202
- package/dist/mjs/workspace-mount-hold-fence.mjs +95 -0
- 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 +31 -19
- package/dist/types/project-checkout-garbage.d.ts +19 -2
- package/dist/types/project-workspace-state.d.ts +9 -9
- package/dist/types/project-worktrees.d.ts +49 -5
- package/dist/types/recovery-journal-protocol.d.ts +35 -0
- package/dist/types/recovery-journal-runtime.d.ts +12 -0
- package/dist/types/recovery-journal-stall-fixture.d.ts +1 -0
- package/dist/types/recovery-journal-thread.d.ts +1 -0
- package/dist/types/recovery-journal.d.ts +246 -0
- package/dist/types/recovery-store.d.ts +6 -0
- package/dist/types/session-file-mutations.d.ts +22 -0
- package/dist/types/workspace-command-sync-policy.d.ts +22 -7
- package/dist/types/workspace-filesystem-executor-thread.d.ts +1 -0
- package/dist/types/workspace-filesystem-executor.d.ts +123 -0
- package/dist/types/workspace-filesystem-job-types.d.ts +246 -0
- package/dist/types/workspace-filesystem-jobs.d.ts +7 -0
- package/dist/types/workspace-git-sync.d.ts +113 -7
- package/dist/types/workspace-mount-hold-fence.d.ts +42 -0
- package/package.json +1 -1
- package/dist/cjs/project-snapshot-recovery-runner.cjs +0 -171
- package/dist/mjs/project-snapshot-recovery-runner.mjs +0 -135
- package/dist/types/project-snapshot-recovery-runner.d.ts +0 -10
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
|
+
});
|