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.
- package/README.md +13 -11
- package/dist/cli.js +9 -14
- package/dist/commands/install.js +18 -9
- package/dist/commands/mutation.js +7 -0
- package/dist/commands/prepare.js +5 -51
- package/dist/commands/probe.js +8 -10
- package/dist/commands/uninstall.js +2 -2
- package/dist/effective-selection.js +3 -3
- package/dist/harnesses/codex/adapter.js +336 -457
- package/dist/harnesses/codex/compatibility.js +2 -17
- package/dist/harnesses/codex/generated-plugin.js +1 -1
- package/dist/harnesses/codex/harness.js +45 -272
- package/dist/harnesses/codex/hooks.js +1 -1
- package/dist/harnesses/codex/json.js +6 -2
- package/dist/harnesses/codex/lifecycle.js +110 -54
- package/dist/harnesses/codex/manifest-overlay.js +22 -17
- package/dist/harnesses/codex/marketplace.js +148 -0
- package/dist/harnesses/codex/paths.js +97 -0
- package/dist/harnesses/codex/prepare.js +34 -2
- package/dist/harnesses/codex/presentation.js +10 -17
- package/dist/harnesses/codex/publication.js +520 -0
- package/dist/harnesses/codex/recovery.js +338 -0
- package/dist/harnesses/codex/state.js +131 -0
- package/dist/harnesses/codex/stored-state.js +249 -0
- package/dist/provenance.js +3 -3
- package/dist/python-json-format.js +0 -69
- package/dist/python-json.js +2 -27
- package/dist/python-text.js +9 -69
- package/dist/validator.js +35 -72
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +418 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +127 -0
- package/node_modules/smol-toml/dist/error.d.ts +38 -0
- package/node_modules/smol-toml/dist/error.js +63 -0
- package/node_modules/smol-toml/dist/extract.js +69 -0
- package/node_modules/smol-toml/dist/index.cjs +734 -0
- package/node_modules/smol-toml/dist/index.d.ts +43 -0
- package/node_modules/smol-toml/dist/index.js +33 -0
- package/node_modules/smol-toml/dist/parse.d.ts +36 -0
- package/node_modules/smol-toml/dist/parse.js +149 -0
- package/node_modules/smol-toml/dist/primitive.js +238 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +31 -0
- package/node_modules/smol-toml/dist/stringify.js +181 -0
- package/node_modules/smol-toml/dist/struct.js +179 -0
- package/node_modules/smol-toml/dist/util.d.ts +38 -0
- package/node_modules/smol-toml/dist/util.js +89 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/package.json +10 -2
- package/dist/selection-state-cli.js +0 -85
- 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
|
+
}
|
package/dist/provenance.js
CHANGED
|
@@ -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
|
-
|
|
7
|
+
const PROVENANCE_STRICT_PROFILE = {
|
|
8
8
|
duplicateKeys: "last-wins",
|
|
9
9
|
nonStandardConstants: "reject",
|
|
10
10
|
maxDepth: 256,
|
|
11
11
|
};
|
|
12
|
-
|
|
12
|
+
const PROVENANCE_LENIENT_PROFILE = {
|
|
13
13
|
duplicateKeys: "last-wins",
|
|
14
14
|
nonStandardConstants: "reject",
|
|
15
15
|
};
|
|
16
|
-
|
|
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,
|
package/dist/python-json.js
CHANGED
|
@@ -1,29 +1,4 @@
|
|
|
1
|
+
import { escapeNonAscii } from "./python-json-format.js";
|
|
1
2
|
export function escapePythonJsonString(value) {
|
|
2
|
-
|
|
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
|
}
|
package/dist/python-text.js
CHANGED
|
@@ -1,75 +1,15 @@
|
|
|
1
|
-
//
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
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
|
|
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.
|
|
56
|
-
//
|
|
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
|
|
75
|
-
const
|
|
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
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
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"
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
316
|
-
"
|
|
278
|
+
"SUPERPOWERS_VALIDATOR has been removed; unset it and configure " +
|
|
279
|
+
"SUPERPOWERS_VALIDATOR_EXECUTABLE with an executable validator.",
|
|
317
280
|
];
|
|
318
281
|
}
|