superpowers-manager 0.5.2 → 0.7.0

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.
Files changed (83) hide show
  1. package/README.md +44 -26
  2. package/dist/adapter-result.js +5 -9
  3. package/dist/atomic.js +4 -14
  4. package/dist/cli-arguments.js +1 -27
  5. package/dist/cli.js +32 -52
  6. package/dist/commands/adapter-call.js +28 -0
  7. package/dist/commands/install.js +26 -107
  8. package/dist/commands/mutation.js +7 -0
  9. package/dist/commands/pin.js +1 -1
  10. package/dist/commands/prepare.js +39 -129
  11. package/dist/commands/probe.js +35 -264
  12. package/dist/commands/track-latest.js +1 -1
  13. package/dist/commands/uninstall.js +16 -92
  14. package/dist/commands/update.js +5 -25
  15. package/dist/effective-selection.js +7 -9
  16. package/dist/git-tree.js +85 -0
  17. package/dist/harness-command-result.js +47 -0
  18. package/dist/harnesses/codex/adapter.js +231 -510
  19. package/dist/harnesses/codex/compatibility.js +2 -17
  20. package/dist/harnesses/codex/conflicts.js +2 -3
  21. package/dist/harnesses/codex/harness.js +18 -263
  22. package/dist/harnesses/codex/hooks.js +1 -1
  23. package/dist/harnesses/codex/json.js +1 -11
  24. package/dist/harnesses/codex/lifecycle.js +80 -72
  25. package/dist/harnesses/codex/manifest-overlay.js +23 -18
  26. package/dist/harnesses/codex/paths.js +14 -29
  27. package/dist/harnesses/codex/presentation.js +8 -37
  28. package/dist/harnesses/codex/publication.js +13 -37
  29. package/dist/harnesses/codex/recovery.js +5 -5
  30. package/dist/harnesses/codex/stored-state.js +1 -1
  31. package/dist/harnesses/opencode/config.js +256 -0
  32. package/dist/harnesses/opencode/discovery.js +587 -0
  33. package/dist/harnesses/opencode/harness.js +37 -0
  34. package/dist/harnesses/opencode/install.js +601 -0
  35. package/dist/harnesses/opencode/native.js +57 -0
  36. package/dist/harnesses/opencode/package.js +89 -0
  37. package/dist/harnesses/opencode/paths.js +38 -0
  38. package/dist/harnesses/opencode/prepare.js +30 -0
  39. package/dist/harnesses/opencode/presentation.js +28 -0
  40. package/dist/harnesses/opencode/state.js +174 -0
  41. package/dist/harnesses/pi/compatibility.js +5 -37
  42. package/dist/harnesses/pi/install.js +7 -5
  43. package/dist/harnesses/pi/native.js +3 -47
  44. package/dist/harnesses/pi/package.js +5 -107
  45. package/dist/harnesses/pi/prepare.js +13 -104
  46. package/dist/harnesses/pi/presentation.js +23 -66
  47. package/dist/harnesses/pi/shared-skills.js +28 -39
  48. package/dist/harnesses/pi/state.js +7 -27
  49. package/dist/provenance.js +2 -2
  50. package/dist/python-json-format.js +0 -69
  51. package/dist/python-json.js +2 -27
  52. package/dist/python-text.js +9 -69
  53. package/dist/safe-path.js +2 -2
  54. package/dist/selection-store.js +2 -22
  55. package/dist/snapshot-package.js +78 -0
  56. package/dist/snapshot-prepare.js +99 -0
  57. package/dist/snapshot-presentation.js +44 -0
  58. package/dist/validator.js +39 -73
  59. package/node_modules/jsonc-parser/CHANGELOG.md +76 -0
  60. package/node_modules/jsonc-parser/LICENSE.md +21 -0
  61. package/node_modules/jsonc-parser/README.md +364 -0
  62. package/node_modules/jsonc-parser/SECURITY.md +41 -0
  63. package/node_modules/jsonc-parser/lib/esm/impl/edit.js +185 -0
  64. package/node_modules/jsonc-parser/lib/esm/impl/format.js +261 -0
  65. package/node_modules/jsonc-parser/lib/esm/impl/parser.js +659 -0
  66. package/node_modules/jsonc-parser/lib/esm/impl/scanner.js +443 -0
  67. package/node_modules/jsonc-parser/lib/esm/impl/string-intern.js +29 -0
  68. package/node_modules/jsonc-parser/lib/esm/main.d.ts +351 -0
  69. package/node_modules/jsonc-parser/lib/esm/main.js +178 -0
  70. package/node_modules/jsonc-parser/lib/umd/impl/edit.js +201 -0
  71. package/node_modules/jsonc-parser/lib/umd/impl/format.js +275 -0
  72. package/node_modules/jsonc-parser/lib/umd/impl/parser.js +682 -0
  73. package/node_modules/jsonc-parser/lib/umd/impl/scanner.js +456 -0
  74. package/node_modules/jsonc-parser/lib/umd/impl/string-intern.js +42 -0
  75. package/node_modules/jsonc-parser/lib/umd/main.d.ts +351 -0
  76. package/node_modules/jsonc-parser/lib/umd/main.js +194 -0
  77. package/node_modules/jsonc-parser/package.json +37 -0
  78. package/package.json +8 -4
  79. package/dist/domain/fingerprint.js +0 -4
  80. package/dist/selection-state-cli.js +0 -85
  81. package/dist/status.js +0 -24
  82. package/dist/upstream-cli.js +0 -110
  83. package/dist/validate-generated-plugin-cli.js +0 -118
