omp-conductor 0.18.0 → 0.18.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/README.md +34 -0
- package/REFERENCE.md +60 -10
- package/agents/to-spec.md +90 -0
- package/package.json +2 -1
- package/schema/config.schema.json +29 -0
- package/src/admission.ts +204 -75
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +42 -14
- package/src/briefs/to-spec.md +84 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +2 -0
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +22 -0
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +50 -2
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +24 -0
- package/src/config.ts +42 -1
- package/src/daemon.ts +965 -36
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +235 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +52 -0
- package/src/escalate.ts +9 -3
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +146 -22
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +567 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp.ts +227 -20
- package/src/orchestrator-tick.ts +1386 -15
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +99 -5
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1205 -6
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +13 -2
- package/src/setup.ts +29 -13
- package/src/shell.ts +15 -0
- package/src/status-render.ts +78 -11
- package/src/store.ts +443 -42
- package/src/to-spec.ts +387 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +343 -13
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +730 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +25 -2
- package/src/worktree.ts +29 -12
package/src/orchestrator.ts
CHANGED
|
@@ -82,6 +82,12 @@ export interface OrchestratorHandle {
|
|
|
82
82
|
* reads alive here — that is the stall watchdog's job, not liveness. */
|
|
83
83
|
alive(): boolean;
|
|
84
84
|
sessionFile(): string | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* The omp-conductor version the live orchestrator process loaded (#832):
|
|
87
|
+
* the session's own attestation at start, exposed to the upgrade's
|
|
88
|
+
* session-reload verification. `undefined` when the session never attested.
|
|
89
|
+
*/
|
|
90
|
+
extensionVersion(): string | undefined;
|
|
85
91
|
dispose(): Promise<void>;
|
|
86
92
|
}
|
|
87
93
|
|
|
@@ -290,6 +296,12 @@ export async function startOrchestrator(o: OrchestratorOpts): Promise<Orchestrat
|
|
|
290
296
|
// The path the harness actually opened, read live: the transcript is how a
|
|
291
297
|
// human audits what the orchestrator decided on their behalf.
|
|
292
298
|
sessionFile: () => session.sessionFile,
|
|
299
|
+
// The loaded-module attestation (#832): the extension version this
|
|
300
|
+
// orchestrator PROCESS loaded at session start — the live fact the
|
|
301
|
+
// upgrade's session-reload verification compares against the target
|
|
302
|
+
// release. `undefined` when the session never attested (an old build, a
|
|
303
|
+
// hand-written fake) — absence is "not attested", never "the newest".
|
|
304
|
+
extensionVersion: () => session.extensionVersion,
|
|
293
305
|
async dispose(): Promise<void> {
|
|
294
306
|
if (disposed) return;
|
|
295
307
|
disposed = true;
|
package/src/privileged.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import type { WizardUi } from "./wizard-ui.ts";
|
|
28
|
+
import { shellQuote } from "./shell.ts";
|
|
28
29
|
|
|
29
30
|
export interface PrivilegedStep {
|
|
30
31
|
/** One line naming what this step accomplishes, shown in the plan. */
|
|
@@ -113,10 +114,6 @@ export function formatStep(step: PrivilegedStep, asRoot = false): string {
|
|
|
113
114
|
return [...prefix, ...step.argv].map(shellQuote).join(" ");
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
function shellQuote(value: string): string {
|
|
117
|
-
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
117
|
/**
|
|
121
118
|
* Refuses to build a step that re-runs conductor itself.
|
|
122
119
|
*
|
package/src/release-policy.ts
CHANGED
|
@@ -175,17 +175,17 @@ function commandSegments(command: string): string[] {
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
/** A leading wrapper command, matched and consumed in a chain (#558). `env`
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
178
|
+
* has its own dedicated stripper below — its option forms need their own
|
|
179
|
+
* handling — while `timeout`, `nice`, `stdbuf` and a shell `-c` were not
|
|
180
|
+
* stripped at all, so a whole-package run became allowed the moment it
|
|
181
|
+
* picked up a wrapper: the incident ran `timeout 300 bun test` and the guard
|
|
182
|
+
* never fired. Each alternative owns the arguments that belong to it (the
|
|
182
183
|
* duration, the niceness, the option run), so stripping never swallows the
|
|
183
184
|
* command's own words. Ordered longest-first so `timeout` is consumed
|
|
184
|
-
* before `time` and
|
|
185
|
+
* before `time` and a `VAR=…` chain after `sudo`. */
|
|
185
186
|
const COMMAND_WRAPPER_PREFIX = new RegExp(
|
|
186
187
|
[
|
|
187
188
|
"^sudo(?:\\s+-[a-z][a-z0-9-]*)*\\s+",
|
|
188
|
-
"^env\\s+",
|
|
189
189
|
"^(?:[A-Za-z_][A-Za-z0-9_]*=\\S+\\s+)+",
|
|
190
190
|
"^timeout(?:\\s+--?[a-z][a-z0-9-]*(?:=\\S+|\\s+\\S+)?)*\\s+(?:inf|infinity|\\d+(?:\\.\\d+)?[smhd]?)\\s+",
|
|
191
191
|
"^nice(?:\\s+-n\\s+-?\\d+|\\s+-\\d+|\\s+--adjustment\\s*=\\s*-?\\d+)?\\s+",
|
|
@@ -196,6 +196,478 @@ const COMMAND_WRAPPER_PREFIX = new RegExp(
|
|
|
196
196
|
].join("|"),
|
|
197
197
|
);
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Consume `env` together with every argument that belongs to it — its
|
|
201
|
+
* environment assignments and its own option forms — leaving the command the
|
|
202
|
+
* wrapper would run. The wrapper table's bare `env ` strip read one word and
|
|
203
|
+
* stopped, so `env -u NAME bun test` survived classification as an
|
|
204
|
+
* unclassified `-u NAME bun test` while `env FOO=1 bun test` was consumed
|
|
205
|
+
* through the assignment chain (#789). Every option form below exists to
|
|
206
|
+
* mutate or choose the *environment*, and that mutation is the wrapper; the
|
|
207
|
+
* command sits after it, so the whole argument list must go before the
|
|
208
|
+
* command is classified.
|
|
209
|
+
*
|
|
210
|
+
* The parsing mirrors how `env` itself stops: `--` ends env's own argument
|
|
211
|
+
* list and the rest is the command; a token that is neither an assignment
|
|
212
|
+
* nor an env option is the command, not a wrapper argument. A short cluster
|
|
213
|
+
* may end in an operand-taking flag (`-u NAME`, `-iu NAME` — the operand is
|
|
214
|
+
* the next token) or carry the operand mid-token (`-ui` is `-u i`, `-uNAME`
|
|
215
|
+
* is `-u NAME` — the rest of the token is the operand), exactly as compiled
|
|
216
|
+
* option parsing does; a lone `-` is env's implied `-i`. A cluster containing
|
|
217
|
+
* an unrecognised flag is left behind because `env` itself rejects the whole
|
|
218
|
+
* command on it — there is no command to gate.
|
|
219
|
+
*
|
|
220
|
+
* `-S`/`--split-string` is handled, never unwrapped piecemeal. env *re-splits*
|
|
221
|
+
* the option's operand as its own command line — assignments, options, then
|
|
222
|
+
* the command — and then keeps the rest of the line as further arguments to
|
|
223
|
+
* it (verified against coreutils 9.4, where `env -S --unset=FOO cmd` applies
|
|
224
|
+
* the unset and runs `cmd` via the remaining argv). The operand is decoded
|
|
225
|
+
* exactly as the shell and env's split-string parser together leave it and is
|
|
226
|
+
* re-entered into the argument loop below, so the command env would actually
|
|
227
|
+
* execute meets ordinary classification — a whole-suite `bun test` behind any
|
|
228
|
+
* `-S` spelling is refused, a focused `bun test src/x.test.ts` still passes.
|
|
229
|
+
* The only case a static command line cannot resolve is an operand that
|
|
230
|
+
* expands a runtime variable (`${VAR}`): it is refused via
|
|
231
|
+
* {@link UNCLASSIFIABLE_ENV_SPLIT} rather than guessed at.
|
|
232
|
+
*
|
|
233
|
+
* Long options are the same, and GNU accepts any unambiguous abbreviation of
|
|
234
|
+
* one (`--u=NAME` is `--unset=NAME`, `--split 'bun test'` is
|
|
235
|
+
* `--split-string 'bun test'`), so they are resolved against env's option
|
|
236
|
+
* table by prefix; a token too ambiguous to be one option is what env itself
|
|
237
|
+
* rejects — nothing runs — and is left behind, exactly like an unrecognised
|
|
238
|
+
* short flag.
|
|
239
|
+
*
|
|
240
|
+
* Adjacent wrappers compose: env's own command may itself be an env
|
|
241
|
+
* invocation (`env -i env bun test` runs the inner env on the suite), so the
|
|
242
|
+
* strip reaches a strict shortening fixpoint rather than stopping at the
|
|
243
|
+
* first `env`.
|
|
244
|
+
*/
|
|
245
|
+
/** The sentinel a strip returns for a segment it must not let through but
|
|
246
|
+
* cannot classify: an `env -S` operand that expands a runtime variable
|
|
247
|
+
* (`${VAR}`), which env resolves against its live environment and a static
|
|
248
|
+
* command line cannot. `releaseCommandMatch` reads it and refuses the whole
|
|
249
|
+
* segment as `shared-host-gate` rather than risk a whole-package run
|
|
250
|
+
* slipping through, unclassified, as itself. */
|
|
251
|
+
const UNCLASSIFIABLE_ENV_SPLIT = "\u0000env-split-unclassifiable";
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Read one shell word at `input`'s head to the first unquoted whitespace (or
|
|
255
|
+
* end), quoted regions and concatenated fragments included — `'bun test'` and
|
|
256
|
+
* `'bun'test` are each one word, so the `env -S` operand survives as a unit
|
|
257
|
+
* while the rest of the segment stays behind. The raw command line still
|
|
258
|
+
* carries the quotes, because the shell would only remove them as env runs;
|
|
259
|
+
* they are preserved in `word` for {@link decodeEnvSplitOperand} to resolve
|
|
260
|
+
* against the split-string parser's own quoting. `undefined` when the head
|
|
261
|
+
* holds no word at all.
|
|
262
|
+
*/
|
|
263
|
+
function readShellWord(input: string): { word: string; rest: string } | undefined {
|
|
264
|
+
let end = 0;
|
|
265
|
+
const n = input.length;
|
|
266
|
+
while (end < n) {
|
|
267
|
+
const c = input[end]!;
|
|
268
|
+
if (c === "\\") {
|
|
269
|
+
end += end + 1 < n ? 2 : 1;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (c === "'" || c === '"') {
|
|
273
|
+
const quote = c;
|
|
274
|
+
end += 1;
|
|
275
|
+
while (end < n && input[end] !== quote) {
|
|
276
|
+
end += input[end] === "\\" && end + 1 < n ? 2 : 1;
|
|
277
|
+
}
|
|
278
|
+
if (end < n) end += 1; // the matching close
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (/\s/.test(c)) break;
|
|
282
|
+
end += 1;
|
|
283
|
+
}
|
|
284
|
+
if (end === 0) return undefined;
|
|
285
|
+
return { word: input.slice(0, end), rest: input.slice(end) };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* One backslash escape env's split-string parser accepts, as its character.
|
|
290
|
+
* `\_` is a word-separating space exactly like a literal one — `bun\_test`
|
|
291
|
+
* splits into `bun` and `test` — so it cannot decode to an underscore
|
|
292
|
+
* (verified against coreutils 9.4). `undefined` for anything else: env
|
|
293
|
+
* rejects `\a`, `\b`, `\e`, `\0` (no octal), `\x`, `\q`, a backslash before
|
|
294
|
+
* whitespace and any other undeclared escape with "invalid sequence",
|
|
295
|
+
* running nothing — the caller fails closed rather than guess.
|
|
296
|
+
*/
|
|
297
|
+
function splitEscapeChar(e: string): string | undefined {
|
|
298
|
+
switch (e) {
|
|
299
|
+
case "n": return "\n";
|
|
300
|
+
case "r": return "\r";
|
|
301
|
+
case "f": return "\f";
|
|
302
|
+
case "t": return "\t";
|
|
303
|
+
case "v": return "\v";
|
|
304
|
+
case "_": return " ";
|
|
305
|
+
case "\\": return "\\";
|
|
306
|
+
case '"': return '"';
|
|
307
|
+
case "'": return "'";
|
|
308
|
+
case "$": return "$";
|
|
309
|
+
case "#": return "#";
|
|
310
|
+
default: return undefined;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Decode one `'…'`/`"…"` split-string quoting group. env removes the quotes
|
|
316
|
+
* and the group stands as a single token, so its content is decoded exactly
|
|
317
|
+
* like the rest of the string. A `\c` inside a group is rejected by env
|
|
318
|
+
* ("'\c' must not appear in … -S string") and so is an unterminated group
|
|
319
|
+
* ("no terminating quote in -S string"): both fail closed.
|
|
320
|
+
*/
|
|
321
|
+
function decodeSplitGroup(
|
|
322
|
+
text: string,
|
|
323
|
+
start: number,
|
|
324
|
+
quote: string,
|
|
325
|
+
): { content: string; end: number } | undefined {
|
|
326
|
+
let content = "";
|
|
327
|
+
let i = start;
|
|
328
|
+
const n = text.length;
|
|
329
|
+
while (i < n && text[i] !== quote) {
|
|
330
|
+
const c = text[i]!;
|
|
331
|
+
if (c === "\\") {
|
|
332
|
+
i += 1;
|
|
333
|
+
if (i >= n) return undefined;
|
|
334
|
+
if (text[i] === "c") return undefined; // env rejects `\c` in a group
|
|
335
|
+
const decoded = splitEscapeChar(text[i]!);
|
|
336
|
+
if (decoded === undefined) return undefined;
|
|
337
|
+
content += decoded;
|
|
338
|
+
i += 1;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (c === "$" || c === "`") return undefined;
|
|
342
|
+
content += c;
|
|
343
|
+
i += 1;
|
|
344
|
+
}
|
|
345
|
+
if (i >= n) return undefined; // no terminating quote
|
|
346
|
+
return { content, end: i + 1 };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Resolve env's split-string decoding of `text`, so the operand re-enters
|
|
351
|
+
* classification the way env would execute it (verified against coreutils
|
|
352
|
+
* 9.4). Escapes become their characters (`\_` a word-separating space),
|
|
353
|
+
* whitespace runs collapse to single spaces, and a `'…'`/`"…"` quoting group
|
|
354
|
+
* becomes one token with its quotes removed: `'bun' test` reads `bun test`,
|
|
355
|
+
* exactly as env runs it. A grouped token whose content carries whitespace
|
|
356
|
+
* keeps quotes around it in the decoded line — that is what a
|
|
357
|
+
* `bash -c "bun test"` command string looks like to the downstream shell-`-c`
|
|
358
|
+
* resolver, and the only shape whose token boundary classification would
|
|
359
|
+
* otherwise lose. An unquoted, unescaped `#` at the start of an argument — a
|
|
360
|
+
* `#` coreutils' `ss.sep` flag finds at the string head, after whitespace, or
|
|
361
|
+
* after an outer `\_` — is a comment that ends the split string: coreutils
|
|
362
|
+
* `goto eos` drops the `#` and everything after it (`env -S '#' bun test`
|
|
363
|
+
* executes the untouched `bun test` argv via the remaining command line), so
|
|
364
|
+
* the decode stops there and the caller re-enters only what preceded it.
|
|
365
|
+
* Escaped (`\#`) or quoted (`'#'`, `"#"`) hashes are that same literal `#`
|
|
366
|
+
* argument — `\#` passes as-is, and quotes group — never a comment, and a
|
|
367
|
+
* mid-argument `#` (`bun#test`, `'a'#b`) is the ordinary character it is to
|
|
368
|
+
* env. A construct env rejects (a `\c` in a group, an unterminated group, an
|
|
369
|
+
* undeclared escape, a trailing backslash) or cannot resolve statically (a
|
|
370
|
+
* `${VAR}`/`$VAR`/backtick expansion) is `undefined`: the caller must refuse
|
|
371
|
+
* the whole segment rather than guess.
|
|
372
|
+
*/
|
|
373
|
+
function decodeSplitString(text: string): string | undefined {
|
|
374
|
+
let out = "";
|
|
375
|
+
const n = text.length;
|
|
376
|
+
let i = 0;
|
|
377
|
+
// Mirror coreutils' `ss.sep`: true while the next character opens a new
|
|
378
|
+
// argument — the string head, after a whitespace run, or after an outer
|
|
379
|
+
// `\_` separator. A `#` seen there is the comment that ends the split;
|
|
380
|
+
// every other position keeps `#` the literal character it is to env.
|
|
381
|
+
let sep = true;
|
|
382
|
+
while (i < n) {
|
|
383
|
+
const c = text[i]!;
|
|
384
|
+
if (c === "$" || c === "`") return undefined;
|
|
385
|
+
if (c === "\\") {
|
|
386
|
+
i += 1;
|
|
387
|
+
if (i >= n) return undefined; // a trailing backslash: env rejects it
|
|
388
|
+
const e = text[i]!;
|
|
389
|
+
if (e === "c") break; // `\c` stops processing the rest of the string
|
|
390
|
+
const decoded = splitEscapeChar(e);
|
|
391
|
+
if (decoded === undefined) return undefined;
|
|
392
|
+
out += decoded;
|
|
393
|
+
// `\_` is an argument separator outside quotes, exactly like a space;
|
|
394
|
+
// any other escape is a character inside the current argument.
|
|
395
|
+
sep = decoded === " " ? true : false;
|
|
396
|
+
i += 1;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (c === "#" && sep) break; // comment: an arg-initial `#` ends the split
|
|
400
|
+
if (c === "'" || c === '"') {
|
|
401
|
+
const group = decodeSplitGroup(text, i + 1, c);
|
|
402
|
+
if (group === undefined) return undefined;
|
|
403
|
+
const content = group.content;
|
|
404
|
+
if (/\s/.test(content)) {
|
|
405
|
+
// One token with an interior space; re-quote it so the shell-`-c`
|
|
406
|
+
// resolver below still sees a quoted command string. Pick the quote
|
|
407
|
+
// style the content does not already carry; content with both is not
|
|
408
|
+
// representable and fails closed.
|
|
409
|
+
if (!content.includes(c)) {
|
|
410
|
+
out += c + content + c;
|
|
411
|
+
} else if (c === "'" && !content.includes('"')) {
|
|
412
|
+
out += `"${content}"`;
|
|
413
|
+
} else if (c === '"' && !content.includes("'")) {
|
|
414
|
+
out += `'${content}'`;
|
|
415
|
+
} else {
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
out += content; // quotes removed: `'bun' test` is `bun test`
|
|
420
|
+
}
|
|
421
|
+
// The group opened an argument, so what follows it — a `#` included —
|
|
422
|
+
// continues that same argument (`'a'#b` is `a#b`).
|
|
423
|
+
sep = false;
|
|
424
|
+
i = group.end;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (/\s/.test(c)) {
|
|
428
|
+
out += " ";
|
|
429
|
+
sep = true;
|
|
430
|
+
while (i < n && /\s/.test(text[i]!)) i += 1;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
out += c;
|
|
434
|
+
sep = false;
|
|
435
|
+
i += 1;
|
|
436
|
+
}
|
|
437
|
+
return out.replace(/\s+/g, " ").trim();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Decode the `env -S` operand word for re-entry, the way the shell and env's
|
|
442
|
+
* split-string parser between them leave it. The operand's own shell quoting
|
|
443
|
+
* — the `'…'`/`"…"` wrapping the whole word on the raw command line — is the
|
|
444
|
+
* printable form of the argument; stripping that one level is what the shell
|
|
445
|
+
* does before env sees it. The quotes *inside* the content are env's own
|
|
446
|
+
* grouping and are preserved for {@link decodeSplitString}. `undefined` when
|
|
447
|
+
* the decoded text carries a runtime expansion.
|
|
448
|
+
*/
|
|
449
|
+
function decodeEnvSplitOperand(word: string): string | undefined {
|
|
450
|
+
let content = word;
|
|
451
|
+
if (word.length >= 2) {
|
|
452
|
+
const first = word[0]!;
|
|
453
|
+
const last = word[word.length - 1]!;
|
|
454
|
+
if (first === "'" && last === "'" && !word.slice(1, -1).includes("'")) {
|
|
455
|
+
content = word.slice(1, -1);
|
|
456
|
+
} else if (first === '"' && last === '"') {
|
|
457
|
+
content = word.slice(1, -1);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return decodeSplitString(content);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Consume a short `-S` option from the current head, returning its operand
|
|
465
|
+
* word (raw, quotes included) and the rest of the segment. `operand: null`
|
|
466
|
+
* means the option had no operand at all — env itself rejects that
|
|
467
|
+
* invocation, so there is no command to gate. The accepted spellings match
|
|
468
|
+
* compiled option parsing: `-S`, `-iS`, `-0S`, an operand attached to `S` in
|
|
469
|
+
* the same token (`-Sbun`, `-S'cmd'`), or the next word (`-S bun`). The long
|
|
470
|
+
* `--split-string` spelling — exact or an unambiguous abbreviation — is
|
|
471
|
+
* resolved by {@link parseEnvLongOption} instead.
|
|
472
|
+
*/
|
|
473
|
+
function parseEnvSplitOption(rest: string): { operand: string | null; rest: string } | undefined {
|
|
474
|
+
const short = /^-[i0v]*S/.exec(rest);
|
|
475
|
+
if (short === null) return undefined;
|
|
476
|
+
// The operand is attached to S in the same token — `-Sbun`, `-S'cmd'` —
|
|
477
|
+
// or, after whitespace, the next word: `-S bun`.
|
|
478
|
+
const after = rest.slice(short[0].length).replace(/^\s+/, "");
|
|
479
|
+
const word = readShellWord(after);
|
|
480
|
+
if (word === undefined) return { operand: null, rest: "" };
|
|
481
|
+
return { operand: word.word, rest: word.rest };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** env's long options with the operand shape each one declares. */
|
|
485
|
+
const ENV_LONG_OPTIONS: ReadonlyArray<{
|
|
486
|
+
name: string;
|
|
487
|
+
/** `required`: an operand via `=` or the next word (empty `=` rejected);
|
|
488
|
+
* `eq-only`: an optional operand only via `=` (`--block-signal[=SIG]`);
|
|
489
|
+
* `none`: no operand, and a trailing `=` is rejected. */
|
|
490
|
+
operand: "required" | "eq-only" | "none";
|
|
491
|
+
}> = [
|
|
492
|
+
{ name: "split-string", operand: "required" },
|
|
493
|
+
{ name: "unset", operand: "required" },
|
|
494
|
+
{ name: "chdir", operand: "required" },
|
|
495
|
+
{ name: "block-signal", operand: "eq-only" },
|
|
496
|
+
{ name: "default-signal", operand: "eq-only" },
|
|
497
|
+
{ name: "ignore-signal", operand: "eq-only" },
|
|
498
|
+
{ name: "ignore-environment", operand: "none" },
|
|
499
|
+
{ name: "null", operand: "none" },
|
|
500
|
+
{ name: "list-signal-handling", operand: "none" },
|
|
501
|
+
{ name: "debug", operand: "none" },
|
|
502
|
+
];
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Consume a `--…` env long option from the current head, exact or an
|
|
506
|
+
* unambiguous GNU abbreviation. `/usr/bin/env --u=NAME cmd` applies the unset
|
|
507
|
+
* and runs `cmd`, and `--split 'bun test'` re-splits — verified against
|
|
508
|
+
* coreutils 9.4 — so a parser that only knew the full spellings left a
|
|
509
|
+
* leading `--u=…` behind and a whole-package run bypassed classification.
|
|
510
|
+
* The resolution mirrors getopt_long: the token is matched against env's own
|
|
511
|
+
* option table as a prefix; exactly one match is the option, consumed with
|
|
512
|
+
* the operand shape it declares (split-string's operand is returned for the
|
|
513
|
+
* caller to decode and re-enter, exactly like `-S`). Zero matches (unknown)
|
|
514
|
+
* or several (ambiguous) are what env itself rejects — verified:
|
|
515
|
+
* `--frobnicate …` and `--i=…` both error out and run nothing — so the token
|
|
516
|
+
* is left behind for the caller to return untouched; there is no command to
|
|
517
|
+
* gate. `undefined` when the head is not a long option at all.
|
|
518
|
+
*/
|
|
519
|
+
function parseEnvLongOption(
|
|
520
|
+
rest: string,
|
|
521
|
+
): { next: "leave" } | { next: "continue"; rest: string } | { next: "split"; operand: string | null; rest: string } | undefined {
|
|
522
|
+
const head = /^--([A-Za-z][A-Za-z0-9-]*)/.exec(rest);
|
|
523
|
+
if (head === null) return undefined;
|
|
524
|
+
const token = head[1]!;
|
|
525
|
+
const matches = ENV_LONG_OPTIONS.filter((option) => option.name.startsWith(token));
|
|
526
|
+
if (matches.length !== 1) return { next: "leave" };
|
|
527
|
+
const option = matches[0]!;
|
|
528
|
+
const after = rest.slice(head[0].length);
|
|
529
|
+
|
|
530
|
+
if (option.operand === "none") {
|
|
531
|
+
if (after.startsWith("=")) return { next: "leave" }; // env rejects `--debug=…`
|
|
532
|
+
return { next: "continue", rest: after.replace(/^\s+/, "") };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (option.operand === "eq-only") {
|
|
536
|
+
// `--block-signal[=SIG]`: the signal rides only on the `=` and may be
|
|
537
|
+
// empty (a no-op); without `=`, the token is consumed bare and the next
|
|
538
|
+
// word is env's command, matching env's own parsing.
|
|
539
|
+
if (after.startsWith("=")) {
|
|
540
|
+
const value = readShellWord(after.slice(1));
|
|
541
|
+
if (value === undefined) {
|
|
542
|
+
return { next: "continue", rest: after.slice(1).replace(/^\s+/, "") };
|
|
543
|
+
}
|
|
544
|
+
return { next: "continue", rest: value.rest.replace(/^\s+/, "") };
|
|
545
|
+
}
|
|
546
|
+
return { next: "continue", rest: after.replace(/^\s+/, "") };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (option.name === "split-string") {
|
|
550
|
+
// The operand is split and re-entered as a command line by the caller;
|
|
551
|
+
// an empty `=` value still splits to nothing, after which the rest of
|
|
552
|
+
// the line is the command.
|
|
553
|
+
if (after.startsWith("=")) {
|
|
554
|
+
const value = readShellWord(after.slice(1));
|
|
555
|
+
if (value === undefined) return { next: "split", operand: "", rest: after.slice(1) };
|
|
556
|
+
return { next: "split", operand: value.word, rest: value.rest };
|
|
557
|
+
}
|
|
558
|
+
const spaced = after.replace(/^\s+/, "");
|
|
559
|
+
if (spaced.length === 0) {
|
|
560
|
+
// `--split-string` with no operand: env rejects the invocation.
|
|
561
|
+
return { next: "split", operand: null, rest: "" };
|
|
562
|
+
}
|
|
563
|
+
const word = readShellWord(spaced);
|
|
564
|
+
if (word === undefined) return { next: "split", operand: null, rest: "" };
|
|
565
|
+
return { next: "split", operand: word.word, rest: word.rest };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// `--unset NAME` / `--chdir DIR` — a required operand via `=` (empty is
|
|
569
|
+
// rejected by env: "cannot unset ''") or as the next word.
|
|
570
|
+
if (after.startsWith("=")) {
|
|
571
|
+
const value = readShellWord(after.slice(1));
|
|
572
|
+
if (value === undefined || value.word === "") return { next: "leave" };
|
|
573
|
+
return { next: "continue", rest: value.rest.replace(/^\s+/, "") };
|
|
574
|
+
}
|
|
575
|
+
const spaced = after.replace(/^\s+/, "");
|
|
576
|
+
const word = readShellWord(spaced);
|
|
577
|
+
if (word === undefined) {
|
|
578
|
+
// `--unset` with no operand: env rejects the invocation.
|
|
579
|
+
return { next: "continue", rest: "" };
|
|
580
|
+
}
|
|
581
|
+
return { next: "continue", rest: word.rest.replace(/^\s+/, "") };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function stripEnvPrefix(segment: string): string {
|
|
585
|
+
// env's own command may itself be an env invocation — `env env bun test`
|
|
586
|
+
// runs the inner env, which runs the whole suite (same for `env -i env …`
|
|
587
|
+
// and, after a freed wrapper, `sudo env -u NAME env …`) — and the `--`
|
|
588
|
+
// separator hands env's command to the same strip. So the strip reaches a
|
|
589
|
+
// strict fixpoint: loop until the remainder no longer opens with `env `.
|
|
590
|
+
// Each pass consumes at least the `env ` head, so this terminates.
|
|
591
|
+
let current = segment;
|
|
592
|
+
while (true) {
|
|
593
|
+
const head = /^env\s+/.exec(current);
|
|
594
|
+
if (head === null) return current;
|
|
595
|
+
let rest = current.slice(head[0].length);
|
|
596
|
+
const consume = (pattern: RegExp): boolean => {
|
|
597
|
+
const match = pattern.exec(rest);
|
|
598
|
+
if (match === null) return false;
|
|
599
|
+
rest = rest.slice(match[0].length);
|
|
600
|
+
return true;
|
|
601
|
+
};
|
|
602
|
+
while (true) {
|
|
603
|
+
// `-S`/`--split-string`: env splits its operand as its own command line
|
|
604
|
+
// and keeps the rest of the segment as further arguments to it — so the
|
|
605
|
+
// whole line after the option is still one command env makes the
|
|
606
|
+
// environment for. Decode the operand and let the argument loop below
|
|
607
|
+
// consume the line, so the command env would actually run meets ordinary
|
|
608
|
+
// classification. An operand that expands a runtime variable cannot be
|
|
609
|
+
// classified statically: refuse the whole segment instead of guessing.
|
|
610
|
+
const split = parseEnvSplitOption(rest);
|
|
611
|
+
if (split !== undefined) {
|
|
612
|
+
if (split.operand === null) {
|
|
613
|
+
// No operand at all: env itself rejects the invocation, so there is
|
|
614
|
+
// no command to gate — consume the bare option and move on.
|
|
615
|
+
rest = split.rest;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
const decoded = decodeEnvSplitOperand(split.operand);
|
|
619
|
+
if (decoded === undefined) return UNCLASSIFIABLE_ENV_SPLIT;
|
|
620
|
+
rest = `${decoded} ${split.rest}`.trimStart();
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
// `--…` env long options, exact or an unambiguous GNU abbreviation:
|
|
624
|
+
// consumed with the operand shape the option declares. split-string's
|
|
625
|
+
// operand is decoded and re-entered here, like `-S`; unknown or ambiguous
|
|
626
|
+
// long options are what env itself rejects (nothing runs), so they are
|
|
627
|
+
// left behind and the loop stops — there is no command to classify.
|
|
628
|
+
const long = parseEnvLongOption(rest);
|
|
629
|
+
if (long !== undefined) {
|
|
630
|
+
if (long.next === "leave") return rest;
|
|
631
|
+
if (long.next === "split") {
|
|
632
|
+
if (long.operand === null) {
|
|
633
|
+
// `--split-string` with no operand: env rejects the invocation.
|
|
634
|
+
rest = "";
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
const decoded = decodeEnvSplitOperand(long.operand);
|
|
638
|
+
if (decoded === undefined) return UNCLASSIFIABLE_ENV_SPLIT;
|
|
639
|
+
rest = `${decoded} ${long.rest}`.trimStart();
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
rest = long.rest;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
// `env -- cmd`: the separator ends env's argument list; the command
|
|
646
|
+
// follows — command or not, the outer fixpoint loop re-checks it.
|
|
647
|
+
if (consume(/^--\s+/)) {
|
|
648
|
+
current = rest;
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
// An assignment: `env FOO=1 cmd` (`FOO= cmd` — the empty value env also
|
|
652
|
+
// honours — is an assignment too, because the `=` makes it one).
|
|
653
|
+
if (consume(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*)\s+/)) continue;
|
|
654
|
+
// Short-option cluster: operand-taking flag terminal in the token (the
|
|
655
|
+
// operand is the next token), operand-taking flag mid-token (the rest of
|
|
656
|
+
// the token is its operand), a bare cluster of no-operand flags, or the
|
|
657
|
+
// lone `-` that means `-i` (the cluster is then empty).
|
|
658
|
+
if (
|
|
659
|
+
consume(
|
|
660
|
+
/^-(?:[i0v]*[uC]\s+\S+|[i0v]*[uC]\S+|[i0v]+)?\s+/,
|
|
661
|
+
)
|
|
662
|
+
) {
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
current = rest;
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
199
671
|
/** `bash -c`, `sh -c`, `zsh -c` with optional preceding flags — `-lc` and
|
|
200
672
|
* friends parse as combined short options, so the `c` may ride in the same
|
|
201
673
|
* token as the flags before it. A `bash -n` parse check is deliberately not
|
|
@@ -224,14 +696,18 @@ function shellCommandInner(segment: string): string | undefined {
|
|
|
224
696
|
* command (#558). The old stripper recognised `env` and `sudo` and nothing
|
|
225
697
|
* else, so a whole-package `bun test` became allowed the moment a worker
|
|
226
698
|
* wrapped it in `timeout`/`nice`/`bash -c`. Chains matter — `timeout 300
|
|
227
|
-
* nice bun test` is one command with two wrappers
|
|
228
|
-
*
|
|
699
|
+
* nice bun test` is one command with two wrappers, and an `env` with its own
|
|
700
|
+
* options may sit anywhere in the chain (`sudo env -u NAME timeout 300 bun
|
|
701
|
+
* test`) — so env is stripped before the table and again after each match,
|
|
702
|
+
* and the table is matched repeatedly until the leading word belongs to the
|
|
703
|
+
* command itself.
|
|
229
704
|
*/
|
|
230
705
|
function stripCommandPrefix(segment: string): string {
|
|
231
|
-
let stripped = segment;
|
|
706
|
+
let stripped = stripEnvPrefix(segment);
|
|
232
707
|
let match: RegExpExecArray | null;
|
|
233
708
|
while ((match = COMMAND_WRAPPER_PREFIX.exec(stripped)) !== null) {
|
|
234
709
|
stripped = stripped.slice(match[0].length);
|
|
710
|
+
stripped = stripEnvPrefix(stripped);
|
|
235
711
|
}
|
|
236
712
|
return stripped;
|
|
237
713
|
}
|
|
@@ -448,6 +924,12 @@ function gitTagIsWrite(segment: string): boolean {
|
|
|
448
924
|
function releaseCommandMatch(command: string): { shape: GateShape; matched: string } | undefined {
|
|
449
925
|
for (const raw of commandSegments(command)) {
|
|
450
926
|
const segment = stripCommandPrefix(raw);
|
|
927
|
+
// A segment whose `env -S` operand expands a runtime variable cannot be
|
|
928
|
+
// classified from a static command line; refuse the whole command rather
|
|
929
|
+
// than let an unclassified whole-package run past.
|
|
930
|
+
if (segment === UNCLASSIFIABLE_ENV_SPLIT) {
|
|
931
|
+
return { shape: SHARED_HOST_SHAPE, matched: raw.trim() };
|
|
932
|
+
}
|
|
451
933
|
// A `bash -c '<cmd>'` / `sh -c "<cmd>"` wrapper executes the quoted
|
|
452
934
|
// string, so classify that string as a command of its own: a whole
|
|
453
935
|
// package inside the quotes is refused (#558), a focused run inside
|
|
@@ -667,6 +1149,18 @@ function sharedHostRefusalReason(matched: string | undefined): string {
|
|
|
667
1149
|
"Run a focused `bun test <file>.test.ts` instead."
|
|
668
1150
|
);
|
|
669
1151
|
}
|
|
1152
|
+
// The only shared-host matches that still open with `env` are the -S
|
|
1153
|
+
// operands this policy refuses rather than classify (a runtime `${…}`
|
|
1154
|
+
// expansion, or an escape env's split-string parser rejects). Name that,
|
|
1155
|
+
// not the generic suite wording, so a refused command reads like what it is.
|
|
1156
|
+
if (matched !== undefined && /^env\s+/.test(matched)) {
|
|
1157
|
+
return (
|
|
1158
|
+
"Blocked by sharedHostPolicy: an `env -S`/`--split-string` operand cannot be classified from a static " +
|
|
1159
|
+
"command line — it carries a `${…}`/`$` runtime expansion or an escape env's split-string parser does " +
|
|
1160
|
+
"not accept. Refusing rather than guessing whether it runs a whole-package `bun test`; spell the " +
|
|
1161
|
+
"command literally instead."
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
670
1164
|
if (matched !== undefined) {
|
|
671
1165
|
return (
|
|
672
1166
|
`Blocked by sharedHostPolicy: \`${matched}\` is a shared-host shell suite and not a worker's proof ` +
|