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.
Files changed (89) hide show
  1. package/CHANGELOG.md +1483 -0
  2. package/LICENSE +21 -0
  3. package/README.md +738 -2
  4. package/SPAWN.md +2108 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +1018 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +124 -0
  9. package/lib/agents.mjs +914 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1491 -0
  12. package/lib/authenticatedHttp.mjs +75 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1292 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +767 -0
  20. package/lib/codexApps.mjs +344 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +629 -0
  28. package/lib/devserver.mjs +670 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/hosted.mjs +112 -0
  33. package/lib/imageAttachments.mjs +31 -0
  34. package/lib/init-connect.mjs +418 -0
  35. package/lib/kill.mjs +104 -0
  36. package/lib/logindaemon.mjs +976 -0
  37. package/lib/logs.mjs +120 -0
  38. package/lib/mcpjson.mjs +242 -0
  39. package/lib/memberPreview.mjs +173 -0
  40. package/lib/multiwatch.mjs +428 -0
  41. package/lib/pagePictures.mjs +396 -0
  42. package/lib/pageRoutes.mjs +70 -0
  43. package/lib/paths.mjs +10 -0
  44. package/lib/permissionAsk.mjs +418 -0
  45. package/lib/picker.mjs +300 -0
  46. package/lib/platform.mjs +60 -0
  47. package/lib/previewGateway.mjs +130 -0
  48. package/lib/previewTunnel.mjs +85 -0
  49. package/lib/procs.mjs +428 -0
  50. package/lib/project-mcp.mjs +288 -0
  51. package/lib/projectPaths.mjs +31 -0
  52. package/lib/refusal.mjs +70 -0
  53. package/lib/registry.mjs +204 -0
  54. package/lib/repomap.mjs +323 -0
  55. package/lib/restartPass.mjs +151 -0
  56. package/lib/retired-address.mjs +78 -0
  57. package/lib/rowsAnswers.mjs +388 -0
  58. package/lib/rowsEvents.mjs +86 -0
  59. package/lib/run-engine.mjs +16 -0
  60. package/lib/runner-health.mjs +57 -0
  61. package/lib/runner.mjs +1640 -0
  62. package/lib/runnerAdmission.mjs +35 -0
  63. package/lib/runnerApi.mjs +312 -0
  64. package/lib/runnerCompletion.mjs +406 -0
  65. package/lib/runnerLease.mjs +37 -0
  66. package/lib/runnerMaintenance.mjs +27 -0
  67. package/lib/runnerPace.mjs +283 -0
  68. package/lib/runnerPerformance.mjs +26 -0
  69. package/lib/runnerRealtime.mjs +340 -0
  70. package/lib/runnerRegister.mjs +96 -0
  71. package/lib/runnerSettings.mjs +40 -0
  72. package/lib/runnerSettlement.mjs +145 -0
  73. package/lib/runnerWorkspace.mjs +177 -0
  74. package/lib/serverSweep.mjs +90 -0
  75. package/lib/sharedPreview.mjs +292 -0
  76. package/lib/signIn.mjs +47 -0
  77. package/lib/spawn.mjs +330 -0
  78. package/lib/sweep.mjs +161 -0
  79. package/lib/sync.mjs +484 -0
  80. package/lib/uninstall-project.mjs +589 -0
  81. package/lib/uninstall.mjs +464 -0
  82. package/lib/update.mjs +302 -0
  83. package/lib/usageLimit.mjs +108 -0
  84. package/lib/viewableFolder.mjs +105 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1142 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. 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
+ }