getbotons 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +505 -2
  3. package/installer/admin.mjs +6 -0
  4. package/installer/botons.mjs +430 -0
  5. package/installer/brain-clis.mjs +28 -0
  6. package/installer/linger.mjs +39 -0
  7. package/installer/output.mjs +27 -0
  8. package/installer/revision.mjs +30 -0
  9. package/installer/shell-path.mjs +66 -0
  10. package/installer/source-lock.json +2891 -0
  11. package/package.json +26 -4
  12. package/payload/LICENSE +21 -0
  13. package/payload/SECURITY.md +33 -0
  14. package/payload/installer/admin.mjs +6 -0
  15. package/payload/installer/botons.mjs +430 -0
  16. package/payload/installer/brain-clis.mjs +28 -0
  17. package/payload/installer/linger.mjs +39 -0
  18. package/payload/installer/output.mjs +27 -0
  19. package/payload/installer/revision.mjs +30 -0
  20. package/payload/installer/shell-path.mjs +66 -0
  21. package/payload/installer/source-lock.json +2891 -0
  22. package/payload/npm-shrinkwrap.json +51 -0
  23. package/payload/package.json +13 -0
  24. package/payload/scripts/backup.ts +47 -0
  25. package/payload/scripts/botons-backup.service +15 -0
  26. package/payload/scripts/botons-backup.timer +11 -0
  27. package/payload/scripts/botons.service +36 -0
  28. package/payload/scripts/check-browser.ts +90 -0
  29. package/payload/scripts/doctor.ts +790 -0
  30. package/payload/scripts/install-backup-timer.ts +52 -0
  31. package/payload/scripts/install-service.ts +206 -0
  32. package/payload/scripts/login.ts +17 -0
  33. package/payload/scripts/migrate-names.ts +47 -0
  34. package/payload/scripts/pair.ts +163 -0
  35. package/payload/scripts/restore.ts +108 -0
  36. package/payload/server/bin/botons +4 -0
  37. package/payload/server/bin/legacy/minion +4 -0
  38. package/payload/server/bin/legacy/tots +4 -0
  39. package/payload/server/src/acting.ts +16 -0
  40. package/payload/server/src/api-keys.ts +544 -0
  41. package/payload/server/src/api.ts +1484 -0
  42. package/payload/server/src/app.ts +546 -0
  43. package/payload/server/src/approvals.ts +873 -0
  44. package/payload/server/src/ask-rules.ts +181 -0
  45. package/payload/server/src/audit.ts +158 -0
  46. package/payload/server/src/auth.ts +497 -0
  47. package/payload/server/src/backup.ts +382 -0
  48. package/payload/server/src/brain-login.ts +75 -0
  49. package/payload/server/src/browser-cert.ts +67 -0
  50. package/payload/server/src/browser-handoff.ts +119 -0
  51. package/payload/server/src/browser-handover.ts +175 -0
  52. package/payload/server/src/browser-policy.ts +210 -0
  53. package/payload/server/src/browser-proxy.ts +580 -0
  54. package/payload/server/src/browser-request.ts +335 -0
  55. package/payload/server/src/browser-screenshot.ts +56 -0
  56. package/payload/server/src/browser-send.ts +155 -0
  57. package/payload/server/src/browser.ts +2164 -0
  58. package/payload/server/src/callback.ts +218 -0
  59. package/payload/server/src/capabilities.ts +90 -0
  60. package/payload/server/src/cards.ts +243 -0
  61. package/payload/server/src/cli/approve-mcp.ts +119 -0
  62. package/payload/server/src/cli/botons.ts +629 -0
  63. package/payload/server/src/cli/browser-mcp.ts +168 -0
  64. package/payload/server/src/cli/workspace-file.ts +78 -0
  65. package/payload/server/src/cli-homes.ts +38 -0
  66. package/payload/server/src/cli-install.ts +104 -0
  67. package/payload/server/src/cli-updates.ts +112 -0
  68. package/payload/server/src/codex-config-repair.ts +80 -0
  69. package/payload/server/src/config.ts +473 -0
  70. package/payload/server/src/connector-access.ts +158 -0
  71. package/payload/server/src/connector-launch.ts +203 -0
  72. package/payload/server/src/connectors.ts +947 -0
  73. package/payload/server/src/disk-usage.ts +62 -0
  74. package/payload/server/src/engine.ts +2328 -0
  75. package/payload/server/src/env-init.ts +5 -0
  76. package/payload/server/src/events.ts +46 -0
  77. package/payload/server/src/files.ts +528 -0
  78. package/payload/server/src/github-access.ts +52 -0
  79. package/payload/server/src/health.ts +117 -0
  80. package/payload/server/src/held-dir.ts +96 -0
  81. package/payload/server/src/hooks.ts +141 -0
  82. package/payload/server/src/http.ts +207 -0
  83. package/payload/server/src/index.ts +139 -0
  84. package/payload/server/src/install-type.ts +11 -0
  85. package/payload/server/src/instructions.ts +204 -0
  86. package/payload/server/src/isolation.ts +234 -0
  87. package/payload/server/src/legacy-upgrade.ts +668 -0
  88. package/payload/server/src/limits.ts +44 -0
  89. package/payload/server/src/lock.ts +75 -0
  90. package/payload/server/src/log.ts +137 -0
  91. package/payload/server/src/managed-clis.ts +65 -0
  92. package/payload/server/src/mcp-client.ts +229 -0
  93. package/payload/server/src/notify.ts +193 -0
  94. package/payload/server/src/oauth-connections.ts +650 -0
  95. package/payload/server/src/oauth.ts +311 -0
  96. package/payload/server/src/outbound.ts +142 -0
  97. package/payload/server/src/portable.ts +1669 -0
  98. package/payload/server/src/prompt.ts +676 -0
  99. package/payload/server/src/providers/api-brain.ts +612 -0
  100. package/payload/server/src/providers/api-history.ts +131 -0
  101. package/payload/server/src/providers/api-tools.ts +481 -0
  102. package/payload/server/src/providers/api-wire.ts +690 -0
  103. package/payload/server/src/providers/claude-cli.ts +620 -0
  104. package/payload/server/src/providers/cli-check.ts +265 -0
  105. package/payload/server/src/providers/cli-turn.ts +20 -0
  106. package/payload/server/src/providers/codex-cli.ts +552 -0
  107. package/payload/server/src/providers/codex-workspace.ts +61 -0
  108. package/payload/server/src/providers/failures.ts +143 -0
  109. package/payload/server/src/providers/index.ts +131 -0
  110. package/payload/server/src/providers/mcp-stdio.ts +147 -0
  111. package/payload/server/src/providers/mock.ts +94 -0
  112. package/payload/server/src/providers/process.ts +268 -0
  113. package/payload/server/src/providers/readiness.ts +43 -0
  114. package/payload/server/src/providers/sse.ts +73 -0
  115. package/payload/server/src/providers/types.ts +173 -0
  116. package/payload/server/src/rates.ts +128 -0
  117. package/payload/server/src/reactions.ts +266 -0
  118. package/payload/server/src/recent-logs.ts +489 -0
  119. package/payload/server/src/rehearsal.ts +593 -0
  120. package/payload/server/src/rehearsals.ts +303 -0
  121. package/payload/server/src/replay.ts +165 -0
  122. package/payload/server/src/sandbox.ts +767 -0
  123. package/payload/server/src/schedule.ts +214 -0
  124. package/payload/server/src/scheduler.ts +24 -0
  125. package/payload/server/src/schema.ts +882 -0
  126. package/payload/server/src/secrets.ts +46 -0
  127. package/payload/server/src/setup.ts +356 -0
  128. package/payload/server/src/skill-packages.ts +363 -0
  129. package/payload/server/src/skills.ts +527 -0
  130. package/payload/server/src/spend.ts +255 -0
  131. package/payload/server/src/ssrf.ts +126 -0
  132. package/payload/server/src/starter-gate.ts +168 -0
  133. package/payload/server/src/starters.ts +610 -0
  134. package/payload/server/src/storage.ts +32 -0
  135. package/payload/server/src/store.ts +3087 -0
  136. package/payload/server/src/tailnet.ts +132 -0
  137. package/payload/server/src/templates.ts +454 -0
  138. package/payload/server/src/trash.ts +772 -0
  139. package/payload/server/src/util.ts +97 -0
  140. package/payload/server/src/webhooks.ts +216 -0
  141. package/payload/server/src/work-feed.ts +473 -0
  142. package/payload/shared/app-version.d.mts +1 -0
  143. package/payload/shared/app-version.mjs +3 -0
  144. package/payload/shared/boton.ts +323 -0
  145. package/payload/shared/brand.ts +107 -0
  146. package/payload/shared/cards.ts +427 -0
  147. package/payload/shared/effort.ts +91 -0
  148. package/payload/shared/files.ts +104 -0
  149. package/payload/shared/handoff.ts +97 -0
  150. package/payload/shared/install-commands.ts +7 -0
  151. package/payload/shared/legacy-names.d.mts +35 -0
  152. package/payload/shared/legacy-names.mjs +244 -0
  153. package/payload/shared/live-view.ts +94 -0
  154. package/payload/shared/look-critter.ts +189 -0
  155. package/payload/shared/look-tater.ts +127 -0
  156. package/payload/shared/look.ts +200 -0
  157. package/payload/shared/reactions.ts +43 -0
  158. package/payload/shared/seeded.ts +34 -0
  159. package/payload/shared/services.ts +90 -0
  160. package/payload/shared/types.ts +3016 -0
  161. package/payload/shared/util.ts +68 -0
  162. package/payload/shared/versions.ts +44 -0
  163. package/payload/shared/wake.ts +164 -0
  164. package/payload/web/dist/apple-touch-icon.png +0 -0
  165. package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
  166. package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
  167. package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
  168. package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
  169. package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
  170. package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
  171. package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
  172. package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
  173. package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
  174. package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
  175. package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
  176. package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
  177. package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
  178. package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
  179. package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
  180. package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
  181. package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
  183. package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
  184. package/payload/web/dist/favicon.svg +1 -0
  185. package/payload/web/dist/icon-192.png +0 -0
  186. package/payload/web/dist/icon-512.png +0 -0
  187. package/payload/web/dist/icon.svg +1 -0
  188. package/payload/web/dist/index.html +67 -0
  189. package/payload/web/dist/manifest.webmanifest +12 -0
  190. package/shared/legacy-names.d.mts +35 -0
  191. package/shared/legacy-names.mjs +244 -0
