viewable-one 0.0.0-stage → 0.12.2
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/CHANGELOG.md +1483 -0
- package/LICENSE +21 -0
- package/README.md +738 -2
- package/SPAWN.md +2108 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +1018 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +124 -0
- package/lib/agents.mjs +914 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1491 -0
- package/lib/authenticatedHttp.mjs +75 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1292 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +767 -0
- package/lib/codexApps.mjs +344 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +629 -0
- package/lib/devserver.mjs +670 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +173 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +70 -0
- package/lib/paths.mjs +10 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +130 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +323 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +388 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1640 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +145 -0
- package/lib/runnerWorkspace.mjs +177 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +589 -0
- package/lib/uninstall.mjs +464 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/viewableFolder.mjs +105 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1142 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// USE MY COMPUTER'S APPS FOR CODEX (#481). Transport stays in its source;
|
|
2
|
+
// only policy is overridden. The thread inventory is checked before a turn.
|
|
3
|
+
import { codexRpc, startCodex } from "./codex-rpc.mjs";
|
|
4
|
+
import { killTree } from "./kill.mjs";
|
|
5
|
+
import { secretLike } from "./codexToolInputs.mjs";
|
|
6
|
+
|
|
7
|
+
const unreadable = () => new Error("Couldn't read the apps set up in Codex, so this run did not start. Check your apps in Codex, then try again.");
|
|
8
|
+
const unsupported = () => new Error("Codex couldn't check approval for every app on this computer, so this run did not start. Check your apps in Codex, then try again.");
|
|
9
|
+
const offSources = ["-c", "features.plugins=false", "-c", "features.apps=false"];
|
|
10
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
11
|
+
// A server that started shows tools, resources or resource templates.
|
|
12
|
+
const exposed = (status) => Object.keys(status.tools).length || status.resources?.length || status.resourceTemplates?.length;
|
|
13
|
+
|
|
14
|
+
// Use the incumbent cross-platform kill primitive, with a bounded wait after
|
|
15
|
+
// each signal. A blocked preflight must neither leak a child nor hang OFF or
|
|
16
|
+
// drafts forever. No process-group kill: these children share our group.
|
|
17
|
+
async function stopPreflight(child, gone, killGraceMs) {
|
|
18
|
+
const wait = async () => {
|
|
19
|
+
let timer;
|
|
20
|
+
try { return await Promise.race([gone.then(() => true), new Promise((resolve) => { timer = setTimeout(() => resolve(false), killGraceMs); })]); }
|
|
21
|
+
finally { clearTimeout(timer); }
|
|
22
|
+
};
|
|
23
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
24
|
+
for (const force of [false, true]) {
|
|
25
|
+
await killTree(child.pid, { force, signal: (_pid, signal) => child.kill(signal) }).catch(() => {});
|
|
26
|
+
if (await wait()) return;
|
|
27
|
+
}
|
|
28
|
+
child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); child.unref();
|
|
29
|
+
throw unreadable();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// CLI discovery expands plugin servers; it is not the config/read transport
|
|
33
|
+
// table. Keep both views, with the same binary/cwd/args/env, for provenance.
|
|
34
|
+
export async function listCodexServers({ bin = "codex", args, cwd, env = process.env, timeoutMs = 15_000, killGraceMs = 250 } = {}) {
|
|
35
|
+
const base = (args ?? ["app-server"]).filter((arg) => arg !== "app-server");
|
|
36
|
+
let child;
|
|
37
|
+
try { child = startCodex({ bin, args: [...base, "mcp", "list", "--json"], cwd, env }); } catch { throw unreadable(); }
|
|
38
|
+
const gone = new Promise((resolve) => child.once("close", resolve));
|
|
39
|
+
child.stderr.resume();
|
|
40
|
+
let timer;
|
|
41
|
+
try {
|
|
42
|
+
const list = await new Promise((resolve, reject) => {
|
|
43
|
+
let out = "";
|
|
44
|
+
timer = setTimeout(() => reject(unreadable()), timeoutMs);
|
|
45
|
+
child.stdout.setEncoding("utf8");
|
|
46
|
+
child.stdout.on("data", (chunk) => { out += chunk; if (out.length > 2_000_000) reject(unreadable()); });
|
|
47
|
+
child.once("error", () => reject(unreadable()));
|
|
48
|
+
child.once("close", (code) => {
|
|
49
|
+
try { if (code !== 0) throw unreadable(); resolve(JSON.parse(out)); } catch { reject(unreadable()); }
|
|
50
|
+
});
|
|
51
|
+
child.stdin.end();
|
|
52
|
+
});
|
|
53
|
+
if (!Array.isArray(list) || list.some((server) => !object(server) || typeof server.name !== "string")) throw unreadable();
|
|
54
|
+
return list;
|
|
55
|
+
} finally { clearTimeout(timer); await stopPreflight(child, gone, killGraceMs); }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readAppsConfig(rpc, cwd) {
|
|
59
|
+
const config = (await rpc.request("config/read", { cwd }))?.config;
|
|
60
|
+
const servers = config?.mcp_servers === undefined ? {} : config.mcp_servers;
|
|
61
|
+
if (!object(config) || !object(servers)) throw unreadable();
|
|
62
|
+
for (const server of Object.values(servers)) {
|
|
63
|
+
if (!object(server) || (server.tools != null && !object(server.tools))) throw unreadable();
|
|
64
|
+
}
|
|
65
|
+
return { ...config, mcp_servers: servers };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function readInventory(rpc, threadId) {
|
|
69
|
+
const inventory = [];
|
|
70
|
+
const cursors = new Set();
|
|
71
|
+
let cursor;
|
|
72
|
+
do {
|
|
73
|
+
const page = await rpc.request("mcpServerStatus/list", { ...(threadId ? { threadId } : {}), ...(cursor ? { cursor } : {}) });
|
|
74
|
+
if (!Array.isArray(page?.data)) throw unreadable();
|
|
75
|
+
for (const server of page.data) {
|
|
76
|
+
if (!object(server) || typeof server.name !== "string" || !object(server.tools)) throw unreadable();
|
|
77
|
+
if (inventory.some((entry) => entry.name === server.name)) throw unreadable();
|
|
78
|
+
inventory.push(server);
|
|
79
|
+
}
|
|
80
|
+
cursor = page.nextCursor;
|
|
81
|
+
if (cursor && (typeof cursor !== "string" || cursors.has(cursor))) throw unreadable();
|
|
82
|
+
cursors.add(cursor);
|
|
83
|
+
} while (cursor);
|
|
84
|
+
return inventory;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The child is reaped before returning, on success and every refusal path.
|
|
88
|
+
async function readCodexApps({ bin = "codex", args, cwd, env = process.env, timeoutMs = 15_000, killGraceMs = 250 } = {}, inventory = false) {
|
|
89
|
+
let child;
|
|
90
|
+
try { child = startCodex({ bin, args: args ?? ["app-server"], cwd, env }); } catch { throw unreadable(); }
|
|
91
|
+
child.stderr.resume();
|
|
92
|
+
const gone = new Promise((resolve) => child.once("close", resolve));
|
|
93
|
+
const rpc = codexRpc(child, { timeoutMs });
|
|
94
|
+
try {
|
|
95
|
+
await rpc.initialize();
|
|
96
|
+
return { config: await readAppsConfig(rpc, cwd), inventory: inventory ? await readInventory(rpc) : [] };
|
|
97
|
+
} catch { throw unreadable(); } finally { rpc.close(); await stopPreflight(child, gone, killGraceMs); }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// -c splits dotted paths literally, including quote characters (0.162.1).
|
|
101
|
+
// Plugin IDs may contain @; literal tool names go inside TOML table values,
|
|
102
|
+
// so a dotted app resource name cannot accidentally address another table.
|
|
103
|
+
function policyArgs(prefix, tools) {
|
|
104
|
+
const names = [...new Set(tools)];
|
|
105
|
+
return ["-c", `${prefix}.default_tools_approval_mode="prompt"`,
|
|
106
|
+
...(names.length ? ["-c", `${prefix}.tools={${names.map((name) => `${JSON.stringify(name)}={approval_mode="prompt"}`).join(",")}}`] : [])];
|
|
107
|
+
}
|
|
108
|
+
function pathPart(value) {
|
|
109
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_@-]+$/.test(value)) throw unsupported();
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Built-in app provenance comes from the app-server's own tool metadata,
|
|
114
|
+
// never a server-name exception. Store only IDs/names, not link credentials.
|
|
115
|
+
function appTools(status) {
|
|
116
|
+
const tools = Object.values(status.tools);
|
|
117
|
+
if (status.pluginId || status.httpOrigin !== "https://chatgpt.com" || !tools.length) throw unsupported();
|
|
118
|
+
return tools.map((tool) => {
|
|
119
|
+
const meta = tool?._meta;
|
|
120
|
+
if (typeof meta?._codex_apps?.contains_mcp_source !== "boolean" || typeof meta.resource_name !== "string" || !meta.resource_name) throw unsupported();
|
|
121
|
+
return { app: pathPart(meta.connector_id), tool: meta.resource_name };
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The name a person knows: their own server name, the plugin's name (its
|
|
126
|
+
// server too when they differ), or ChatGPT apps for the built-in service.
|
|
127
|
+
function notice(source, reason) {
|
|
128
|
+
if (source.kind === "apps") return `ChatGPT apps were turned off for this run because ${reason}.`;
|
|
129
|
+
const plugin = source.kind === "plugin" ? source.identity.split("@")[0] : source.name;
|
|
130
|
+
return `${plugin === source.name ? plugin : `${plugin} (${source.name})`} was turned off for this run because ${reason}.`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A source is never merely omitted from verification: apply its OFF policy
|
|
134
|
+
// and check the resulting process before a turn. Unknown built-in provenance
|
|
135
|
+
// can only be isolated at the built-in apps transport, as with the OFF switch.
|
|
136
|
+
function disableSource(plan, source, reason) {
|
|
137
|
+
const affected = source.kind === "apps" ? plan.sources.filter((entry) => entry.kind === "apps") : [source];
|
|
138
|
+
for (const entry of affected) {
|
|
139
|
+
if (!entry.enabled) continue;
|
|
140
|
+
entry.enabled = false;
|
|
141
|
+
if (!plan.notices.includes(notice(entry, reason))) plan.notices.push(notice(entry, reason));
|
|
142
|
+
}
|
|
143
|
+
if (source.kind === "apps") plan.args.push("-c", "features.apps=false");
|
|
144
|
+
else if (source.kind === "configured") plan.args.push("-c", `mcp_servers.${pathPart(source.name)}.enabled=false`);
|
|
145
|
+
else plan.args.push("-c", `plugins.${pathPart(source.identity)}.mcp_servers.${pathPart(source.name)}.enabled=false`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sourceIsOff(config, source) {
|
|
149
|
+
if (source.kind === "configured") return config.mcp_servers[source.name]?.enabled === false;
|
|
150
|
+
if (source.kind === "apps") return config.features?.apps === false;
|
|
151
|
+
const plugin = config.plugins?.[source.identity];
|
|
152
|
+
return plugin?.enabled === false || plugin?.mcp_servers?.[source.name]?.enabled === false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function prepareCodexApps(opts, { on, isDraft }) {
|
|
156
|
+
const allowed = on && !isDraft;
|
|
157
|
+
const sourceArgs = allowed ? [] : offSources;
|
|
158
|
+
const restricted = { ...opts, args: [...(opts.args ?? ["app-server"]), ...sourceArgs] };
|
|
159
|
+
// ON gets plugin identities from the effective app-server inventory and
|
|
160
|
+
// config. A separate CLI listing would start an unnecessary third process.
|
|
161
|
+
const listed = allowed ? [] : await listCodexServers(restricted);
|
|
162
|
+
const { config, inventory } = await readCodexApps(restricted, allowed);
|
|
163
|
+
const servers = config.mcp_servers;
|
|
164
|
+
if (!allowed && (config.features?.plugins !== false || config.features?.apps !== false)) throw unreadable();
|
|
165
|
+
if (listed.some((entry) => entry.enabled !== false && !Object.hasOwn(servers, entry.name))) throw unsupported();
|
|
166
|
+
const tools = Object.fromEntries(Object.entries(servers).map(([name, server]) => [name, Object.keys(server.tools ?? {})]));
|
|
167
|
+
const plan = {
|
|
168
|
+
args: [...sourceArgs, ...appsArgs(Object.entries(servers).map(([name, server]) => ({ ...server, name })), { on: allowed, isDraft, tools })],
|
|
169
|
+
sources: [], notices: [],
|
|
170
|
+
};
|
|
171
|
+
if (!allowed) return plan;
|
|
172
|
+
plan.args.push("-c", 'approvals_reviewer="user"');
|
|
173
|
+
const apps = new Map(Object.entries(config.apps ?? {}).filter(([id]) => id !== "_default").map(([id, app]) => [pathPart(id), new Set(Object.keys(app?.tools ?? {}))]));
|
|
174
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
175
|
+
const status = inventory.find((entry) => entry.name === name && !entry.pluginId);
|
|
176
|
+
plan.sources.push({ name, kind: "configured", identity: "configured", enabled: server.enabled !== false, tools: Object.keys(status?.tools ?? {}) });
|
|
177
|
+
}
|
|
178
|
+
for (const status of inventory) {
|
|
179
|
+
const names = Object.keys(status.tools);
|
|
180
|
+
if (Object.hasOwn(servers, status.name) && !status.pluginId) continue;
|
|
181
|
+
const source = { name: status.name, kind: status.pluginId ? "plugin" : "apps", identity: status.pluginId ?? "apps", enabled: true, tools: names };
|
|
182
|
+
plan.sources.push(source);
|
|
183
|
+
if (status.pluginId) {
|
|
184
|
+
const plugin = pathPart(status.pluginId);
|
|
185
|
+
const server = pathPart(status.name);
|
|
186
|
+
source.enabled = config.plugins?.[plugin]?.enabled !== false && config.plugins?.[plugin]?.mcp_servers?.[server]?.enabled !== false;
|
|
187
|
+
if (Object.hasOwn(servers, status.name) || !exposed(status)) {
|
|
188
|
+
disableSource(plan, source, "Codex could not check the app after it started");
|
|
189
|
+
} else if (source.enabled) {
|
|
190
|
+
plan.args.push(...policyArgs(`plugins.${plugin}.mcp_servers.${server}`, [...names, ...Object.keys(config.plugins?.[plugin]?.mcp_servers?.[server]?.tools ?? {})]));
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
try {
|
|
194
|
+
for (const entry of appTools(status)) {
|
|
195
|
+
if (!apps.has(entry.app)) apps.set(entry.app, new Set());
|
|
196
|
+
apps.get(entry.app).add(entry.tool);
|
|
197
|
+
}
|
|
198
|
+
} catch { disableSource(plan, source, "Codex could not check where the app came from"); }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Codex 0.162.1 names every server it can start in mcpServerStatus/list,
|
|
202
|
+
// a failed one with no tools, so a plugin with no entry there has no server
|
|
203
|
+
// (it holds only skills) and stays on. A server declared in config that
|
|
204
|
+
// Codex does not name did not start: turn just that server off.
|
|
205
|
+
for (const [plugin, policy] of Object.entries(config.plugins ?? {})) {
|
|
206
|
+
if (policy?.enabled === false) continue;
|
|
207
|
+
for (const [name, server] of Object.entries(policy?.mcp_servers ?? {})) {
|
|
208
|
+
if (server?.enabled === false || inventory.some((entry) => entry.name === name && entry.pluginId === plugin)) continue;
|
|
209
|
+
const source = { name, kind: "plugin", identity: plugin, enabled: true, tools: [] };
|
|
210
|
+
plan.sources.push(source);
|
|
211
|
+
disableSource(plan, source, "the app did not start");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// A disabled built-in transport disables every source it owns.
|
|
215
|
+
if (plan.sources.some((entry) => entry.kind === "apps" && !entry.enabled)) {
|
|
216
|
+
for (const source of plan.sources) if (source.kind === "apps" && source.enabled) disableSource(plan, source, "Codex could not check the app service");
|
|
217
|
+
}
|
|
218
|
+
plan.args.push(...policyArgs("apps._default", []), "-c", 'apps._default.approvals_reviewer="user"');
|
|
219
|
+
for (const [id, names] of apps) plan.args.push(...policyArgs(`apps.${id}`, [...names]), "-c", `apps.${id}.approvals_reviewer="user"`);
|
|
220
|
+
return plan;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Return a stricter plan when one source cannot be verified. The caller must
|
|
224
|
+
// reap this process and apply that plan to a new process before any model turn.
|
|
225
|
+
// Ignored OFF overrides still refuse: an unverified source must never stay on.
|
|
226
|
+
export async function verifyCodexApps(rpc, { cwd, threadId, on, isDraft, sources = [] }) {
|
|
227
|
+
const allowed = on && !isDraft;
|
|
228
|
+
try {
|
|
229
|
+
const config = await readAppsConfig(rpc, cwd);
|
|
230
|
+
if (!allowed && (config.features?.plugins !== false || config.features?.apps !== false)) throw unreadable();
|
|
231
|
+
if (allowed && config.approvals_reviewer !== "user") throw unsupported();
|
|
232
|
+
const prompt = (policy, names = []) => {
|
|
233
|
+
if (policy?.default_tools_approval_mode !== "prompt" ||
|
|
234
|
+
Object.values(policy.tools ?? {}).some((tool) => tool?.approval_mode !== "prompt") ||
|
|
235
|
+
names.some((name) => policy.tools?.[name]?.approval_mode !== "prompt")) throw unsupported();
|
|
236
|
+
};
|
|
237
|
+
const servers = config.mcp_servers;
|
|
238
|
+
const inventory = await readInventory(rpc, threadId);
|
|
239
|
+
const plan = { args: [], sources: structuredClone(sources), notices: [] };
|
|
240
|
+
// Check actual exposure, even for disabled sources and unexpected arrivals.
|
|
241
|
+
for (const status of inventory) {
|
|
242
|
+
const kind = status.pluginId ? "plugin" : Object.hasOwn(servers, status.name) ? "configured" : "apps";
|
|
243
|
+
const identity = status.pluginId ?? kind;
|
|
244
|
+
let source = plan.sources.find((entry) => entry.name === status.name && entry.kind === kind && entry.identity === identity);
|
|
245
|
+
if (!allowed) {
|
|
246
|
+
if (kind !== "configured" || (servers[status.name].enabled === false && exposed(status))) throw unreadable();
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (status.name === "glytch" && kind === "configured" && !isDraft) continue;
|
|
250
|
+
if (!source) {
|
|
251
|
+
source = { name: status.name, kind, identity, enabled: true, tools: Object.keys(status.tools) };
|
|
252
|
+
plan.sources.push(source);
|
|
253
|
+
disableSource(plan, source, "Codex could not check the app in this run");
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (!source.enabled) {
|
|
257
|
+
// This plan's new OFF settings take effect only in the retry process.
|
|
258
|
+
// Sources already disabled on entry must still prove they are off now.
|
|
259
|
+
const previous = sources.find((entry) => entry.name === source.name && entry.kind === kind && entry.identity === identity);
|
|
260
|
+
if (previous?.enabled !== false) continue;
|
|
261
|
+
if (!sourceIsOff(config, source) || exposed(status)) throw unsupported();
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
if (kind === "configured") prompt(servers[status.name]);
|
|
266
|
+
else if (kind === "plugin") {
|
|
267
|
+
if (Object.hasOwn(servers, status.name)) throw unsupported();
|
|
268
|
+
prompt(config.plugins?.[identity]?.mcp_servers?.[status.name], Object.keys(status.tools));
|
|
269
|
+
} else {
|
|
270
|
+
prompt(config.apps?._default);
|
|
271
|
+
if (config.apps._default.approvals_reviewer !== "user") throw unsupported();
|
|
272
|
+
for (const entry of appTools(status)) {
|
|
273
|
+
const policy = config.apps?.[entry.app];
|
|
274
|
+
prompt(policy, [entry.tool]);
|
|
275
|
+
if (policy.approvals_reviewer !== "user") throw unsupported();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (source.tools.some((name) => !Object.hasOwn(status.tools, name))) {
|
|
279
|
+
disableSource(plan, source, "Codex could not find all of its tools in this run");
|
|
280
|
+
}
|
|
281
|
+
} catch { disableSource(plan, source, "Codex could not check approval for the app"); }
|
|
282
|
+
}
|
|
283
|
+
for (const source of plan.sources) {
|
|
284
|
+
if (!source.enabled || (source.name === "glytch" && !isDraft)) continue;
|
|
285
|
+
const status = inventory.find((entry) => entry.name === source.name);
|
|
286
|
+
const kind = status?.pluginId ? "plugin" : status && Object.hasOwn(servers, status.name) ? "configured" : "apps";
|
|
287
|
+
const identity = status?.pluginId ?? kind;
|
|
288
|
+
if (!status || kind !== source.kind || identity !== source.identity) disableSource(plan, source, "Codex could not find the app in this run");
|
|
289
|
+
}
|
|
290
|
+
// Disabled entries may disappear entirely, but their config must prove OFF.
|
|
291
|
+
for (const source of sources.filter((entry) => !entry.enabled)) {
|
|
292
|
+
if (!sourceIsOff(config, source)) throw unsupported();
|
|
293
|
+
}
|
|
294
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
295
|
+
if (server.enabled === false || (name === "glytch" && !isDraft)) continue;
|
|
296
|
+
if (!allowed) throw unreadable();
|
|
297
|
+
if (sources.some((entry) => entry.name === name) || plan.sources.some((entry) => entry.name === name && entry.kind === "configured")) continue;
|
|
298
|
+
// Added after discovery: check it like a late inventory entry.
|
|
299
|
+
try { prompt(server); } catch {
|
|
300
|
+
const source = { name, kind: "configured", identity: "configured", enabled: true, tools: [] };
|
|
301
|
+
plan.sources.push(source);
|
|
302
|
+
disableSource(plan, source, "Codex could not check approval for the app");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return plan;
|
|
306
|
+
} catch { throw allowed ? unsupported() : unreadable(); }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// What the switch adds to the app server's command line (#481). Off, and in
|
|
310
|
+
// every draft: each server the person enabled is switched off for this run.
|
|
311
|
+
// On: each one stays, but every tool call asks first ("prompt" asks even for
|
|
312
|
+
// tools marked read-only), each tool the person saved an approval for
|
|
313
|
+
// included. glytch is ours and is set by codexBoardArgs; a
|
|
314
|
+
// draft switches it off too. Codex reads `mcp_servers.<name>` as a dotted
|
|
315
|
+
// path only when the name is plain (a quoted name makes a new, broken
|
|
316
|
+
// server), so any other server or tool name refuses the run.
|
|
317
|
+
export function appsArgs(servers, { on, isDraft, tools = {} }) {
|
|
318
|
+
const args = [];
|
|
319
|
+
for (const server of servers) {
|
|
320
|
+
if (server.enabled === false) continue;
|
|
321
|
+
if (server.name === "glytch" && !isDraft) continue;
|
|
322
|
+
const saved = on && Object.hasOwn(tools, server.name) ? tools[server.name] : [];
|
|
323
|
+
const names = [server.name, ...saved];
|
|
324
|
+
const odd = names.find((name) => !/^[A-Za-z0-9_-]+$/.test(name));
|
|
325
|
+
if (odd !== undefined) {
|
|
326
|
+
throw new Error(`An app set up in Codex has a name this run can't switch off safely (${JSON.stringify(odd)}). Rename it in your Codex config, then try again.`);
|
|
327
|
+
}
|
|
328
|
+
// Codex 0.165 rejects policy-only CLI overrides for workspace-provided
|
|
329
|
+
// servers after account/read ("workspace requirements: invalid data").
|
|
330
|
+
// The same policy succeeds when its override table includes the endpoint.
|
|
331
|
+
// Reassert only the public endpoint; auth headers and tokens stay in their
|
|
332
|
+
// original source. Keep the same prompt/OFF policy below.
|
|
333
|
+
if (server.environment_id && typeof server.url === "string") {
|
|
334
|
+
let url;
|
|
335
|
+
try { url = new URL(server.url); } catch { throw unsupported(); }
|
|
336
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || secretLike(server.url)) throw unsupported();
|
|
337
|
+
args.push("-c", `mcp_servers.${server.name}.url=${JSON.stringify(server.url)}`);
|
|
338
|
+
}
|
|
339
|
+
if (!on || isDraft) { args.push("-c", `mcp_servers.${server.name}.enabled=false`); continue; }
|
|
340
|
+
args.push("-c", `mcp_servers.${server.name}.default_tools_approval_mode="prompt"`);
|
|
341
|
+
for (const tool of saved) args.push("-c", `mcp_servers.${server.name}.tools.${tool}.approval_mode="prompt"`);
|
|
342
|
+
}
|
|
343
|
+
return args;
|
|
344
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The Codex plan window: what a ChatGPT subscription reports instead of
|
|
2
|
+
// dollars (brief 46, Move 4, the addendum "dollars only where money is
|
|
3
|
+
// metered"). codex-rs/app-server/README.md documents
|
|
4
|
+
// `account/rateLimits/read` answering with a response whose `rateLimits`
|
|
5
|
+
// (or `rateLimitsByLimitId.codex`) is a RateLimitSnapshot carrying
|
|
6
|
+
// `primary` and `secondary` windows, each
|
|
7
|
+
// roughly `{ usedPercent, windowDurationMins, resetsAt }` - checked against
|
|
8
|
+
// the installed codex-cli this bridge already drives through codex-rpc.mjs
|
|
9
|
+
// for `account/read` and `model/list`, so this is one more request on the
|
|
10
|
+
// same connection rather than a new one.
|
|
11
|
+
//
|
|
12
|
+
// Field names are parsed defensively, never trusted verbatim: a version
|
|
13
|
+
// skew that renames a field must read as "no window", not crash a run that
|
|
14
|
+
// was only trying to ask a question before spending. An older Codex with no
|
|
15
|
+
// such method answers with a JSON-RPC "method not found" error, which this
|
|
16
|
+
// module treats exactly the same way - `readPlanWindow` never throws.
|
|
17
|
+
//
|
|
18
|
+
// READ, NEVER ENFORCED (#170, #338). The window is shown on the run's info
|
|
19
|
+
// line and nothing else: a Codex run no longer stands down at a percentage.
|
|
20
|
+
|
|
21
|
+
/** The sentence Codex's dollar line used to be, unconditionally - still the
|
|
22
|
+
* fallback once a window could not be read at all. */
|
|
23
|
+
export const CODEX_DOLLARS_UNAVAILABLE =
|
|
24
|
+
"Codex subscription usage: no dollar cost is reported.";
|
|
25
|
+
|
|
26
|
+
const number = (n) => (typeof n === "number" && Number.isFinite(n) ? n : null);
|
|
27
|
+
const text = (s) => (typeof s === "string" && s.trim() !== "" ? s : null);
|
|
28
|
+
|
|
29
|
+
/** One window (`primary` or `secondary`) from whatever shape the server
|
|
30
|
+
* answered with this version. Null when there is nothing usable in it - a
|
|
31
|
+
* window this side cannot read is the same as no window at all, never a
|
|
32
|
+
* thrown error a caller has to guard against. */
|
|
33
|
+
function parseWindow(raw) {
|
|
34
|
+
if (!raw || typeof raw !== "object") return null;
|
|
35
|
+
const usedPercent = number(raw.usedPercent ?? raw.used_percent ?? raw.percentUsed ?? raw.percent_used);
|
|
36
|
+
if (usedPercent === null) return null;
|
|
37
|
+
const windowMinutes = number(
|
|
38
|
+
raw.windowDurationMins ?? raw.windowDurationMinutes ?? raw.window_duration_mins
|
|
39
|
+
?? raw.windowMinutes ?? raw.window_minutes,
|
|
40
|
+
);
|
|
41
|
+
const reset = raw.resetsAt ?? raw.resets_at ?? raw.resetAt ?? raw.reset_at;
|
|
42
|
+
// The app-server protocol uses Unix seconds; presence uses an ISO date.
|
|
43
|
+
const date = typeof reset === "number" ? new Date(reset * 1000) : null;
|
|
44
|
+
const resetsAt = date ? (Number.isNaN(date.getTime()) ? null : date.toISOString()) : text(reset);
|
|
45
|
+
return { usedPercent, windowMinutes, resetsAt };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `account/rateLimits/read`, wrapped so an unsupported method (an older
|
|
49
|
+
* Codex) or any other failure yields `{primary:null, secondary:null}`,
|
|
50
|
+
* never a thrown error - a caller about to start a run reads "no window"
|
|
51
|
+
* as "start it", not as a crash to propagate.
|
|
52
|
+
*
|
|
53
|
+
* `session` is anything with a `.request(method, params)` - the object
|
|
54
|
+
* `codexRpc()` (codex-rpc.mjs) returns. `log`, when given, is called once
|
|
55
|
+
* with the raw response (or the failure), so an unexpected shape leaves a
|
|
56
|
+
* debug trail instead of silently reading as "no window". */
|
|
57
|
+
export async function readPlanWindow(session, { log } = {}) {
|
|
58
|
+
const readAt = new Date().toISOString();
|
|
59
|
+
try {
|
|
60
|
+
const raw = await session.request("account/rateLimits/read", {});
|
|
61
|
+
log?.(`[codex plan window] account/rateLimits/read -> ${JSON.stringify(raw)}`);
|
|
62
|
+
const snapshot = raw?.rateLimitsByLimitId?.codex ?? raw?.rateLimits ?? raw;
|
|
63
|
+
return { primary: parseWindow(snapshot?.primary), secondary: parseWindow(snapshot?.secondary), readAt };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
log?.(`[codex plan window] account/rateLimits/read unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
return { primary: null, secondary: null, readAt };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Name the duration actually returned by Codex, including single-window
|
|
71
|
+
* accounts and newer durations that the bridge has not seen before. */
|
|
72
|
+
function windowLabel(window) {
|
|
73
|
+
const minutes = number(window?.windowMinutes);
|
|
74
|
+
if (minutes === 10080) return "weekly";
|
|
75
|
+
if (minutes === null || minutes <= 0) return "reported";
|
|
76
|
+
return minutes % 60 === 0 ? `${minutes / 60}-hour` : `${minutes}-minute`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function planWindowInfoMessage(window) {
|
|
80
|
+
const readings = [window?.primary, window?.secondary].filter(Boolean);
|
|
81
|
+
if (readings.length === 0) return CODEX_DOLLARS_UNAVAILABLE;
|
|
82
|
+
return "Codex reports its plan window, not dollars: " + readings.map((reading) =>
|
|
83
|
+
`${windowLabel(reading)} at ${Math.round(reading.usedPercent)}%`).join(", ") + ".";
|
|
84
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// WHAT A CODEX TOOL APPROVAL SENDS, short, with secrets hidden (#608).
|
|
2
|
+
// Codex puts the tool's arguments in the approval's `_meta.tool_params`. The
|
|
3
|
+
// person approving a refund needs the amount; the hosted question must never
|
|
4
|
+
// carry a credential.
|
|
5
|
+
//
|
|
6
|
+
// Text is shown by an ALLOW rule, not a list of things to block: a string is
|
|
7
|
+
// shown only when it reads as plain text: at most 80 characters, made only of
|
|
8
|
+
// letters, digits, spaces and . , - _ / ( ) # + @ ! ? $ € £, with no run of
|
|
9
|
+
// 4 or more digits, no credential part in its letters (pass, pin, token,
|
|
10
|
+
// key…, the same test names get), and neither secret-shaped nor opaque.
|
|
11
|
+
// Anything else (a tab, a line break, any other punctuation) reads "hidden,
|
|
12
|
+
// N characters". A body of any format (JSON, form, query, XML, multipart,
|
|
13
|
+
// comma or tab separated) therefore never shows, whatever it names.
|
|
14
|
+
// Booleans and null show as themselves; a number shows when it has at most
|
|
15
|
+
// 3 digits before the point; nested values show as a count; an argument
|
|
16
|
+
// named like a credential reads "hidden".
|
|
17
|
+
//
|
|
18
|
+
// The limit: an unlabelled password typed as free text ("hunter2") reads
|
|
19
|
+
// as ordinary text and shows. Nothing in a value alone tells them apart.
|
|
20
|
+
//
|
|
21
|
+
// `_meta.tool_params_display` (Codex's own formatted copy) is not read: one
|
|
22
|
+
// source of values keeps every value behind the same rule.
|
|
23
|
+
|
|
24
|
+
// Forwarded prose must not carry a credential value: a labelled one in any
|
|
25
|
+
// case ("Password: hunter2", "API_KEY=abc", "login code:", "code is 123456";
|
|
26
|
+
// bare "code" is not a label, so "exit code: 1" is asked) or a token-shaped one.
|
|
27
|
+
// A label may end a longer name ("access_token=") or a quoted JSON key
|
|
28
|
+
// ('"password": "x"'). codex.mjs uses this for Codex's own message text.
|
|
29
|
+
const labelledSecret = /(?<![a-z0-9])(?:password|passphrase|secret|token|api[ _-]?key|authorization|cookie)\b["']?\s*[:=]\s*\S+|\b(?:verification|one[ -]?time|login)\s+code\s*[:=]|\bcode\s+is\s+\d{4,}/i;
|
|
30
|
+
const tokenShaped = /\b(?:sk|pk|rk)-[\w-]{12,}|\b(?:sk|pk|rk|whsec)_(?:live|test)_\w+|\bgh[pousr]_\w{20,}|\bgithub_pat_\w{20,}|\bxox[abprs]-[\w-]{10,}|\bAKIA[0-9A-Z]{16}\b|\beyJ[\w-]{10,}\.[\w-]{10,}|\b[Bb]earer\s+\S{8,}|-----BEGIN [A-Z ]*KEY-----|\/\/[^\s/:@]+:[^\s/@]+@|(?=[\w+=-]*\d)(?=[\w+=-]*[A-Za-z])[\w+=-]{40,}/;
|
|
31
|
+
export const secretLike = (value) => labelledSecret.test(value) || tokenShaped.test(value);
|
|
32
|
+
|
|
33
|
+
// A name or a text value that mentions a credential: lowercased, with
|
|
34
|
+
// everything but letters stripped ("OTPCode" -> "otpcode", "passcode,482913"
|
|
35
|
+
// -> "passcode"), then tested for these parts anywhere, so one-word names,
|
|
36
|
+
// acronyms and run-together text are caught without splitting words. An
|
|
37
|
+
// argument named this way is hidden whatever its value; a text value this
|
|
38
|
+
// way is hidden itself. Over-matching ("shipping", "keyboard", "author",
|
|
39
|
+
// "compass", "basic plan") only hides a value.
|
|
40
|
+
const SECRET_PART = /pass|pin|otp|token|key|secret|auth|cookie|session|credential|signature|cvv|cvc|private|mfa|totp|pwd|cardnumber|onetime|verification|bearer|basic/;
|
|
41
|
+
const nameLetters = (name) => name.toLowerCase().replace(/[^a-z]/g, "");
|
|
42
|
+
const mentionsSecret = (text) => SECRET_PART.test(nameLetters(text));
|
|
43
|
+
// A name with "code" in it hides a number, or 4 to 10 digits as text
|
|
44
|
+
// (login_code, sms_code, PINCode; zip_code over-hides). country_code: "US"
|
|
45
|
+
// still shows.
|
|
46
|
+
const CODE_DIGITS = /^\d{4,10}$/;
|
|
47
|
+
const codeValue = (name, value) => nameLetters(name).includes("code")
|
|
48
|
+
&& (typeof value === "number" || (typeof value === "string" && CODE_DIGITS.test(value.replace(/[\s-]/g, ""))));
|
|
49
|
+
// A run of mixed letters and digits (an id, a key, a hash), or a card-length
|
|
50
|
+
// number. Shorter ids like a Stripe charge id stay readable.
|
|
51
|
+
const opaque = (text) => /(?=[\w+/=-]*\d)(?=[\w+/=-]*[A-Za-z])[\w+/=-]{32,}|[\w+/=-]{64,}|(?:\d[ -]?){13,}/.test(text);
|
|
52
|
+
// The only characters plain text may hold. Everything else (tabs, control
|
|
53
|
+
// characters, = : ; quotes, brackets, %) can carry labelled or encoded data.
|
|
54
|
+
const PLAIN_CHARS = /^[\p{L}\p{N} .,\-_/()#+@!?$€£]*$/u;
|
|
55
|
+
// 4 or more digits in a row may be a code, a PIN or part of a card ("Your
|
|
56
|
+
// verification code 482913"); "order 1182" over-hides.
|
|
57
|
+
const DIGIT_RUN = /\d{4,}/;
|
|
58
|
+
|
|
59
|
+
const MAX_INPUTS = 12;
|
|
60
|
+
const MAX_VALUE = 80;
|
|
61
|
+
const MAX_TEXT = 900;
|
|
62
|
+
const HIDDEN = "hidden";
|
|
63
|
+
const plainName = (name) => /^[\w .-]{1,40}$/.test(name) && !secretLike(name);
|
|
64
|
+
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
65
|
+
const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
66
|
+
// Length is checked first, so the patterns only ever scan 80 characters.
|
|
67
|
+
const plainText = (text) => text.length <= MAX_VALUE && PLAIN_CHARS.test(text) && !DIGIT_RUN.test(text) && !mentionsSecret(text)
|
|
68
|
+
&& !secretLike(text) && !opaque(text);
|
|
69
|
+
|
|
70
|
+
function valueText(value) {
|
|
71
|
+
if (value === null || typeof value === "boolean") return String(value);
|
|
72
|
+
// At most 3 digits before the point: 49.99 shows, a 4-digit PIN does not.
|
|
73
|
+
if (typeof value === "number") return Number.isFinite(value) && Math.abs(value) < 1000 ? String(value) : HIDDEN;
|
|
74
|
+
if (typeof value === "string") {
|
|
75
|
+
if (value.trim() === "") return "(empty)";
|
|
76
|
+
return plainText(value) ? value.trim() : `${HIDDEN}, ${count(value.length, "character", "characters")}`;
|
|
77
|
+
}
|
|
78
|
+
// Nested values are summarised by size, never opened: a secret one level
|
|
79
|
+
// down is not shown.
|
|
80
|
+
if (Array.isArray(value)) return count(value.length, "item", "items");
|
|
81
|
+
if (record(value)) return count(Object.keys(value).length, "field", "fields");
|
|
82
|
+
return HIDDEN;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The lines that list a tool approval's inputs, or "" when it has none.
|
|
86
|
+
* `params` is the elicitation's params; only `_meta.tool_params` is read. */
|
|
87
|
+
export function toolInputsText(params) {
|
|
88
|
+
const inputs = params?._meta?.tool_params;
|
|
89
|
+
if (inputs === undefined || inputs === null) return "";
|
|
90
|
+
if (!record(inputs)) return `Details: ${valueText(inputs)}`;
|
|
91
|
+
const entries = Object.entries(inputs);
|
|
92
|
+
if (!entries.length) return "";
|
|
93
|
+
const lines = [];
|
|
94
|
+
let length = 0;
|
|
95
|
+
for (const [name, value] of entries.slice(0, MAX_INPUTS)) {
|
|
96
|
+
const line = !plainName(name) ? `(hidden name): ${HIDDEN}`
|
|
97
|
+
: `${name}: ${mentionsSecret(name) || codeValue(name, value) ? HIDDEN : valueText(value)}`;
|
|
98
|
+
if (length + line.length > MAX_TEXT) break;
|
|
99
|
+
lines.push(line);
|
|
100
|
+
length += line.length + 1;
|
|
101
|
+
}
|
|
102
|
+
const rest = entries.length - lines.length;
|
|
103
|
+
if (rest > 0) lines.push(`and ${rest} more`);
|
|
104
|
+
return `Details:\n${lines.join("\n")}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Stripe sends amounts in the currency's smallest unit (4900 USD is $49.00,
|
|
108
|
+
// 500 JPY is ¥500). The divisor is Stripe's, not Intl's: ISK and UGX have no
|
|
109
|
+
// decimals in Intl but Stripe still sends them with two (500 ISK is ISK 5),
|
|
110
|
+
// docs.stripe.com/currencies. Intl only draws the symbol. Without a currency
|
|
111
|
+
// the decimals are unknown and the number stays as sent, said in words.
|
|
112
|
+
const STRIPE_ZERO_DECIMAL = new Set(["BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "VND", "VUV", "XAF", "XOF", "XPF"]);
|
|
113
|
+
const STRIPE_THREE_DECIMAL = new Set(["BHD", "JOD", "KWD", "OMR", "TND"]);
|
|
114
|
+
export function moneyText(amount, currency) {
|
|
115
|
+
if (typeof currency !== "string" || !/^[a-z]{3}$/i.test(currency)) return `${amount} in the payment's smallest unit (cents for USD)`;
|
|
116
|
+
const code = currency.toUpperCase();
|
|
117
|
+
const divisor = STRIPE_ZERO_DECIMAL.has(code) ? 1 : STRIPE_THREE_DECIMAL.has(code) ? 1000 : 100;
|
|
118
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency: code }).format(amount / divisor);
|
|
119
|
+
}
|
|
120
|
+
// A vendor's raw id ("pi_3MtwBwLkdIwHu7ix28a3tqPa", "cus_NffrFeUfNV2Hib"):
|
|
121
|
+
// it names the record but no person knows it, so its row goes last.
|
|
122
|
+
export const RAW_ID = /^[a-z]{2,6}_[A-Za-z0-9]{6,}$/;
|
|
123
|
+
|
|
124
|
+
/** Approval-card rows, using the same secret filter as the computer asker.
|
|
125
|
+
* `tool` is the app's tool name; a Stripe amount reads "Refund" only on a
|
|
126
|
+
* refund tool (or when the tool is not known). */
|
|
127
|
+
export function appInputsText(provider, inputs = {}, tool = "refund") {
|
|
128
|
+
if (!record(inputs)) return toolInputsText({ _meta: { tool_params: inputs } });
|
|
129
|
+
const labels = { team: "Team", title: "Title", description: "Description", priority: "Priority", page_id: "Page", page: "Page", parent: "Page", content: "Adds", markdown: "Adds", body: "Comment", owner: "Owner", repo: "Repository", issue_number: "Issue", pullNumber: "Pull request", customer: "Customer", payment_intent: "Payment", charge: "Payment", reason: "Reason", currency: "Currency", to: "To", subject: "Subject" };
|
|
130
|
+
const money = (name, value) => provider === "stripe" && name === "amount" && Number.isSafeInteger(value) && value >= 0 && value <= 99999999;
|
|
131
|
+
const rows = Object.entries(inputs).slice(0, MAX_INPUTS).flatMap(([name, value]) => {
|
|
132
|
+
if (money(name, value)) return [{ line: `${/refund/i.test(tool) ? "Refund" : "Amount"}: ${moneyText(value, inputs.currency)}` }];
|
|
133
|
+
// The currency is already in the amount.
|
|
134
|
+
if (provider === "stripe" && name === "currency" && money("amount", inputs.amount) && /^[a-z]{3}$/i.test(String(value))) return [];
|
|
135
|
+
const display = mentionsSecret(name) || codeValue(name, value) ? HIDDEN : valueText(value);
|
|
136
|
+
return [{ line: `${(Object.hasOwn(labels, name) ? labels[name] : undefined) ?? (plainName(name) ? name : "hidden name")}: ${display}`, id: RAW_ID.test(display) }];
|
|
137
|
+
});
|
|
138
|
+
return "Details:\n" + [...rows.filter((row) => !row.id), ...rows.filter((row) => row.id)].map((row) => row.line).join("\n");
|
|
139
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Package checks share the run's deadline and Stop signal. They never install
|
|
2
|
+
// dependencies. Only test/build are selected; npm owns their script semantics.
|
|
3
|
+
import { readFile, access } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { runChild, devServerEnv } from "./child.mjs";
|
|
6
|
+
|
|
7
|
+
export async function runCodingChecks({ cwd, env, deadline, stopCheck, platform, killGraceMs, onChunk }) {
|
|
8
|
+
let pkg;
|
|
9
|
+
let unreadable = false;
|
|
10
|
+
try { pkg = JSON.parse(await readFile(join(cwd, "package.json"), "utf8")); }
|
|
11
|
+
catch (error) { unreadable = error.code !== "ENOENT"; }
|
|
12
|
+
let installed = true;
|
|
13
|
+
if (Object.keys({ ...pkg?.dependencies, ...pkg?.devDependencies, ...pkg?.optionalDependencies }).length) {
|
|
14
|
+
try { await access(join(cwd, "node_modules")); } catch { installed = false; }
|
|
15
|
+
}
|
|
16
|
+
const checks = [];
|
|
17
|
+
const failures = [];
|
|
18
|
+
const logs = [];
|
|
19
|
+
let stopped = false;
|
|
20
|
+
for (const [script, kind] of [["test", "tests"], ["build", "build"]]) {
|
|
21
|
+
const entry = { kind, status: "skipped", failed: 0 };
|
|
22
|
+
const hasScript = typeof pkg?.scripts?.[script] === "string" && pkg.scripts[script].trim();
|
|
23
|
+
if (unreadable) entry.reason = "Could not read the folder's checks.";
|
|
24
|
+
else if (!hasScript) entry.reason = `This folder has no ${script === "test" ? "tests" : "build"} to run.`;
|
|
25
|
+
else if (!installed) entry.reason = "The folder's dependencies are not installed.";
|
|
26
|
+
else if (stopped || await stopCheck?.()) {
|
|
27
|
+
stopped = true;
|
|
28
|
+
entry.reason = "Stopped before this check finished.";
|
|
29
|
+
} else if (Date.now() >= deadline) {
|
|
30
|
+
Object.assign(entry, { status: "failed", failed: 1, reason: "Ran out of time before this check finished." });
|
|
31
|
+
} else {
|
|
32
|
+
const out = await runChild({
|
|
33
|
+
bin: "npm", args: ["run", script], cwd, env: { ...devServerEnv(env, platform), CI: "true" },
|
|
34
|
+
platform, maxMs: deadline - Date.now(), stopCheck, stopPollMs: 100,
|
|
35
|
+
killGraceMs, maxLogBytes: 12_000, onChunk, killProcessGroup: true,
|
|
36
|
+
});
|
|
37
|
+
logs.push(out.log ?? "");
|
|
38
|
+
if (out.stoppedByUser) {
|
|
39
|
+
stopped = true;
|
|
40
|
+
entry.reason = "Stopped before this check finished.";
|
|
41
|
+
} else if (out.timedOut) {
|
|
42
|
+
Object.assign(entry, { status: "failed", failed: 1, reason: "Ran out of time before this check finished." });
|
|
43
|
+
} else if (out.spawnError || ([127, 9009].includes(out.code) && /(?:command not found|: not found|is not recognized as an internal or external command)/i.test(out.log ?? ""))) {
|
|
44
|
+
entry.reason = "A tool needed for this check is not installed.";
|
|
45
|
+
} else if (out.ok) {
|
|
46
|
+
entry.status = "passed";
|
|
47
|
+
} else {
|
|
48
|
+
Object.assign(entry, { status: "failed", failed: 1, reason: `${script === "test" ? "Tests" : "The build"} did not pass.` });
|
|
49
|
+
failures.push(`npm run ${script}\n${out.log ?? ""}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
checks.push(entry);
|
|
53
|
+
}
|
|
54
|
+
return { checks, failures, stopped, log: logs.join("\n") };
|
|
55
|
+
}
|