superpowers-manager 0.5.1 → 0.6.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 (51) hide show
  1. package/README.md +13 -11
  2. package/dist/cli.js +9 -14
  3. package/dist/commands/install.js +18 -9
  4. package/dist/commands/mutation.js +7 -0
  5. package/dist/commands/prepare.js +5 -51
  6. package/dist/commands/probe.js +8 -10
  7. package/dist/commands/uninstall.js +2 -2
  8. package/dist/effective-selection.js +3 -3
  9. package/dist/harnesses/codex/adapter.js +336 -457
  10. package/dist/harnesses/codex/compatibility.js +2 -17
  11. package/dist/harnesses/codex/generated-plugin.js +1 -1
  12. package/dist/harnesses/codex/harness.js +45 -272
  13. package/dist/harnesses/codex/hooks.js +1 -1
  14. package/dist/harnesses/codex/json.js +6 -2
  15. package/dist/harnesses/codex/lifecycle.js +110 -54
  16. package/dist/harnesses/codex/manifest-overlay.js +22 -17
  17. package/dist/harnesses/codex/marketplace.js +148 -0
  18. package/dist/harnesses/codex/paths.js +97 -0
  19. package/dist/harnesses/codex/prepare.js +34 -2
  20. package/dist/harnesses/codex/presentation.js +10 -17
  21. package/dist/harnesses/codex/publication.js +520 -0
  22. package/dist/harnesses/codex/recovery.js +338 -0
  23. package/dist/harnesses/codex/state.js +131 -0
  24. package/dist/harnesses/codex/stored-state.js +249 -0
  25. package/dist/provenance.js +3 -3
  26. package/dist/python-json-format.js +0 -69
  27. package/dist/python-json.js +2 -27
  28. package/dist/python-text.js +9 -69
  29. package/dist/validator.js +35 -72
  30. package/node_modules/smol-toml/LICENSE +24 -0
  31. package/node_modules/smol-toml/README.md +418 -0
  32. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  33. package/node_modules/smol-toml/dist/date.js +127 -0
  34. package/node_modules/smol-toml/dist/error.d.ts +38 -0
  35. package/node_modules/smol-toml/dist/error.js +63 -0
  36. package/node_modules/smol-toml/dist/extract.js +69 -0
  37. package/node_modules/smol-toml/dist/index.cjs +734 -0
  38. package/node_modules/smol-toml/dist/index.d.ts +43 -0
  39. package/node_modules/smol-toml/dist/index.js +33 -0
  40. package/node_modules/smol-toml/dist/parse.d.ts +36 -0
  41. package/node_modules/smol-toml/dist/parse.js +149 -0
  42. package/node_modules/smol-toml/dist/primitive.js +238 -0
  43. package/node_modules/smol-toml/dist/stringify.d.ts +31 -0
  44. package/node_modules/smol-toml/dist/stringify.js +181 -0
  45. package/node_modules/smol-toml/dist/struct.js +179 -0
  46. package/node_modules/smol-toml/dist/util.d.ts +38 -0
  47. package/node_modules/smol-toml/dist/util.js +89 -0
  48. package/node_modules/smol-toml/package.json +68 -0
  49. package/package.json +10 -2
  50. package/dist/selection-state-cli.js +0 -85
  51. package/dist/upstream-cli.js +0 -110