@@ -0,0 +1,552 @@
1
+ import { cliTurn } from './cli-turn.ts';
2
+ import { earlierEnvNames } from '../../../shared/legacy-names.mjs';
3
+ import { cliHome, prepareCliHomes } from '../cli-homes.ts';
4
+ import { closeSync, fstatSync, openSync, readdirSync, readFileSync, readSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import type { ModelEfforts } from '../../../shared/effort.ts';
8
+ import type { ProviderModel, TurnStep } from '../../../shared/types.ts';
9
+ import { instructionPaths } from '../instructions.ts';
10
+ import { log } from '../log.ts';
11
+ import { parseJson, seconds, shorten } from '../util.ts';
12
+ import { type CliCheck, CliChecker, terminalVersion, checkCodexCli, cliStatus, NOT_CHECKED } from './cli-check.ts';
13
+ import { classifyFailure } from './failures.ts';
14
+ import { agentEnv, failureMessage, INHERITED_ENV, runJsonl, withExit } from './process.ts';
15
+ import {
16
+ type Provider,
17
+ ProviderError,
18
+ type ProviderStatus,
19
+ step,
20
+ type TurnMcpServer,
21
+ type ToolObservation,
22
+ type TurnRequest,
23
+ type TurnResult,
24
+ type TurnUsage,
25
+ } from './types.ts';
26
+
27
+ const codexHome = () => cliHome('codex').selected;
28
+
29
+ const EFFORT_NAME = /^[a-z][a-z0-9_-]{0,23}$/;
30
+
31
+ /** A model's reasoning levels as Codex's model list gives them (supported_reasoning_levels[].effort), when it does. */
32
+ export function codexModel(m: any): ProviderModel {
33
+ const out: ProviderModel = { id: m.slug, label: m.display_name || m.slug };
34
+ const levels = Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels.map((l: any) => l?.effort).filter((e: unknown): e is string => typeof e === 'string' && EFFORT_NAME.test(e)) : [];
35
+ if (levels.length) out.efforts = [...new Set<string>(levels)];
36
+ if (typeof m.default_reasoning_level === 'string' && EFFORT_NAME.test(m.default_reasoning_level)) out.defaultEffort = m.default_reasoning_level;
37
+ return out;
38
+ }
39
+
40
+ function cachedModels(): ProviderModel[] {
41
+ try {
42
+ const data = JSON.parse(readFileSync(join(codexHome(), 'models_cache.json'), 'utf8'));
43
+ return (data.models ?? []).filter((m: any) => m.visibility === 'list' && typeof m.slug === 'string').map(codexModel);
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ const toml = (s: string) => JSON.stringify(s); // JSON strings are valid TOML basic strings
50
+ const tomlList = (list: string[]) => `[${list.map(toml).join(',')}]`;
51
+ /** Codex reads both timeouts as floats. */
52
+ const tomlFloat = (n: number) => (Number.isInteger(n) ? `${n}.0` : String(n));
53
+ const tomlTable = (entries: [string, string][]) => `{${entries.map(([k, v]) => `${toml(k)}=${toml(v)}`).join(',')}}`;
54
+
55
+ /** Set by the agent environment already, so a server's own value for one can't go through it. */
56
+ const AGENT_VARS = new Set([...INHERITED_ENV, ...['BOTONS_API', 'BOTON_BOT_ID', 'BOTON_TOKEN', 'BOTON_WORKSPACE', 'BOTONS_NODE'].flatMap((k) => [k, ...earlierEnvNames(k)])]);
57
+ const isAgentVar = (k: string) => AGENT_VARS.has(k) || k.startsWith('LC_') || k.startsWith('BOTONS_MCP_');
58
+
59
+ /** Why codexMcp can't pass `s`, given the variables the servers before it took, or null. */
60
+ function codexMcpProblem(s: TurnMcpServer, vars: Record<string, string>): string | null {
61
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(s.name)) return 'Codex server names may only use letters, digits, - and _';
62
+ if (s.kind === 'http' && s.sse) return 'Codex does not support SSE servers';
63
+ if (s.kind === 'stdio') {
64
+ for (const [k, v] of Object.entries(s.env)) if (!isAgentVar(k) && vars[k] !== undefined && vars[k] !== v) return `another server needs a different ${k}`;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function fitCodexMcp(servers: TurnMcpServer[]): { servers: TurnMcpServer[]; skipped: { name: string; reason: string }[] } {
70
+ const out: TurnMcpServer[] = [];
71
+ const skipped: { name: string; reason: string }[] = [];
72
+ const vars: Record<string, string> = {};
73
+ for (const s of servers) {
74
+ const why = codexMcpProblem(s, vars);
75
+ if (why) {
76
+ skipped.push({ name: s.name, reason: why });
77
+ continue;
78
+ }
79
+ if (s.kind === 'stdio') for (const [k, v] of Object.entries(s.env)) if (!isAgentVar(k)) vars[k] = v;
80
+ out.push(s);
81
+ }
82
+ return { servers: out, skipped };
83
+ }
84
+
85
+ /**
86
+ * `-c mcp_servers.<name>.…` overrides with the secrets off the command line: they go through the codex process's
87
+ * environment (env_vars, env_http_headers), and the agent's shell commands don't inherit them.
88
+ */
89
+ export function codexMcp(servers: TurnMcpServer[]): { args: string[]; env: Record<string, string> } {
90
+ const args: string[] = [];
91
+ const env: Record<string, string> = {};
92
+ const fit = fitCodexMcp(servers);
93
+ for (const s of fit.skipped) log.warn('MCP server left out of a codex turn', { server: s.name, reason: s.reason });
94
+ fit.servers.forEach((s, i) => {
95
+ const key = `mcp_servers.${s.name}`;
96
+ const mine: Record<string, string> = {};
97
+ const own: [string, string][] = [];
98
+ if (s.kind === 'stdio') {
99
+ // The turn's token is a secret, so never on the command line: the server reads it from Codex's own environment.
100
+ const forward: string[] = [];
101
+ for (const [k, v] of Object.entries(s.env)) {
102
+ if (k === 'BOTON_TOKEN' || earlierEnvNames('BOTON_TOKEN').includes(k)) forward.push(k);
103
+ else if (isAgentVar(k)) own.push([k, v]);
104
+ else mine[k] = v;
105
+ }
106
+ args.push('-c', `${key}.command=${toml(s.command)}`, '-c', `${key}.args=${tomlList(s.args)}`);
107
+ if (s.cwd) args.push('-c', `${key}.cwd=${toml(s.cwd)}`);
108
+ if (own.length) args.push('-c', `${key}.env=${tomlTable(own)}`);
109
+ if (Object.keys(mine).length || forward.length) args.push('-c', `${key}.env_vars=${tomlList([...Object.keys(mine), ...forward])}`);
110
+ } else {
111
+ const headers: [string, string][] = [];
112
+ Object.entries(s.headers).forEach(([h, v], j) => {
113
+ const name = `BOTONS_MCP_${i}_${j}`;
114
+ mine[name] = v;
115
+ headers.push([h, name]);
116
+ });
117
+ args.push('-c', `${key}.url=${toml(s.url)}`);
118
+ if (headers.length) args.push('-c', `${key}.env_http_headers=${tomlTable(headers)}`);
119
+ }
120
+ if (s.startupTimeoutSec !== undefined) args.push('-c', `${key}.startup_timeout_sec=${tomlFloat(s.startupTimeoutSec)}`);
121
+ if (s.toolTimeoutSec !== undefined) args.push('-c', `${key}.tool_timeout_sec=${tomlFloat(s.toolTimeoutSec)}`);
122
+ args.push('-c', `${key}.default_tools_approval_mode="approve"`);
123
+ Object.assign(env, mine);
124
+ });
125
+ if (Object.keys(env).length) args.push('-c', `shell_environment_policy.exclude=${tomlList(Object.keys(env))}`);
126
+ return { args, env };
127
+ }
128
+
129
+ /** None of the user's Codex setup at any access level; the login in CODEX_HOME still works. */
130
+ const CODEX_ISOLATION = [
131
+ '--ignore-user-config',
132
+ '--ignore-rules',
133
+ '--disable',
134
+ 'plugins',
135
+ '--disable',
136
+ 'remote_plugin',
137
+ '--disable',
138
+ 'apps',
139
+ '--disable',
140
+ 'tool_suggest',
141
+ '--disable',
142
+ 'hooks',
143
+ '-c',
144
+ 'skills.include_instructions=false',
145
+ '-c',
146
+ 'skills.bundled.enabled=false',
147
+ ];
148
+
149
+ function accessArgs(req: Pick<TurnRequest, 'access' | 'privatePaths' | 'sharedDirs' | 'cwd' | 'protectInstructions'>): string[] {
150
+ if (req.access === 'host') return ['--dangerously-bypass-approvals-and-sandbox'];
151
+ // Workspace: a permission profile (sandbox_workspace_write.writable_roots is ignored under one). The network stays
152
+ // on for the `botons` CLI. The .claude folders are read-only, so a Codex Boton can't plant settings or hooks for
153
+ // a Claude Boton; so are the instruction files in a sandboxed turn (a missing file given "read" can't be created).
154
+ const shared = req.sharedDirs ?? [];
155
+ const settingsDirs = [req.cwd, ...shared].map((d) => join(d, '.claude'));
156
+ const instructions = req.protectInstructions ? instructionPaths(req.cwd) : [];
157
+ const filesystem = [
158
+ ...req.privatePaths.map((p) => `${toml(p)}="none"`),
159
+ ...shared.map((p) => `${toml(p)}="write"`),
160
+ ...[...settingsDirs, ...instructions].map((p) => `${toml(p)}="read"`),
161
+ ].join(',');
162
+ return [
163
+ '-c',
164
+ 'default_permissions="boton"',
165
+ '-c',
166
+ 'permissions.boton.extends=":workspace"',
167
+ '-c',
168
+ 'permissions.boton.network.enabled=true',
169
+ '-c',
170
+ `permissions.boton.filesystem={${filesystem}}`,
171
+ '-c',
172
+ 'approval_policy="never"',
173
+ ];
174
+ }
175
+
176
+ /** Codex runs each command as `<shell> -lc '<command>'`: take the shell and its quoting off. */
177
+ export function unwrapShell(command: string): string {
178
+ const m = /^(?:\/usr)?(?:\/bin\/)?(?:ba|z)?sh -lc ('(?:[^']|'\\'')*'|"(?:[^"\\]|\\.)*")$/s.exec(command);
179
+ if (!m) return command;
180
+ const quoted = m[1];
181
+ return quoted.startsWith("'") ? quoted.slice(1, -1).replaceAll("'\\''", "'") : quoted.slice(1, -1).replace(/\\(.)/gs, '$1');
182
+ }
183
+
184
+ export function itemStep(item: any): TurnStep | null {
185
+ const label = describeItem(item) ?? '';
186
+ switch (item?.type) {
187
+ case 'command_execution':
188
+ return step('command', label, unwrapShell(String(item.command ?? '')));
189
+ case 'file_change': {
190
+ const changes = Array.isArray(item.changes) ? item.changes : [];
191
+ return step('edit', label, changes.map((c: any) => `${c?.kind ?? 'update'} ${c?.path ?? '?'}`).join('\n'));
192
+ }
193
+ case 'web_search':
194
+ return step('web', label, String(item.query ?? ''));
195
+ case 'mcp_tool_call':
196
+ return step('tool', label, `${item.server ? `${item.server}.` : ''}${item.tool ?? '?'} ${JSON.stringify(item.arguments ?? {})}`);
197
+ default:
198
+ return null;
199
+ }
200
+ }
201
+
202
+ function describeItem(item: any): string | null {
203
+ switch (item?.type) {
204
+ case 'command_execution':
205
+ return `Running \`${shorten(String(item.command ?? '').replace(/^\/usr\/bin\/bash -lc /, ''), 70)}\``;
206
+ case 'reasoning':
207
+ return 'Thinking';
208
+ case 'file_change':
209
+ return 'Editing files';
210
+ case 'web_search':
211
+ return item.query ? `Searching the web for “${shorten(String(item.query), 50)}”` : 'Searching the web';
212
+ case 'mcp_tool_call':
213
+ return `Using ${item.tool ?? 'a tool'}`;
214
+ case 'todo_list':
215
+ return 'Planning';
216
+ case 'agent_message':
217
+ return 'Writing';
218
+ default:
219
+ return null;
220
+ }
221
+ }
222
+
223
+ /** A completed mcp_tool_call item on one of the turn's servers, as an observation; null for anything else. */
224
+ export function codexToolResult(item: any, servers: string[]): ToolObservation | null {
225
+ if (item?.type !== 'mcp_tool_call' || typeof item.server !== 'string' || !servers.includes(item.server) || typeof item.tool !== 'string') return null;
226
+ const result = item.result && typeof item.result === 'object' ? item.result : null;
227
+ const sc = result?.structured_content ?? result?.structuredContent;
228
+ let structured = sc && typeof sc === 'object' && !Array.isArray(sc) ? (sc as Record<string, unknown>) : null;
229
+ if (!structured && Array.isArray(result?.content)) {
230
+ for (const b of result.content) {
231
+ if (b?.type !== 'text' || typeof b.text !== 'string' || !b.text.trim().startsWith('{') || b.text.length > 200_000) continue;
232
+ try {
233
+ const v = JSON.parse(b.text);
234
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
235
+ structured = v;
236
+ break;
237
+ }
238
+ } catch {}
239
+ }
240
+ }
241
+ const ok = item.status === 'completed' && !item.error && result?.is_error !== true && result?.isError !== true;
242
+ return { callId: String(item.id ?? ''), server: item.server, tool: item.tool, ok, structured };
243
+ }
244
+
245
+ const count = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
246
+
247
+ /** Codex's own counts: input includes cached input. */
248
+ interface CodexTokens {
249
+ input: number;
250
+ cached: number;
251
+ cacheWrite: number;
252
+ output: number;
253
+ }
254
+
255
+ function codexTokens(u: any): CodexTokens {
256
+ return { input: count(u?.input_tokens), cached: count(u?.cached_input_tokens), cacheWrite: count(u?.cache_write_input_tokens), output: count(u?.output_tokens) };
257
+ }
258
+
259
+ function asUsage(t: CodexTokens): TurnUsage {
260
+ return { inputTokens: Math.max(0, t.input - t.cached), outputTokens: t.output, cacheReadTokens: t.cached, cacheWriteTokens: t.cacheWrite };
261
+ }
262
+
263
+ /** A thread's latest turn (after the last turn_context line) as its rollout file records it. */
264
+ interface RolloutTurn {
265
+ model: string | null;
266
+ /** From its token_usage_record. */
267
+ own: CodexTokens | null;
268
+ /** The thread's running totals at its end. */
269
+ after: CodexTokens | null;
270
+ /** The thread's running totals when it started; null if the part of the file read doesn't reach back to them. */
271
+ before: CodexTokens | null;
272
+ }
273
+
274
+ const ZERO: CodexTokens = { input: 0, cached: 0, cacheWrite: 0, output: 0 };
275
+
276
+ /** `text` is the end of a rollout file, all of it when `whole` (else its first line may be cut off). */
277
+ export function parseRolloutTurn(text: string, whole: boolean): RolloutTurn | null {
278
+ const lines = text.split('\n');
279
+ const parse = (line: string): any => parseJson(line, null);
280
+ const totals = (ev: any): CodexTokens | null => {
281
+ const t = ev?.type === 'event_msg' && ev.payload?.type === 'token_count' ? ev.payload.info?.total_token_usage : null;
282
+ return t && typeof t === 'object' ? codexTokens(t) : null;
283
+ };
284
+ let i = lines.length - 1;
285
+ let context: any = null;
286
+ let after: CodexTokens | null = null;
287
+ const records: any[] = [];
288
+ for (; i >= 0 && !context; i--) {
289
+ const line = lines[i];
290
+ if (line.includes('"turn_context"')) {
291
+ const ev = parse(line);
292
+ if (ev?.type === 'turn_context') context = ev.payload ?? {};
293
+ } else if (line.includes('"token_count"')) {
294
+ after ??= totals(parse(line));
295
+ } else if (line.includes('"token_usage_record"')) {
296
+ const ev = parse(line);
297
+ if (ev?.type === 'token_usage_record') records.push(ev.payload);
298
+ }
299
+ }
300
+ if (!context) return null;
301
+ const model = typeof context.model === 'string' && context.model ? context.model : null;
302
+ // The turn's last record (records are newest first); its turn_token_usage counts every request of the turn.
303
+ const record = records.find((r) => r?.turn_id && r.turn_id === context.turn_id && r.turn_token_usage);
304
+ let before: CodexTokens | null = null;
305
+ for (; i >= 0 && !before; i--) if (lines[i].includes('"token_count"')) before = totals(parse(lines[i]));
306
+ return { model, own: record ? codexTokens(record.turn_token_usage) : null, after, before: before ?? (whole ? ZERO : null) };
307
+ }
308
+
309
+ /** By Codex home and thread id (a resumed thread keeps the file it started in). */
310
+ const rollouts = new Map<string, string>();
311
+
312
+ /** <home>/sessions/YYYY/MM/DD/rollout-<time>-<thread id>.jsonl, newest folders first. */
313
+ function findRollout(home: string, threadId: string): string | null {
314
+ const key = `${home}\0${threadId}`;
315
+ const known = rollouts.get(key);
316
+ if (known) return known;
317
+ const suffix = `-${threadId}.jsonl`;
318
+ const desc = (dir: string) => {
319
+ try {
320
+ return readdirSync(dir).sort().reverse();
321
+ } catch {
322
+ return [];
323
+ }
324
+ };
325
+ const root = join(home, 'sessions');
326
+ for (const y of desc(root)) {
327
+ for (const m of desc(join(root, y))) {
328
+ for (const d of desc(join(root, y, m))) {
329
+ const name = desc(join(root, y, m, d)).find((f) => f.startsWith('rollout-') && f.endsWith(suffix));
330
+ if (!name) continue;
331
+ const path = join(root, y, m, d, name);
332
+ if (rollouts.size >= 200) rollouts.delete(rollouts.keys().next().value!);
333
+ rollouts.set(key, path);
334
+ return path;
335
+ }
336
+ }
337
+ }
338
+ return null;
339
+ }
340
+
341
+ const ROLLOUT_TAILS = [256 * 1024, 4 * 1024 * 1024, 32 * 1024 * 1024];
342
+
343
+ /** The model a turn ran on (`codex exec --json` doesn't say) and its tokens. `needStart`: read back to the turn's start. */
344
+ function rolloutTurn(threadId: string, home: string, needStart: boolean): RolloutTurn | null {
345
+ const path = findRollout(home, threadId);
346
+ if (!path) return null;
347
+ let found: RolloutTurn | null = null;
348
+ try {
349
+ const fd = openSync(path, 'r');
350
+ try {
351
+ const size = fstatSync(fd).size;
352
+ for (const tail of ROLLOUT_TAILS) {
353
+ const length = Math.min(size, tail);
354
+ const buf = Buffer.alloc(length);
355
+ readSync(fd, buf, 0, length, size - length);
356
+ found = parseRolloutTurn(buf.toString('utf8'), length === size) ?? found;
357
+ if ((found && (!needStart || found.own || found.before)) || length === size) break;
358
+ }
359
+ } finally {
360
+ closeSync(fd);
361
+ }
362
+ } catch {}
363
+ return found;
364
+ }
365
+
366
+ /**
367
+ * `turn.completed` reports the thread's running totals, so in a resumed thread the turn's own usage is its rollout
368
+ * record, else the totals less those it started from (when the rollout agrees with Codex). Null when neither is known.
369
+ */
370
+ function completedUsage(reported: any, resumed: boolean, rollout: RolloutTurn | null): TurnUsage | null {
371
+ const total = codexTokens(reported);
372
+ if (!resumed) return asUsage(total);
373
+ if (rollout?.own) return asUsage(rollout.own);
374
+ const { after, before } = rollout ?? {};
375
+ if (!after || !before || after.input !== total.input || after.output !== total.output) return null;
376
+ const own: CodexTokens = { input: total.input - before.input, cached: total.cached - before.cached, cacheWrite: total.cacheWrite - before.cacheWrite, output: total.output - before.output };
377
+ return Object.values(own).every((n) => n >= 0) ? asUsage(own) : null;
378
+ }
379
+
380
+ const MISSING_THREAD = ['no rollout found for thread id', 'thread not found:'];
381
+
382
+ export function codexArgs(
383
+ req: Pick<TurnRequest, 'systemPrompt' | 'model' | 'effort' | 'sessionId' | 'access' | 'cwd' | 'privatePaths' | 'images' | 'sharedDirs' | 'protectInstructions'>,
384
+ mcpArgs: string[] = [],
385
+ ): string[] {
386
+ const common = [
387
+ // First: --image takes several values, up to the next option.
388
+ ...(req.images ?? []).flatMap((path) => ['--image', path]),
389
+ '--json',
390
+ '--skip-git-repo-check',
391
+ ...CODEX_ISOLATION,
392
+ ...(req.model ? ['-m', req.model] : []),
393
+ // Over --ignore-user-config, so only this turn's level (or Codex's default for the model) applies.
394
+ ...(req.effort ? ['-c', `model_reasoning_effort=${toml(req.effort)}`] : []),
395
+ ...accessArgs(req),
396
+ ...mcpArgs,
397
+ '-c',
398
+ `developer_instructions=${toml(req.systemPrompt)}`,
399
+ ];
400
+ return req.sessionId ? ['exec', 'resume', ...common, req.sessionId, '-'] : ['exec', ...common, '-C', req.cwd, '-'];
401
+ }
402
+
403
+ export class CodexCliProvider implements Provider {
404
+ id = 'codex-cli';
405
+ label = 'Codex (local CLI)';
406
+ cli = 'codex' as const;
407
+ #bin: string;
408
+ #home: string | undefined;
409
+ #check: CliChecker;
410
+ /** Botons' own copy (managed-clis.ts), not the system's. */
411
+ #managed: boolean;
412
+
413
+ /** `home`: where Codex keeps its rollouts; CODEX_HOME, else ~/.codex. */
414
+ constructor(bin: string, home?: string, managed = false) {
415
+ this.#bin = bin;
416
+ this.#home = home;
417
+ this.#managed = managed;
418
+ this.#check = new CliChecker(this.id, async () => { const c = await checkCodexCli(this.#bin); return { ...c, terminal: managed && c.installed ? await terminalVersion('codex-cli', this.#bin) : null }; });
419
+ }
420
+
421
+ async info(): Promise<ProviderStatus> {
422
+ return this.#status(await this.#check.get());
423
+ }
424
+
425
+ snapshot(): ProviderStatus {
426
+ return this.#status(this.#check.peek());
427
+ }
428
+
429
+ #status(c: CliCheck | null): ProviderStatus {
430
+ return {
431
+ id: this.id,
432
+ label: this.label,
433
+ kind: 'cli',
434
+ ...cliStatus(c ?? NOT_CHECKED),
435
+ ...(c ? {} : { checkedAt: null }),
436
+ copy: { managed: this.#managed, terminal: c?.terminal ?? null },
437
+ models: [{ id: '', label: 'Codex default' }, ...cachedModels()],
438
+ defaultModel: '',
439
+ };
440
+ }
441
+
442
+ recheck(): void {
443
+ this.#check.invalidate();
444
+ }
445
+
446
+ /** The levels models_cache.json lists for the model; null when it isn't there or lists none (Codex's default model: ""). */
447
+ modelEfforts(model: string): ModelEfforts | null {
448
+ const m = model ? cachedModels().find((x) => x.id === model) : undefined;
449
+ return m?.efforts?.length ? { model, label: m.label, levels: m.efforts, ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}) } : null;
450
+ }
451
+
452
+ fitMcpServers = fitCodexMcp;
453
+
454
+ async runTurn(req: TurnRequest): Promise<TurnResult> {
455
+ prepareCliHomes();
456
+ // No toolFree here (Provider.toolFree): a Codex turn can't be given no tools, so it is refused, never run with them.
457
+ if (req.toolFree) throw new Error("Codex can't run a turn with no tools");
458
+ if (req.signal.aborted) throw new ProviderError('Stopped.');
459
+ const compatibility = await this.#check.get();
460
+ if (req.signal.aborted) throw new ProviderError('Stopped.');
461
+ if (!compatibility.installed || !compatibility.version || compatibility.problem === 'incompatible-version') throw new ProviderError(compatibility.reason ?? 'Could not verify the Codex version; check the server executable and retry.');
462
+ if (req.access === 'workspace' && compatibility.workspaceProblem) throw new ProviderError(compatibility.workspaceProblem);
463
+ let sessionId: string | null = req.sessionId;
464
+ let lastMessage = '';
465
+ let completed = false;
466
+ let reported: unknown = null;
467
+ let turnFailure: string | null = null;
468
+ // Codex also sends errors it recovers from (a dropped stream), so they only fail a turn that never completes.
469
+ const errors: string[] = [];
470
+ // The thread was found and the turn got going.
471
+ let activity = false;
472
+ // An item starts, then completes: one step.
473
+ const stepped = new Set<string>();
474
+ // `codex exec --json` says nothing of MCP startup: a tool call it ran on a server is the only sign it started.
475
+ const servers = (req.mcpServers ?? []).map((s) => s.name);
476
+ const called = new Set<string>();
477
+
478
+ const mcp = codexMcp(req.mcpServers ?? []);
479
+ const snapshot = cliTurn(req, 'codex', mcp.env);
480
+ const run = await runJsonl(this.#bin, codexArgs(snapshot.req, mcp.args), {
481
+ cwd: req.cwd,
482
+ groupDir: req.groupDir,
483
+ env: snapshot.env,
484
+ stdin: req.prompt,
485
+ signal: req.signal,
486
+ timeoutMs: req.timeoutMs,
487
+ onEvent: (ev) => {
488
+ if (ev.type === 'thread.started' && ev.thread_id) sessionId = ev.thread_id;
489
+ else if (ev.type === 'turn.started') activity = true;
490
+ else if (ev.type === 'item.started' || ev.type === 'item.completed') {
491
+ activity = true;
492
+ if (ev.type === 'item.completed' && ev.item?.type === 'mcp_tool_call' && ev.item.status === 'completed' && servers.includes(ev.item.server)) called.add(ev.item.server);
493
+ if (ev.type === 'item.completed' && req.onToolResult) {
494
+ const obs = codexToolResult(ev.item, servers);
495
+ if (obs) {
496
+ try {
497
+ req.onToolResult(obs);
498
+ } catch (err) {
499
+ log.warn('could not record a tool result', { provider: 'codex-cli', err });
500
+ }
501
+ }
502
+ }
503
+ if (ev.type === 'item.completed' && ev.item?.type === 'agent_message' && typeof ev.item.text === 'string') {
504
+ lastMessage = ev.item.text;
505
+ if (lastMessage.trim()) req.onText?.(lastMessage);
506
+ }
507
+ const a = describeItem(ev.item);
508
+ if (a) req.onActivity(a);
509
+ const id = String(ev.item?.id ?? '');
510
+ if (!stepped.has(id)) {
511
+ const s = itemStep(ev.item);
512
+ if (s) {
513
+ if (id) stepped.add(id);
514
+ req.onStep?.(s);
515
+ }
516
+ }
517
+ } else if (ev.type === 'turn.completed') {
518
+ completed = true;
519
+ if (ev.usage) reported = ev.usage;
520
+ } else if (ev.type === 'turn.failed') turnFailure = ev.error?.message ?? 'Codex turn failed';
521
+ else if (ev.type === 'error') errors.push(String(ev.message ?? 'Codex error'));
522
+ },
523
+ isDone: (ev) => ev?.type === 'turn.completed' && turnFailure === null,
524
+ });
525
+
526
+ const failed = turnFailure as string | null;
527
+ const resumed = !!req.sessionId;
528
+ const rollout = sessionId ? rolloutTurn(sessionId, this.#home ?? snapshot.env.CODEX_HOME!, resumed && !!reported) : null;
529
+ const model = rollout?.model ?? (req.model || null);
530
+ const used = reported ? completedUsage(reported, resumed, rollout) : null;
531
+ if (reported && !used) {
532
+ log.warn("codex turn's own tokens are unknown: turn.completed has the thread's running totals, and its rollout doesn't say where this turn started", {
533
+ provider: this.id,
534
+ sessionId: sessionId ?? undefined,
535
+ });
536
+ }
537
+ const mcpStartup = called.size ? [...called].map((name) => ({ name, started: true, tools: null })) : undefined;
538
+ const turnUsage = used || model ? { ...(used ?? { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }), ...(model ? { model } : {}) } : null;
539
+ // A turn it completed once stopped or past its time limit doesn't count: that ended the turn first.
540
+ if (completed && !failed && !run.aborted && !run.timedOut) return { sessionId, text: lastMessage, usage: turnUsage, ...(errors.length ? { warnings: errors } : {}), ...(mcpStartup ? { mcpStartup } : {}) };
541
+ const failure = failed ?? errors.at(-1) ?? null;
542
+ const said = `${failure ?? ''}\n${run.stderr}`;
543
+ const lost = !!req.sessionId && !activity && !run.aborted && !run.timedOut && MISSING_THREAD.some((m) => said.includes(m));
544
+ const message = failureMessage('Codex', run, failure);
545
+ // Not stderr for transient failures: Codex logs network noise there on runs that work.
546
+ const info = run.aborted || run.timedOut || lost ? {} : classifyFailure(`${message}\n${said}`, `${failure ?? ''}\n${errors.join('\n')}`);
547
+ const err = withExit(new ProviderError(message, lost, info), run);
548
+ err.usage = turnUsage;
549
+ if (mcpStartup) err.mcpStartup = mcpStartup;
550
+ throw err;
551
+ }
552
+ }
@@ -0,0 +1,61 @@
1
+ import { createServer } from 'node:http';
2
+ import { spawn } from 'node:child_process';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ export function workspaceFailure(stderr: string): string {
8
+ if (/Can't mount proc on \/proc|mount.*proc.*Operation not permitted/i.test(stderr)) {
9
+ return 'Not available on this server: it hides part of /proc, which the sandbox needs.';
10
+ }
11
+ return 'Not available on this server: the Workspace sandbox could not start.';
12
+ }
13
+
14
+ /** Offline real helper probe: no model, login, owner config or owner workspace is used. */
15
+ export async function probeCodexWorkspace(bin: string, timeoutMs = 10000): Promise<string | null> {
16
+ if (process.platform !== 'linux') return null;
17
+ const root = mkdtempSync(join(tmpdir(), 'botons-codex-workspace-'));
18
+ const home = join(root, 'home');
19
+ const cwd = join(root, 'workspace');
20
+ mkdirSync(home); mkdirSync(cwd);
21
+ const secret = join(root, 'private');
22
+ writeFileSync(secret, 'fixture');
23
+ const filesystem = `{${JSON.stringify(secret)}="none",${JSON.stringify(join(cwd, '.claude'))}="read",${JSON.stringify(join(cwd, 'AGENTS.md'))}="read"}`;
24
+ const args = ['sandbox', '-C', cwd, '-P', 'preflight', '-c', 'permissions.preflight.extends=":workspace"', '-c', 'permissions.preflight.network.enabled=false', '-c', `permissions.preflight.filesystem=${filesystem}`, '--', '/bin/sh', '-c', 'set -e; echo ok > writable; if cat "$1" 2>/dev/null; then exit 31; fi; if touch .claude 2>/dev/null; then exit 32; fi; if touch AGENTS.md 2>/dev/null; then exit 33; fi; echo BOTONS_WORKSPACE_PROBE_OK', 'probe', secret];
25
+ const run = (argv: string[], startup: boolean): Promise<string | null> => new Promise(resolve => {
26
+ const child = spawn(bin, argv, { cwd, env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home, CODEX_HOME: home, CLAUDE_CONFIG_DIR: join(home, 'claude'), OPENAI_API_KEY: '', ANTHROPIC_API_KEY: '', DISABLE_AUTOUPDATER: '1' }, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
27
+ let stdout = ''; let stderr = ''; let timedOut = false; let ready = false;
28
+ const kill = () => { try { if (child.pid) process.kill(-child.pid, 'SIGKILL'); } catch {} };
29
+ const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
30
+ child.stdout.on('data', d => {
31
+ stdout = (stdout + d).slice(-8192);
32
+ if (startup && stdout.split('\n').some(line => { try { return JSON.parse(line).type === 'thread.started'; } catch { return false; } })) { ready = true; kill(); }
33
+ });
34
+ child.stderr.on('data', d => { stderr = (stderr + d).slice(-4096); });
35
+ child.on('error', () => { clearTimeout(timer); resolve(workspaceFailure('')); });
36
+ child.on('close', code => {
37
+ clearTimeout(timer); kill();
38
+ resolve(!timedOut && (startup ? ready : code === 0 && stdout.trim() === 'BOTONS_WORKSPACE_PROBE_OK') ? null : workspaceFailure(stderr));
39
+ });
40
+ });
41
+ const endpoint = createServer((_req, res) => { res.writeHead(503, { Connection: 'close' }); res.end(); });
42
+ try {
43
+ const problem = await run(args, false);
44
+ if (problem) return problem;
45
+ // `sandbox` alone can take a different proc fallback from the filesystem helper
46
+ // used to load AGENTS.md. Confirm real session initialization too. The fixed
47
+ // owned loopback stub and no-auth provider cannot reach a model or use a login.
48
+ // Stop the complete process group as soon as session creation is confirmed.
49
+ await new Promise<void>((resolve, reject) => { endpoint.once('error', reject); endpoint.listen(0, '127.0.0.1', resolve); });
50
+ const port = (endpoint.address() as { port: number }).port;
51
+ return await run(['exec', '--json', '--skip-git-repo-check', '-C', cwd,
52
+ '-c', 'model_provider="preflight"',
53
+ '-c', `model_providers.preflight={name="Preflight",base_url="http://127.0.0.1:${port}/v1",wire_api="responses",requires_openai_auth=false}`,
54
+ '-c', 'default_permissions="preflight"', '-c', 'permissions.preflight.extends=":workspace"',
55
+ '-c', 'permissions.preflight.network.enabled=true', '-c', `permissions.preflight.filesystem=${filesystem}`,
56
+ 'Reply OK'], true);
57
+ } catch { return workspaceFailure(''); } finally {
58
+ endpoint.closeAllConnections(); endpoint.close();
59
+ rmSync(root, { recursive: true, force: true });
60
+ }
61
+ }