@@ -1,29 +1,4 @@
1
+ import { escapeNonAscii } from "./python-json-format.js";
1
2
  export function escapePythonJsonString(value) {
2
- let escaped = "";
3
- const shortEscapes = {
4
- 0x08: "\\b",
5
- 0x09: "\\t",
6
- 0x0a: "\\n",
7
- 0x0c: "\\f",
8
- 0x0d: "\\r",
9
- };
10
- for (let index = 0; index < value.length; index += 1) {
11
- const code = value.charCodeAt(index);
12
- if (code === 0x22) {
13
- escaped += '\\"';
14
- }
15
- else if (code === 0x5c) {
16
- escaped += "\\\\";
17
- }
18
- else if (Object.hasOwn(shortEscapes, code)) {
19
- escaped += shortEscapes[code];
20
- }
21
- else if (code < 0x20 || code >= 0x7f) {
22
- escaped += `\\u${code.toString(16).padStart(4, "0")}`;
23
- }
24
- else {
25
- escaped += value[index];
26
- }
27
- }
28
- return escaped;
3
+ return escapeNonAscii(JSON.stringify(value).slice(1, -1));
29
4
  }
@@ -1,75 +1,15 @@
1
- // CPython `str.strip()` whitespace, from Unicode's Bidi/White_Space tables as
2
- // CPython applies them. Deliberately excludes U+FEFF, which JavaScript's
3
- // `trim()` removes and Python's `strip()` does not.
4
- const PYTHON_WHITESPACE = new Set([
5
- "\t",
6
- "\n",
7
- "\v",
8
- "\f",
9
- "\r",
10
- " ",
11
- "\x1c",
12
- "\x1d",
13
- "\x1e",
14
- "\x1f",
15
- "\x85",
16
- " ",
17
- " ",
18
- " ",
19
- " ",
20
- " ",
21
- " ",
22
- " ",
23
- " ",
24
- " ",
25
- " ",
26
- " ",
27
- " ",
28
- " ",
29
- "
",
30
- "
",
31
- " ",
32
- " ",
33
- " ",
34
- ]);
35
- // CPython `str.splitlines()` boundaries. `\r\n` is handled as a pair below.
36
- const PYTHON_LINE_BOUNDARIES = new Set([
37
- "\n",
38
- "\r",
39
- "\v",
40
- "\f",
41
- "\x1c",
42
- "\x1d",
43
- "\x1e",
44
- "\x85",
45
- "
",
46
- "
",
47
- ]);
1
+ // Match first through last non-whitespace; avoid suffix retries on internal runs.
48
2
  export function pythonStrip(value) {
49
- let start = 0;
50
- let end = value.length;
51
- while (start < end && PYTHON_WHITESPACE.has(value[start]))
52
- start += 1;
53
- while (end > start && PYTHON_WHITESPACE.has(value[end - 1]))
54
- end -= 1;
55
- return value.slice(start, end);
3
+ return (value.match(
4
+ // oxlint-disable-next-line no-control-regex -- exact CPython whitespace includes C0 separators.
5
+ /[^\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028-\u2029\u202f\u205f\u3000](?:[\s\S]*[^\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028-\u2029\u202f\u205f\u3000])?/)?.[0] ?? "");
56
6
  }