@@ -0,0 +1,249 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open, readdir } from "node:fs/promises";
3
+ import { isAbsolute, join } from "node:path";
4
+ import { parse, TomlDate } from "smol-toml";
5
+ import { SafetyError } from "../../safety-error.js";
6
+ import { codexHome } from "./paths.js";
7
+ const CONFIG_LIMIT = 1024 * 1024;
8
+ const CONFIG_DEPTH_LIMIT = 64;
9
+ const MANAGER_MARKETPLACE = "superpowers-manager";
10
+ const LEGACY_MARKETPLACE = "superpowers-wrapper";
11
+ const MANAGER_PLUGIN = `superpowers@${MANAGER_MARKETPLACE}`;
12
+ const LEGACY_PLUGIN = `superpowers@${LEGACY_MARKETPLACE}`;
13
+ const VERSION_SEGMENT = /^[A-Za-z0-9._+-]+$/u;
14
+ const MARKETPLACE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
15
+ function storedError(message, cause) {
16
+ return new SafetyError("codex-stored-state", message, { cause });
17
+ }
18
+ function isErrno(cause, code) {
19
+ return (cause !== null &&
20
+ typeof cause === "object" &&
21
+ "code" in cause &&
22
+ cause.code === code);
23
+ }
24
+ function table(value, name) {
25
+ if (value === undefined)
26
+ return {};
27
+ if (value === null ||
28
+ typeof value !== "object" ||
29
+ Array.isArray(value) ||
30
+ value instanceof TomlDate ||
31
+ Object.getPrototypeOf(value) !== Object.prototype) {
32
+ throw storedError(`Codex configuration ${name} must be a table`);
33
+ }
34
+ return value;
35
+ }
36
+ async function readConfig(path) {
37
+ let handle;
38
+ try {
39
+ const initial = await lstat(path);
40
+ if (!initial.isFile() || initial.isSymbolicLink()) {
41
+ throw storedError(`Codex configuration must be a regular file: ${path}`);
42
+ }
43
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
44
+ const before = await handle.stat();
45
+ if (!before.isFile() || before.size > CONFIG_LIMIT) {
46
+ throw storedError(`Codex configuration must be a bounded regular file: ${path}`);
47
+ }
48
+ const buffer = Buffer.alloc(CONFIG_LIMIT + 1);
49
+ let length = 0;
50
+ while (length < buffer.length) {
51
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
52
+ if (bytesRead === 0)
53
+ break;
54
+ length += bytesRead;
55
+ }
56
+ const bytes = buffer.subarray(0, length);
57
+ const after = await handle.stat();
58
+ const pathAfter = await lstat(path);
59
+ if (bytes.length > CONFIG_LIMIT ||
60
+ bytes.length !== before.size ||
61
+ before.dev !== after.dev ||
62
+ before.ino !== after.ino ||
63
+ before.size !== after.size ||
64
+ before.mtimeMs !== after.mtimeMs ||
65
+ before.ctimeMs !== after.ctimeMs ||
66
+ before.dev !== pathAfter.dev ||
67
+ before.ino !== pathAfter.ino ||
68
+ !pathAfter.isFile() ||
69
+ pathAfter.isSymbolicLink()) {
70
+ throw storedError(`Codex configuration changed while being read: ${path}`);
71
+ }
72
+ let text;
73
+ try {
74
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
75
+ }
76
+ catch (cause) {
77
+ throw storedError(`Codex configuration is not valid UTF-8: ${path}`, cause);
78
+ }
79
+ try {
80
+ return parse(text, {
81
+ maxDepth: CONFIG_DEPTH_LIMIT,
82
+ integersAsBigInt: true,
83
+ });
84
+ }
85
+ catch (cause) {
86
+ throw storedError(`Codex configuration TOML is invalid: ${path}`, cause);
87
+ }
88
+ }
89
+ catch (cause) {
90
+ if (cause instanceof SafetyError && cause.module === "codex-stored-state") {
91
+ throw cause;
92
+ }
93
+ throw storedError(`cannot safely read Codex configuration: ${path}`, cause);
94
+ }
95
+ finally {
96
+ await handle?.close().catch(() => { });
97
+ }
98
+ }
99
+ function marketplaceRoot(marketplaces, name) {
100
+ const value = marketplaces[name];
101
+ if (value === undefined)
102
+ return null;
103
+ const entry = table(value, `marketplaces.${name}`);
104
+ if (entry.source_type !== "local" ||
105
+ typeof entry.source !== "string" ||
106
+ !isAbsolute(entry.source)) {
107
+ throw storedError(`Codex marketplace ${name} must name an absolute local source`);
108
+ }
109
+ return entry.source;
110
+ }
111
+ async function requireMissingManagerSource(path) {
112
+ try {
113
+ await lstat(path);
114
+ }
115
+ catch (cause) {
116
+ if (isErrno(cause, "ENOENT"))
117
+ return;
118
+ throw storedError(`cannot inspect Codex marketplace source: ${path}`, cause);
119
+ }
120
+ throw storedError(`Codex marketplace source is not missing: ${path}`);
121
+ }
122
+ function validVersionSegment(value) {
123
+ return value !== "." && value !== ".." && VERSION_SEGMENT.test(value);
124
+ }
125
+ async function cachedVersionPresent(searchRoot, marketplace) {
126
+ if (!MARKETPLACE_SEGMENT.test(marketplace))
127
+ return null;
128
+ let base = searchRoot;
129
+ for (const segment of ["plugins", "cache", marketplace, "superpowers"]) {
130
+ let details;
131
+ try {
132
+ details = await lstat(base);
133
+ }
134
+ catch (cause) {
135
+ if (isErrno(cause, "ENOENT"))
136
+ return false;
137
+ throw storedError(`cannot inspect Codex plugin cache: ${base}`, cause);
138
+ }
139
+ if (!details.isDirectory() || details.isSymbolicLink()) {
140
+ throw storedError(`Codex plugin cache must be a directory: ${base}`);
141
+ }
142
+ base = join(base, segment);
143
+ }
144
+ let before;
145
+ try {
146
+ before = await lstat(base);
147
+ }
148
+ catch (cause) {
149
+ if (isErrno(cause, "ENOENT"))
150
+ return false;
151
+ throw storedError(`cannot inspect Codex plugin cache: ${base}`, cause);
152
+ }
153
+ if (!before.isDirectory() || before.isSymbolicLink()) {
154
+ throw storedError(`Codex plugin cache must be a directory: ${base}`);
155
+ }
156
+ let names;
157
+ try {
158
+ names = await readdir(base);
159
+ }
160
+ catch (cause) {
161
+ throw storedError(`cannot inspect Codex plugin cache: ${base}`, cause);
162
+ }
163
+ let present = false;
164
+ for (const name of names) {
165
+ if (!validVersionSegment(name))
166
+ continue;
167
+ const candidate = join(base, name);
168
+ try {
169
+ const details = await lstat(candidate);
170
+ if (details.isSymbolicLink()) {
171
+ throw storedError(`Codex plugin cache entry must not be a symlink: ${candidate}`);
172
+ }
173
+ if (details.isDirectory() && !details.isSymbolicLink())
174
+ present = true;
175
+ }
176
+ catch (cause) {
177
+ if (!isErrno(cause, "ENOENT")) {
178
+ throw storedError(`cannot inspect Codex plugin cache entry: ${candidate}`, cause);
179
+ }
180
+ }
181
+ }
182
+ let after;
183
+ try {
184
+ after = await lstat(base);
185
+ }
186
+ catch (cause) {
187
+ throw storedError(`cannot verify Codex plugin cache: ${base}`, cause);
188
+ }
189
+ if (!after.isDirectory() ||
190
+ after.isSymbolicLink() ||
191
+ before.dev !== after.dev ||
192
+ before.ino !== after.ino ||
193
+ before.mtimeMs !== after.mtimeMs) {
194
+ throw storedError(`Codex plugin cache changed while being inspected: ${base}`);
195
+ }
196
+ return present;
197
+ }
198
+ export async function readCodexStoredState(env, cwd) {
199
+ const root = codexHome(env, cwd);
200
+ try {
201
+ const details = await lstat(root);
202
+ if (!details.isDirectory() || details.isSymbolicLink()) {
203
+ throw storedError(`Codex home must be a directory: ${root}`);
204
+ }
205
+ }
206
+ catch (cause) {
207
+ if (cause instanceof SafetyError && cause.module === "codex-stored-state") {
208
+ throw cause;
209
+ }
210
+ throw storedError(`cannot inspect Codex home: ${root}`, cause);
211
+ }
212
+ const config = await readConfig(join(root, "config.toml"));
213
+ const marketplaces = table(config.marketplaces, "marketplaces");
214
+ const managerMarketplaceRoot = marketplaceRoot(marketplaces, MANAGER_MARKETPLACE);
215
+ if (managerMarketplaceRoot === null) {
216
+ throw storedError("Codex manager marketplace is not configured");
217
+ }
218
+ await requireMissingManagerSource(managerMarketplaceRoot);
219
+ const legacyMarketplaceRoot = marketplaceRoot(marketplaces, LEGACY_MARKETPLACE);
220
+ const plugins = table(config.plugins, "plugins");
221
+ const searchRoot = env.SUPERPOWERS_INSTALLED_SEARCH_ROOT || root;
222
+ const installed = [];
223
+ for (const [pluginId, rawEntry] of Object.entries(plugins)) {
224
+ const entry = table(rawEntry, `plugins.${pluginId}`);
225
+ if (typeof entry.enabled !== "boolean") {
226
+ throw storedError(`Codex plugin ${pluginId} has invalid enabled state`);
227
+ }
228
+ if (!pluginId.startsWith("superpowers@"))
229
+ continue;
230
+ const marketplace = pluginId.slice("superpowers@".length);
231
+ installed.push({
232
+ pluginId,
233
+ installed: await cachedVersionPresent(searchRoot, marketplace),
234
+ enabled: entry.enabled === true,
235
+ });
236
+ }
237
+ const byId = (id) => installed.find((plugin) => plugin.pluginId === id);
238
+ const manager = byId(MANAGER_PLUGIN);
239
+ const legacy = byId(LEGACY_PLUGIN);
240
+ return {
241
+ managerMarketplaceRoot,
242
+ legacyMarketplaceRoot,
243
+ managerPluginPresent: manager?.installed === true,
244
+ managerPluginEnabled: manager?.enabled === true,
245
+ legacyPluginPresent: legacy?.installed === true,
246
+ legacyPluginEnabled: legacy?.enabled === true,
247
+ installedListingJson: JSON.stringify({ installed }),
248
+ };
249
+ }
@@ -4,16 +4,16 @@ import { COMMIT_INPUT_RE } from "./domain/refs.js";
4
4
  import { escapePythonJsonString } from "./python-json.js";