7
+ // CRLF is one boundary; a terminating boundary adds no final empty line.
57
8
  export function pythonSplitlines(value) {
58
- const lines = [];
59
- let start = 0;
60
- let index = 0;
61
- while (index < value.length) {
62
- const character = value[index];
63
- if (!PYTHON_LINE_BOUNDARIES.has(character)) {
64
- index += 1;
65
- continue;
66
- }
67
- lines.push(value.slice(start, index));
68
- index += character === "\r" && value[index + 1] === "\n" ? 2 : 1;
69
- start = index;
70
- }
71
- if (start < value.length)
72
- lines.push(value.slice(start));
9
+ // oxlint-disable-next-line no-control-regex -- CPython boundaries include C0 controls.
10
+ const lines = value.split(/\r\n|[\n\r\v\f\u001c-\u001e\u0085\u2028\u2029]/);
11
+ if (lines.at(-1) === "")
12
+ lines.pop();
73
13
  return lines;
74
14
  }
75
15
  /** Python `sorted()` orders by code point; JavaScript's default sort does not. */
package/dist/safe-path.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { lstat, readlink, realpath } from "node:fs/promises";
2
2
  import { basename, dirname, isAbsolute, relative, resolve, sep, } from "node:path";
3
3
  import { SafetyError } from "./safety-error.js";
4
- function isErrno(value, code) {
4
+ export function isErrno(value, code) {
5
5
  return (value instanceof Error &&
6
6
  "code" in value &&
7
7
  value.code === code);
8
8
  }
9
- function isContained(root, candidate) {
9
+ export function isContained(root, candidate) {
10
10
  const suffix = relative(root, candidate);
11
11
  return (suffix === "" ||
12
12
  (!isAbsolute(suffix) && suffix !== ".." && !suffix.startsWith(`..${sep}`)));
@@ -1,7 +1,7 @@
1
1
  import { constants } from "node:fs";
2
2
  import { mkdir, open } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
- import { atomicWriteFile, } from "./atomic.js";
4
+ import { atomicWriteFile } from "./atomic.js";
5
5
  import { classifyPathNoFollow } from "./safe-path.js";
6
6
  import { SafetyError } from "./safety-error.js";
7
7
  import { selectionError, serializeRecord, validateRecord, } from "./selection.js";
@@ -132,19 +132,6 @@ async function ensureStateDirectory(path) {
132
132
  throw selectionError(`selection state directory is not usable: ${path}`);
133
133
  }
134
134
  }
135
- async function finalStateDiagnostic(path, finalBytes) {
136
- try {
137
- const record = finalBytes === undefined
138
- ? await readSelectionState(path)
139
- : parseRecordBytes(path, finalBytes);
140
- return record === null
141
- ? "selection state is now absent"
142
- : `selection state is now ${record.mode}`;
143
- }
144
- catch (cause) {
145
- return `final selection state cannot be validated: ${errorText(cause)}`;
146
- }
147
- }
148
135
  export async function writeSelectionState(path, proposed, options = {}) {
149
136
  await ensureStateDirectory(dirname(path));
150
137
  // Invalid existing state must block overwrite.
@@ -160,13 +147,6 @@ export async function writeSelectionState(path, proposed, options = {}) {
160
147
  });
161
148
  }
162
149
  catch (cause) {
163
- const details = cause instanceof SafetyError && cause.module === "atomic"
164
- ? cause.details
165
- : undefined;
166
- if (details?.phase !== "post-replacement") {
167
- throw selectionError(`cannot write selection state: ${causedErrorText(cause)}`, cause);
168
- }
169
- const diagnostic = await finalStateDiagnostic(path, details.finalBytes);
170
- throw selectionError(`cannot complete selection state write: ${causedErrorText(cause)}; ${diagnostic}`, cause);
150
+ throw selectionError(`cannot write selection state: ${causedErrorText(cause)}`, cause);
171
151
  }
172
152
  }