5
5
  import { SafetyError } from "./safety-error.js";
6
6
  import { parseStrictJson, } from "./strict-json.js";
7
- export const PROVENANCE_STRICT_PROFILE = {
7
+ const PROVENANCE_STRICT_PROFILE = {
8
8
  duplicateKeys: "last-wins",
9
9
  nonStandardConstants: "reject",
10
10
  maxDepth: 256,
11
11
  };
12
- export const PROVENANCE_LENIENT_PROFILE = {
12
+ const PROVENANCE_LENIENT_PROFILE = {
13
13
  duplicateKeys: "last-wins",
14
14
  nonStandardConstants: "reject",
15
15
  };
16
- export const PROVENANCE_CODEX_SOURCE_PROFILE = {
16
+ const PROVENANCE_CODEX_SOURCE_PROFILE = {
17
17
  duplicateKeys: "last-wins",
18
18
  nonStandardConstants: "accept",
19
19
  };
@@ -1,72 +1,3 @@
1
- import { SafetyError } from "./safety-error.js";
2
- /**
3
- * Render a JSON number token the way CPython's `json.dump` does.
4
- *
5
- * Classification is by source text, matching CPython's own: a token containing
6
- * `.` or `e`/`E` parses as a float, anything else as an arbitrary-width int.
7
- * Integers are emitted verbatim so widths above 2^53 survive; `-0` is the only
8
- * normalization JSON's grammar leaves room for, since leading zeros and a
9
- * leading `+` are already forbidden.
10
- */
11
- export function formatPythonNumber(raw) {
12
- if (!/[.eE]/.test(raw)) {
13
- // NaN/Infinity/-Infinity contain no `.` or `e`/`E`, so they would
14
- // otherwise fall into the "emit verbatim" integer branch and echo back
15
- // as invalid JSON. A reader running a non-standard-constants accept
16
- // profile (see src/strict-json.ts) can hand this function exactly these
17
- // three tokens as a raw number source, so this function must reject them
18
- // itself rather than assume its caller already did. CPython's
19
- // `json.dump(..., allow_nan=False)` raises ValueError for all three.
20
- if (raw === "NaN" || raw === "Infinity" || raw === "-Infinity") {
21
- throw new SafetyError("manifest-overlay", `JSON number out of range: ${raw}`);
22
- }
23
- return raw === "-0" ? "0" : raw;
24
- }
25
- const value = Number(raw);
26
- if (!Number.isFinite(value)) {
27
- throw new SafetyError("manifest-overlay", `JSON number out of range: ${raw}`);
28
- }
29
- return formatPythonFloat(value);
30
- }
31
- function formatPythonFloat(value) {
32
- // toExponential() drops the sign of negative zero; CPython's repr keeps it.
33
- // Note this differs from the integer -0, which normalizes to 0 — consistent,
34
- // because -0 has no `.` or `e` and so classifies as an integer.
35
- if (Object.is(value, -0))
36
- return "-0.0";
37
- // Called with no argument, toExponential() yields the shortest digit string
38
- // that uniquely identifies the double — the same guarantee CPython's repr
39
- // relies on. Taking digits and exponent separately is what lets us apply
40
- // CPython's notation threshold instead of JavaScript's.
41
- const [mantissa, exponentText] = value.toExponential().split("e");
42
- const exponent = Number(exponentText);
43
- const negative = mantissa.startsWith("-");
44
- const digits = mantissa.replace("-", "").replace(".", "");
45
- let body;
46
- if (exponent >= -4 && exponent < 16) {
47
- // Fixed notation, built by MOVING the decimal point through the digit
48
- // string above. Do NOT route this through value.toFixed(): toExponential()
49
- // has already produced the shortest digits that identify this double, and
50
- // toFixed performs a second, independent rounding of the full decimal
51
- // expansion. The two disagree — 1888570120608320.2 formats as
52
- // ...320.3 via toFixed and ...320.2 in CPython. Measured 2026-07-31.
53
- if (exponent >= digits.length - 1) {
54
- body = `${digits}${"0".repeat(exponent - (digits.length - 1))}.0`;
55
- }
56
- else if (exponent >= 0) {
57
- body = `${digits.slice(0, exponent + 1)}.${digits.slice(exponent + 1)}`;
58
- }
59
- else {
60
- body = `0.${"0".repeat(-exponent - 1)}${digits}`;
61
- }
62
- }
63
- else {
64
- const sign = exponent < 0 ? "-" : "+";
65
- const magnitude = String(Math.abs(exponent)).padStart(2, "0");
66
- body = `${mantissa.replace("-", "")}e${sign}${magnitude}`;
67
- }
68
- return negative ? `-${body}` : body;
69
- }
70
1
  /**
71
2
  * Apply CPython's `ensure_ascii=True` to an already-JSON-encoded string. Input
72
3
  * must be the output of JSON.stringify, which has already escaped quotes,
@@ -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/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
@@ -113,20 +97,15 @@ export function runValidator(argv, policy, env, workspace, cwd) {
113
97
  env: { ...process.env, ...env, TMPDIR: workspace },
114
98
  cwd,
115
99
  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",
100
+ // The child leads its own process group so a timeout can signal the GROUP --
101
+ // signalling the process alone leaves descendants holding inherited pipes.
102
+ detached: true,
123
103
  });
124
104
  }
125
105
  catch (cause) {
126
106
  settle({
127
107
  kind: "launchFailed",
128
108
  errno: cause.code ?? "UNKNOWN",
129
- cause,
130
109
  });
131
110
  return;
132
111
  }
@@ -136,7 +115,7 @@ export function runValidator(argv, policy, env, workspace, cwd) {
136
115
  child.stderr.on("data", (chunk) => err.push(chunk));
137
116
  // Both error and close fire on a launch failure, so settle() is idempotent.
138
117
  child.on("error", (cause) => {
139
- settle({ kind: "launchFailed", errno: cause.code ?? "UNKNOWN", cause });
118
+ settle({ kind: "launchFailed", errno: cause.code ?? "UNKNOWN" });
140
119
  });
141
120
  child.on("close", (code) => {
142
121
  // On the timeout path, settling here would resolve BEFORE SIGKILL and the
@@ -154,12 +133,6 @@ export function runValidator(argv, policy, env, workspace, cwd) {
154
133
  // may lag it indefinitely. Capture the code here or a successful validator
155
134
  // whose descendant lingers would settle with a null code and be rejected.
156
135
  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
136
  // The TIMEOUT path owns its own settlement, after the escalation has run.
164
137
  if (timedOut)
165
138
  return;
@@ -186,31 +159,29 @@ export function runValidator(argv, policy, env, workspace, cwd) {
186
159
  });
187
160
  }, policy.drainMs);
188
161
  });
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");
162
+ timeoutTimer = setTimeout(() => {
163
+ timedOut = true;
164
+ // Signal the GROUP, not the process: descendants hold the pipes. SIGTERM
165
+ // first, because the contract promises stderr carries the reason, and
166
+ // reading continues through the grace window so the child can print it.
167
+ signalGroup(child, "SIGTERM");
168
+ setTimeout(() => {
169
+ signalGroup(child, "SIGKILL");
170
+ // Settle ONLY after SIGKILL has been sent. An earlier revision settled
171
+ // first and left the escalation on an unref'ed timer, which never fires
172
+ // because the CLI exits as soon as its handler returns -- measured, the
173
+ // manager exited at 146 ms and the descendant survived. The wall-clock
174
+ // bound is therefore timeout + grace + drain, which is still a bound.
196
175
  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
- }
176
+ settle({
177
+ kind: "timedOut",
178
+ afterMs: policy.timeoutMs,
179
+ stdout: out.done(),
180
+ stderr: err.done(),
181
+ });
182
+ }, policy.drainMs);
183
+ }, policy.graceMs);
184
+ }, policy.timeoutMs);
214
185
  });
215
186
  }
216
187
  // Every probe below is best-effort BY DESIGN: spawn is the authority on whether the
@@ -297,22 +268,14 @@ const VALIDATOR_COMMANDS = new Set([
297
268
  "install",
298
269
  "update",
299
270
  ]);
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
271
  // The second exported accessor preflight derives its enforcement from. It is an
307
272
  // accessor rather than an inline check because an accessor that under-reports what
308
273
  // preflight enforces is the blind spot slice 2 closed.
309
274
  export function configurationErrors(cmd, env) {
310
- if (!VALIDATOR_COMMANDS.has(cmd))
311
- return [];
312
- if (!bothConfigured(env))
275
+ if (!VALIDATOR_COMMANDS.has(cmd) || !env.SUPERPOWERS_VALIDATOR)
313
276
  return [];
314
277
  return [
315
- "SUPERPOWERS_VALIDATOR and SUPERPOWERS_VALIDATOR_EXECUTABLE are both set — " +
316
- "unset one; the manager never chooses between them silently",
278
+ "SUPERPOWERS_VALIDATOR has been removed; unset it and configure " +
279
+ "SUPERPOWERS_VALIDATOR_EXECUTABLE with an executable validator.",
317
280
  ];
318
281
  }