@@ -0,0 +1,78 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { addArtifactHashField, digestArtifactTree, readArtifactFile, } from "./artifact-tree.js";
5
+ import { classifyPathNoFollow } from "./safe-path.js";
6
+ import { validateSource } from "./selection.js";
7
+ const OFFICIAL_SOURCES = new Set([
8
+ "https://github.com/obra/superpowers",
9
+ "https://github.com/obra/superpowers.git",
10
+ "ssh://git@github.com/obra/superpowers.git",
11
+ "git@github.com:obra/superpowers.git",
12
+ ]);
13
+ export async function observeSnapshot(root, readReceipt) {
14
+ const kind = await classifyPathNoFollow(root);
15
+ if (kind === "missing")
16
+ return { kind: "absent" };
17
+ if (kind !== "directory")
18
+ return { kind: "unverified" };
19
+ try {
20
+ const receipt = await readReceipt(root);
21
+ const digest = await digestArtifactTree(root);
22
+ return receipt.digest === digest
23
+ ? { kind: "owned", receipt, digest }
24
+ : { kind: "unverified" };
25
+ }
26
+ catch {
27
+ return { kind: "unverified" };
28
+ }
29
+ }
30
+ export function isOfficialSnapshotSource(source) {
31
+ return OFFICIAL_SOURCES.has(source);
32
+ }
33
+ export function sameSnapshotSource(left, right) {
34
+ validateSource(left);
35
+ validateSource(right);
36
+ return (left === right ||
37
+ (isOfficialSnapshotSource(left) && isOfficialSnapshotSource(right)));
38
+ }
39
+ // A consistency checksum, not authentication. A coherent manual rewrite of
40
+ // both receipt and bytes remains possible; installed comparison must still use
41
+ // an independently validated candidate.
42
+ export function snapshotReceiptBinding(receipt) {
43
+ const hash = createHash("sha256");
44
+ for (const field of [
45
+ String(receipt.schema),
46
+ receipt.manager,
47
+ receipt.harness,
48
+ receipt.source,
49
+ receipt.commit,
50
+ receipt.digest,
51
+ ])
52
+ addArtifactHashField(hash, Buffer.from(field));
53
+ return hash.digest("hex");
54
+ }
55
+ export function requireNoSnapshotDependencies(pkg) {
56
+ for (const key of [
57
+ "dependencies",
58
+ "optionalDependencies",
59
+ "peerDependencies",
60
+ "bundleDependencies",
61
+ "bundledDependencies",
62
+ ]) {
63
+ const value = pkg[key];
64
+ if (value !== undefined &&
65
+ (value === null ||
66
+ typeof value !== "object" ||
67
+ Object.keys(value).length !== 0))
68
+ throw new Error("runtime dependencies");
69
+ }
70
+ }
71
+ export async function requireSnapshotBootstrap(root, relativePath, expectedSize, expectedDigest) {
72
+ const bootstrap = join(root, relativePath);
73
+ const bytes = await readArtifactFile(root, bootstrap, expectedSize);
74
+ if ((await lstat(bootstrap)).mode & 0o111 ||
75
+ bytes.length !== expectedSize ||
76
+ createHash("sha256").update(bytes).digest("hex") !== expectedDigest)
77
+ throw new Error("bootstrap implementation");
78
+ }
@@ -0,0 +1,99 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { failureResult, successResult, } from "./adapter-result.js";
4
+ import { ARTIFACT_RECEIPT, digestArtifactTree } from "./artifact-tree.js";
5
+ import { materializeGitTree } from "./git-tree.js";
6
+ import { classifyPathNoFollow } from "./safe-path.js";
7
+ import { sameSnapshotSource, snapshotReceiptBinding, } from "./snapshot-package.js";
8
+ export function createSnapshotPreparation(b) {
9
+ const artifactFrom = (root, a) => ({
10
+ root,
11
+ commit: a.receipt.commit,
12
+ identity: a.receipt.digest,
13
+ compatibility: a.compatibility,
14
+ });
15
+ return {
16
+ async prepareCandidate(input) {
17
+ try {
18
+ await materializeGitTree(input.upstreamRoot, input.selection.desiredCommit, input.candidateRoot);
19
+ const compatibility = await b.assessCompatibility(input.candidateRoot, input.selection);
20
+ if (compatibility.kind === "unsupported" ||
21
+ compatibility.kind === "unknown")
22
+ return failureResult("prepare", "unsupported", compatibility.reason, [], []);
23
+ const digest = await digestArtifactTree(input.candidateRoot);
24
+ const identity = {
25
+ schema: 1,
26
+ manager: "superpowers-manager",
27
+ harness: b.harness,
28
+ source: input.selection.effectiveSource,
29
+ commit: input.selection.desiredCommit,
30
+ digest,
31
+ };
32
+ const receipt = {
33
+ ...identity,
34
+ binding: snapshotReceiptBinding(identity),
35
+ compatibility,
36
+ };
37
+ await writeFile(join(input.candidateRoot, ARTIFACT_RECEIPT), JSON.stringify(receipt) + "\n", { flag: "wx" });
38
+ return successResult("prepare", artifactFrom(input.candidateRoot, { receipt, compatibility }), []);
39
+ }
40
+ catch {
41
+ return failureResult("prepare", "invalid-package", `cannot prepare ${b.label} artifact: ${input.candidateRoot}`, [], []);
42
+ }
43
+ },
44
+ async inspectPrepared(selection, ctx) {
45
+ const root = b.preparationLocation(ctx).destinationRoot;
46
+ const unknown = {
47
+ kind: "unknown",
48
+ reason: `${b.label} prepared compatibility evidence is missing`,
49
+ };
50
+ try {
51
+ if ((await classifyPathNoFollow(join(root, ARTIFACT_RECEIPT))) ===
52
+ "missing")
53
+ return successResult("inspect-prepared", {
54
+ kind: "needs-prepare",
55
+ observedIdentity: "",
56
+ compatibility: unknown,
57
+ }, []);
58
+ const assessment = await b.readAssessment(root);
59
+ const { receipt, compatibility } = assessment;
60
+ if (receipt.commit !== selection.desiredCommit ||
61
+ !sameSnapshotSource(receipt.source, selection.effectiveSource))
62
+ return successResult("inspect-prepared", {
63
+ kind: "needs-prepare",
64
+ observedIdentity: receipt.digest,
65
+ compatibility: unknown,
66
+ }, []);
67
+ if (compatibility.kind !== "supported" &&
68
+ compatibility.kind !== "experimental")
69
+ return successResult("inspect-prepared", {
70
+ kind: "needs-prepare",
71
+ observedIdentity: receipt.digest,
72
+ compatibility,
73
+ }, []);
74
+ return successResult("inspect-prepared", {
75
+ kind: "current",
76
+ artifact: artifactFrom(root, assessment),
77
+ observedIdentity: receipt.digest,
78
+ compatibility,
79
+ }, []);
80
+ }
81
+ catch {
82
+ return failureResult("inspect-prepared", "invalid-package", `cannot inspect ${b.label} prepared artifact: ${root}`, [], []);
83
+ }
84
+ },
85
+ async readPrepared(ctx) {
86
+ const root = b.preparationLocation(ctx).destinationRoot;
87
+ try {
88
+ const assessment = await b.readAssessment(root);
89
+ if (assessment.compatibility.kind !== "supported" &&
90
+ assessment.compatibility.kind !== "experimental")
91
+ throw new Error("unsupported profile");
92
+ return successResult("read-prepared", artifactFrom(root, assessment), []);
93
+ }
94
+ catch {
95
+ return failureResult("read-prepared", "invalid-package", `cannot read ${b.label} prepared artifact: ${root}`, [], []);
96
+ }
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,44 @@
1
+ export function createSnapshotPresentation(label, renderProbe, wasAbsent) {
2
+ return {
3
+ installNotice: "",
4
+ currentNotice: `The Superpowers ${label} snapshot is current.`,
5
+ renderProbe,
6
+ renderInstallVerification(_desired, receipt, inspection) {
7
+ if (receipt.status !== 0 || !receipt.outcome.ok)
8
+ return {
9
+ stdout: [],
10
+ stderr: [
11
+ `error: ${label} activation did not return a verified installation receipt`,
12
+ ],
13
+ };
14
+ if (inspection.status !== 0 || !inspection.outcome.ok)
15
+ return receipt.outcome.result.missingVerificationOutput;
16
+ if (inspection.outcome.result.kind === "current")
17
+ return {
18
+ stdout: [
19
+ `Installed the frozen Superpowers ${label} snapshot. Restart ${label} to load it.`,
20
+ ],
21
+ stderr: [],
22
+ };
23
+ return inspection.outcome.result.kind === "absent"
24
+ ? receipt.outcome.result.missingVerificationOutput
25
+ : receipt.outcome.result.mismatchVerificationOutput;
26
+ },
27
+ renderRemovalCompletion(_ownership, input) {
28
+ return {
29
+ stdout: [
30
+ wasAbsent(input)
31
+ ? `No managed Superpowers ${label} installation is present.`
32
+ : `Removed the managed Superpowers ${label} installation. Restart ${label} to load the resulting state.`,
33
+ ],
34
+ stderr: [],
35
+ };
36
+ },
37
+ callFailure(site) {
38
+ return {
39
+ unexpected: `${label} ${site} did not complete`,
40
+ invalidStatus: `${label} ${site} returned an invalid status`,
41
+ };
42
+ },
43
+ };
44
+ }
package/dist/validator.js CHANGED
@@ -2,21 +2,16 @@ import { spawn } from "node:child_process";
2
2
  import { lstat, realpath, stat } from "node:fs/promises";
3
3
  import { sep } from "node:path";
4
4
  import { hasTerminalControl, pythonUnicodeEscapeBytes, } from "./adapter-result.js";
5
- // The legacy SUPERPOWERS_VALIDATOR path. Its behaviour is frozen by parity, so it
6
- // carries no limits at all; see the spec's D2. Bounding it is PR 12's row.
7
- export const UNBOUNDED_LEGACY = { kind: "unbounded" };
8
5
  // SUPERPOWERS_VALIDATOR_EXECUTABLE. 30s is ~270x a measured realistic validator run
9
6
  // (0.11s, dominated by interpreter startup); 64 KiB per stream clears a
10
7
  // one-diagnostic-line-per-file report (30 KB on a real tree) with headroom.
11
8
  export const BOUNDED_EXECUTABLE = {
12
- kind: "bounded",
13
9
  timeoutMs: 30_000,
14
10
  graceMs: 2_000,
15
11
  drainMs: 200,
16
12
  maxBytesPerStream: 64 * 1024,
17
13
  };
18
- // Retains the first `limit` bytes and counts the rest. A null limit retains
19
- // everything, which is the unbounded policy's contract. push() always consumes the
14
+ // Retains the first `limit` bytes and counts the rest. push() always consumes the
20
15
  // whole chunk: ceasing to consume would block the child on a full pipe.
21
16
  class Sink {
22
17
  chunks = [];
@@ -27,10 +22,6 @@ class Sink {
27
22
  this.limit = limit;
28
23
  }
29
24
  push(chunk) {
30
- if (this.limit === null) {
31
- this.chunks.push(chunk);
32
- return;
33
- }
34
25
  const room = this.limit - this.kept;
35
26
  if (room <= 0) {
36
27
  this.dropped += chunk.length;
@@ -42,21 +33,15 @@ class Sink {
42
33
  this.dropped += chunk.length - take.length;
43
34
  }
44
35
  done() {
45
- // Buffer.toString, NOT TextDecoder: TextDecoder strips a leading BOM and the
46
- // legacy path's current string accumulator preserves it. Measured:
47
- // decode(<BOM>hi) is "hi" but toString("utf8") is "\ufeffhi". The legacy path
48
- // must be byte-identical, so BOTH paths use toString.
36
+ // Buffer.toString preserves a leading BOM; TextDecoder would strip it.
49
37
  return {
50
38
  text: Buffer.concat(this.chunks).toString("utf8"),
51
39
  droppedBytes: this.dropped,
52
40
  };
53
41
  }
54
42
  }
55
- // Signal the child's whole process group. Only meaningful for a child spawned
56
- // under the BOUNDED policy: that spawn passes `detached: true`, so the child
57
- // leads a group of its own and -pid reaches its descendants. The LEGACY path
58
- // spawns without `detached`, so its child does not lead a group and this
59
- // function is not for it. Wrapped because the group is already gone in the
43
+ // Signal the child's whole process group. The child leads a group of its own,
44
+ // so -pid reaches descendants. Wrapped because the group is already gone in
60
45
  // ordinary race between SIGTERM and SIGKILL.
61
46
  function signalGroup(child, signal) {
62
47
  if (child.pid === undefined)
@@ -71,9 +56,8 @@ function signalGroup(child, signal) {
71
56
  export function runValidator(argv, policy, env, workspace, cwd) {
72
57
  return new Promise((resolveRun) => {
73
58
  const [command, ...args] = argv;
74
- const limit = policy.kind === "bounded" ? policy.maxBytesPerStream : null;
75
- const out = new Sink(limit);
76
- const err = new Sink(limit);
59
+ const out = new Sink(policy.maxBytesPerStream);
60
+ const err = new Sink(policy.maxBytesPerStream);
77
61
  let settled = false;
78
62
  // Settlement and termination are independent BY CONSTRUCTION, which is why
79
63
  // there is no grace-timer handle to clear here: the timedOut settle is nested
@@ -109,24 +93,22 @@ export function runValidator(argv, policy, env, workspace, cwd) {
109
93
  // not an unhandled rejection -- which is what this try/catch prevents.
110
94
  let child;
111
95
  try {
96
+ const childEnvironment = policy.inheritEnvironment === false
97
+ ? { ...env, TMPDIR: workspace }
98
+ : { ...process.env, ...env, TMPDIR: workspace };
112
99
  child = spawn(command, args, {
113
- env: { ...process.env, ...env, TMPDIR: workspace },
100
+ env: childEnvironment,
114
101
  cwd,
115
102
  stdio: ["ignore", "pipe", "pipe"],
116
- // D4: under the BOUNDED policy only, the child leads its own process group
117
- // so a timeout can signal the GROUP -- signalling the process alone leaves
118
- // descendants holding the inherited pipes, and `close` then waits for them
119
- // (measured at 5279 ms against a 300 ms timeout). The LEGACY path must not
120
- // acquire group leadership: that changes process and session semantics for
121
- // a path whose whole contract is that it is unchanged.
122
- detached: policy.kind === "bounded",
103
+ // The child leads its own process group so a timeout can signal the GROUP --
104
+ // signalling the process alone leaves descendants holding inherited pipes.
105
+ detached: true,
123
106
  });
124
107
  }
125
108
  catch (cause) {
126
109
  settle({
127
110
  kind: "launchFailed",
128
111
  errno: cause.code ?? "UNKNOWN",
129
- cause,
130
112
  });
131
113
  return;
132
114
  }
@@ -136,7 +118,7 @@ export function runValidator(argv, policy, env, workspace, cwd) {
136
118
  child.stderr.on("data", (chunk) => err.push(chunk));
137
119
  // Both error and close fire on a launch failure, so settle() is idempotent.
138
120
  child.on("error", (cause) => {
139
- settle({ kind: "launchFailed", errno: cause.code ?? "UNKNOWN", cause });
121
+ settle({ kind: "launchFailed", errno: cause.code ?? "UNKNOWN" });
140
122
  });
141
123
  child.on("close", (code) => {
142
124
  // On the timeout path, settling here would resolve BEFORE SIGKILL and the
@@ -154,12 +136,6 @@ export function runValidator(argv, policy, env, workspace, cwd) {
154
136
  // may lag it indefinitely. Capture the code here or a successful validator
155
137
  // whose descendant lingers would settle with a null code and be rejected.
156
138
  lastCode = code;
157
- // BOUNDED ONLY. The legacy path settles on `close`, exactly as it does today:
158
- // a validator that backgrounds `(sleep 0.6; echo late) &` has "late" captured
159
- // under `close` and lost under a 200 ms drain, and that output is captured
160
- // today.
161
- if (policy.kind !== "bounded")
162
- return;
163
139
  // The TIMEOUT path owns its own settlement, after the escalation has run.
164
140
  if (timedOut)
165
141
  return;
@@ -186,31 +162,29 @@ export function runValidator(argv, policy, env, workspace, cwd) {
186
162
  });
187
163
  }, policy.drainMs);
188
164
  });
189
- if (policy.kind === "bounded") {
190
- timeoutTimer = setTimeout(() => {
191
- timedOut = true;
192
- // Signal the GROUP, not the process: descendants hold the pipes. SIGTERM
193
- // first, because the contract promises stderr carries the reason, and
194
- // reading continues through the grace window so the child can print it.
195
- signalGroup(child, "SIGTERM");
165
+ timeoutTimer = setTimeout(() => {
166
+ timedOut = true;
167
+ // Signal the GROUP, not the process: descendants hold the pipes. SIGTERM
168
+ // first, because the contract promises stderr carries the reason, and
169
+ // reading continues through the grace window so the child can print it.
170
+ signalGroup(child, "SIGTERM");
171
+ setTimeout(() => {
172
+ signalGroup(child, "SIGKILL");
173
+ // Settle ONLY after SIGKILL has been sent. An earlier revision settled
174
+ // first and left the escalation on an unref'ed timer, which never fires
175
+ // because the CLI exits as soon as its handler returns -- measured, the
176
+ // manager exited at 146 ms and the descendant survived. The wall-clock
177
+ // bound is therefore timeout + grace + drain, which is still a bound.
196
178
  setTimeout(() => {
197
- signalGroup(child, "SIGKILL");
198
- // Settle ONLY after SIGKILL has been sent. An earlier revision settled
199
- // first and left the escalation on an unref'ed timer, which never fires
200
- // because the CLI exits as soon as its handler returns -- measured, the
201
- // manager exited at 146 ms and the descendant survived. The wall-clock
202
- // bound is therefore timeout + grace + drain, which is still a bound.
203
- setTimeout(() => {
204
- settle({
205
- kind: "timedOut",
206
- afterMs: policy.timeoutMs,
207
- stdout: out.done(),
208
- stderr: err.done(),
209
- });
210
- }, policy.drainMs);
211
- }, policy.graceMs);
212
- }, policy.timeoutMs);
213
- }
179
+ settle({
180
+ kind: "timedOut",
181
+ afterMs: policy.timeoutMs,
182
+ stdout: out.done(),
183
+ stderr: err.done(),
184
+ });
185
+ }, policy.drainMs);
186
+ }, policy.graceMs);
187
+ }, policy.timeoutMs);
214
188
  });
215
189
  }
216
190
  // Every probe below is best-effort BY DESIGN: spawn is the authority on whether the
@@ -297,22 +271,14 @@ const VALIDATOR_COMMANDS = new Set([
297
271
  "install",
298
272
  "update",
299
273
  ]);
300
- // Legacy emptiness semantics exactly: empty string means unset, whitespace-only
301
- // counts as set. No trimming — a trim would silently change legacy behaviour.
302
- export function bothConfigured(env) {
303
- return ((env.SUPERPOWERS_VALIDATOR || "").length > 0 &&
304
- (env.SUPERPOWERS_VALIDATOR_EXECUTABLE || "").length > 0);
305
- }
306
274
  // The second exported accessor preflight derives its enforcement from. It is an
307
275
  // accessor rather than an inline check because an accessor that under-reports what
308
276
  // preflight enforces is the blind spot slice 2 closed.
309
277
  export function configurationErrors(cmd, env) {
310
- if (!VALIDATOR_COMMANDS.has(cmd))
311
- return [];
312
- if (!bothConfigured(env))
278
+ if (!VALIDATOR_COMMANDS.has(cmd) || !env.SUPERPOWERS_VALIDATOR)
313
279
  return [];
314
280
  return [
315
- "SUPERPOWERS_VALIDATOR and SUPERPOWERS_VALIDATOR_EXECUTABLE are both set — " +
316
- "unset one; the manager never chooses between them silently",
281
+ "SUPERPOWERS_VALIDATOR has been removed; unset it and configure " +
282
+ "SUPERPOWERS_VALIDATOR_EXECUTABLE with an executable validator.",
317
283
  ];
318
284
  }