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,265 @@
1
+ import { probeCodexWorkspace } from './codex-workspace.ts';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import type { BrainCheckProblem } from '../../../shared/types.ts';
4
+ import { hostname } from 'node:os';
5
+ import { log } from '../log.ts';
6
+ import { nowIso, truncate } from '../util.ts';
7
+ import { agentEnv } from './process.ts';
8
+ import { terminalCli, type ManagedProvider } from '../managed-clis.ts';
9
+ import type { ProviderStatus } from './types.ts';
10
+
11
+ const CHECK_TTL_MS = 5 * 60_000;
12
+ /** Each of a check's two commands; a check takes at most twice this. */
13
+ export const CHECK_TIMEOUT_MS = 10_000;
14
+
15
+ /** What a check alone can find wrong with a CLI. */
16
+ export type CliCheckProblem = Extract<BrainCheckProblem, 'not-found' | 'not-runnable' | 'version-failed' | 'incompatible-version' | 'timed-out' | 'login-unreadable' | 'signed-out'>;
17
+
18
+ export interface CliCheck {
19
+ installed: boolean;
20
+ workspaceProblem?: string | null;
21
+ version: string | null;
22
+ /** False when the CLI says it is signed out; null when that could not be told. */
23
+ loggedIn: boolean | null;
24
+ reason: string | null;
25
+ checkedAt: string;
26
+ /** Why it is not ready, as a fixed word; null when installed and signed in. Left out by checks that don't say. */
27
+ problem?: CliCheckProblem | null;
28
+ /** Higher for each check the checker finishes (CliChecker sets it). */
29
+ generation?: number;
30
+ /** `--version` of the other copy a terminal here runs, when Botons runs its own; null when there is none. */
31
+ terminal?: string | null;
32
+ }
33
+
34
+ /** The `--version` line of the copy a terminal runs as `codex` or `claude`, when that is another file than `bin`. Runs only `--version`. */
35
+ export async function terminalVersion(provider: ManagedProvider, bin: string, timeoutMs = 5_000): Promise<string | null> {
36
+ const other = terminalCli(provider, bin);
37
+ if (!other) return null;
38
+ const v = await capture(other, ['--version'], timeoutMs);
39
+ return v.code === 0 ? v.stdout.trim().split('\n')[0]?.trim().slice(0, 80) || null : null;
40
+ }
41
+
42
+ export interface Captured {
43
+ code: number | null;
44
+ stdout: string;
45
+ stderr: string;
46
+ /** The spawn error code (ENOENT…) when the command could not run at all. */
47
+ error: string | null;
48
+ timedOut: boolean;
49
+ }
50
+
51
+ /** Run a short command in the agents' environment and capture its output. Never rejects. */
52
+ function capture(cmd: string, args: string[], timeoutMs = CHECK_TIMEOUT_MS): Promise<Captured> {
53
+ return new Promise((resolve) => {
54
+ let stdout = '';
55
+ let stderr = '';
56
+ let timedOut = false;
57
+ let settled = false;
58
+ const done = (r: Omit<Captured, 'stdout' | 'stderr' | 'timedOut'>) => {
59
+ if (settled) return;
60
+ settled = true;
61
+ clearTimeout(timer);
62
+ resolve({ ...r, stdout, stderr, timedOut });
63
+ };
64
+ let child: ReturnType<typeof spawn>;
65
+ try {
66
+ child = spawn(cmd, args, { env: agentEnv({ DISABLE_AUTOUPDATER: '1', DISABLE_UPDATES: '1' }), stdio: ['ignore', 'pipe', 'pipe'] });
67
+ } catch (err) {
68
+ return resolve({ code: null, stdout, stderr, error: (err as NodeJS.ErrnoException).code ?? (err as Error).message, timedOut });
69
+ }
70
+ const timer = setTimeout(() => {
71
+ timedOut = true;
72
+ child.kill('SIGKILL');
73
+ }, timeoutMs);
74
+ child.stdout!.setEncoding('utf8').on('data', (d: string) => (stdout = (stdout + d).slice(-16_000)));
75
+ child.stderr!.setEncoding('utf8').on('data', (d: string) => (stderr = (stderr + d).slice(-16_000)));
76
+ child.on('error', (err: NodeJS.ErrnoException) => done({ code: null, error: err.code ?? err.message }));
77
+ child.on('close', (code) => done({ code, error: null }));
78
+ });
79
+ }
80
+
81
+ function failure(out: Captured, timeoutMs = CHECK_TIMEOUT_MS): string {
82
+ if (out.timedOut) return `it did not answer within ${timeoutMs / 1000}s`;
83
+ if (out.error) return out.error;
84
+ const last = (out.stderr.trim() || out.stdout.trim()).split('\n').at(-1)?.trim() ?? '';
85
+ return truncate(last, 200) || `exit ${out.code}`;
86
+ }
87
+
88
+ type LoginResult = Pick<CliCheck, 'loggedIn' | 'reason'>;
89
+
90
+ export function parseClaudeLogin(out: Captured, host = hostname()): LoginResult {
91
+ try {
92
+ const status = JSON.parse(out.stdout) as { loggedIn?: unknown };
93
+ if (status.loggedIn === true) return { loggedIn: true, reason: null };
94
+ if (status.loggedIn === false) {
95
+ return { loggedIn: false, reason: `Claude Code is not signed in. Run \`claude auth login\` on ${host}, as the user the server runs as.` };
96
+ }
97
+ } catch {}
98
+ return { loggedIn: null, reason: `Could not check the Claude login (\`claude auth status\`): ${failure(out)}` };
99
+ }
100
+
101
+ export function parseCodexLogin(out: Captured, host = hostname()): LoginResult {
102
+ const said = `${out.stdout}\n${out.stderr}`;
103
+ if (!out.timedOut && /\bnot logged in\b/i.test(said)) {
104
+ return { loggedIn: false, reason: `Codex is not signed in. Run \`codex login\` on ${host}, as the user the server runs as.` };
105
+ }
106
+ if (out.code === 0) return { loggedIn: true, reason: null };
107
+ return { loggedIn: null, reason: `Could not check the Codex login (\`codex login status\`): ${failure(out)}` };
108
+ }
109
+
110
+ /**
111
+ * What a login check's answer can be taken for. A command that didn't answer in time confirms nothing, whatever it
112
+ * printed first; one that failed can say it is signed out, but not that it is signed in.
113
+ */
114
+ function confirmedLogin(out: Captured, parsed: LoginResult, cmd: string, timeoutMs: number): LoginResult {
115
+ if (out.timedOut) return { loggedIn: null, reason: `Could not check the sign-in (${cmd}): ${failure(out, timeoutMs)}` };
116
+ if (parsed.loggedIn === true && out.code !== 0) return { loggedIn: null, reason: `Could not check the sign-in (${cmd}): ${failure(out, timeoutMs)}` };
117
+ return parsed;
118
+ }
119
+
120
+ /** Why `--version` failed, as a fixed word. */
121
+ function versionProblem(v: Captured): CliCheckProblem {
122
+ if (v.timedOut) return 'timed-out';
123
+ if (v.error === 'ENOENT') return 'not-found';
124
+ return v.error ? 'not-runnable' : 'version-failed';
125
+ }
126
+
127
+ export async function checkCli(bin: string, loginArgs: string[], parseLogin: (out: Captured) => LoginResult, timeoutMs = CHECK_TIMEOUT_MS, checkVersion?: (version: string | null) => string | null): Promise<CliCheck> {
128
+ const checkedAt = nowIso();
129
+ const v = await capture(bin, ['--version'], timeoutMs);
130
+ if (v.code !== 0) {
131
+ const reason = v.error === 'ENOENT' ? `\`${bin}\` was not found on PATH` : `\`${bin} --version\` failed: ${failure(v, timeoutMs)}`;
132
+ return { installed: false, version: null, loggedIn: null, reason, checkedAt, problem: versionProblem(v) };
133
+ }
134
+ const version = v.stdout.trim().split('\n')[0]?.trim().slice(0, 80) || null;
135
+ const incompatible = checkVersion?.(version);
136
+ if (incompatible) return { installed: true, version, loggedIn: null, reason: incompatible, checkedAt, problem: 'incompatible-version' };
137
+ const out = await capture(bin, loginArgs, timeoutMs);
138
+ const login = confirmedLogin(out, parseLogin(out), `\`${[bin, ...loginArgs].join(' ')}\``, timeoutMs);
139
+ const problem: CliCheckProblem | null = login.loggedIn === true ? null : login.loggedIn === false ? 'signed-out' : out.timedOut ? 'timed-out' : 'login-unreadable';
140
+ return { installed: true, version, ...login, checkedAt, problem };
141
+ }
142
+
143
+ /**
144
+ * Codex's bubblewrap helper must give each synthetic missing-path mount its own descriptor.
145
+ * 0.166.0 is the verified baseline; older helpers can close a descriptor another mount still needs.
146
+ */
147
+ export async function checkCodexCli(bin: string, timeoutMs = CHECK_TIMEOUT_MS): Promise<CliCheck> {
148
+ const result = await checkCli(bin, ['login', 'status'], parseCodexLogin, timeoutMs, (version) => {
149
+ const match = /^(?:codex-cli )?(\d+)\.(\d+)\.(\d+)$/.exec(version ?? '');
150
+ if (match) {
151
+ const [, major, minor] = match.map(Number);
152
+ if (major > 0 || minor >= 166) return null;
153
+ }
154
+ return 'Botons requires Codex 0.166.0 or newer (a stable version): older bubblewrap helpers can fail on protected missing paths with Bad file descriptor. For npm installs run `npm install -g @openai/codex@latest` with the permissions used to install it. For Docker set BOTONS_CODEX_VERSION to a compatible stable version and run `docker compose up -d --build`. Upgrade Codex for the user Botons runs as, verify `codex --version` (or run `"$BOTONS_CODEX_BIN" --version` when configured), then open Settings → Brains for the reviewed upgrade action and Check again. Keep Workspace sandboxing enabled.';
155
+ });
156
+ return result.installed && result.problem !== 'incompatible-version'
157
+ ? { ...result, workspaceProblem: await probeCodexWorkspace(bin, timeoutMs) } : result;
158
+ }
159
+
160
+ /** What a CLI's status is before any check has finished: not known, so never ready. */
161
+ export const NOT_CHECKED: CliCheck = { installed: false, version: null, loggedIn: null, reason: 'Not checked yet since the server started.', checkedAt: '', problem: null, generation: 0 };
162
+
163
+ export function cliStatus(c: CliCheck): Pick<ProviderStatus, 'available' | 'reason' | 'installed' | 'version' | 'loggedIn' | 'checkedAt' | 'check' | 'workspaceProblem'> {
164
+ return {
165
+ available: c.installed && c.loggedIn !== false && c.problem !== 'incompatible-version',
166
+ reason: c.reason,
167
+ ...(c.workspaceProblem !== undefined ? { workspaceProblem: c.workspaceProblem } : {}),
168
+ installed: c.installed,
169
+ version: c.version,
170
+ loggedIn: c.loggedIn,
171
+ checkedAt: c.checkedAt,
172
+ check: { problem: c.problem ?? null, generation: c.generation ?? 0 },
173
+ };
174
+ }
175
+
176
+ type QueuedCheck = { at: number; started: boolean; check: Promise<CliCheck>; done?: CliCheck };
177
+
178
+ /**
179
+ * The checker's clocks: `elapsed` only ever goes forward (how old a cached check is), `now` is the wall clock checks
180
+ * are stamped with, which can be set back.
181
+ */
182
+ export interface CheckClock {
183
+ elapsed(): number;
184
+ now(): number;
185
+ }
186
+ const SYSTEM_CLOCK: CheckClock = { elapsed: () => performance.now(), now: () => Date.now() };
187
+
188
+ /**
189
+ * Runs a CLI's checks at most every CHECK_TTL_MS, sharing the result. Checks never overlap: one asked for while
190
+ * another runs starts when it ends, and is shared by everyone who asks before it starts.
191
+ */
192
+ export class CliChecker {
193
+ #provider: string;
194
+ #run: () => Promise<CliCheck>;
195
+ #cached: QueuedCheck | null = null;
196
+ /** Settles when the latest check has finished. */
197
+ #idle: Promise<unknown> = Promise.resolve();
198
+ #last: CliCheck | null = null;
199
+ /** Counts this checker's checks; it starts again with the server, whose boot (readiness) tells the two runs apart. */
200
+ #generation = 0;
201
+ #clock: CheckClock;
202
+
203
+ constructor(provider: string, run: () => Promise<CliCheck>, clock: CheckClock = SYSTEM_CLOCK) {
204
+ this.#provider = provider;
205
+ this.#run = run;
206
+ this.#clock = clock;
207
+ }
208
+
209
+ get(): Promise<CliCheck> {
210
+ const cached = this.#cached;
211
+ if (cached && (!cached.started || (this.#clock.elapsed() - cached.at < CHECK_TTL_MS && !this.#fromTheFuture(cached)))) return cached.check;
212
+ const entry: QueuedCheck = {
213
+ at: this.#clock.elapsed(),
214
+ started: false,
215
+ check: this.#idle.then(async () => {
216
+ entry.started = true;
217
+ entry.at = this.#clock.elapsed();
218
+ const c = { ...(await this.#run()), generation: ++this.#generation };
219
+ entry.done = c;
220
+ this.#noteChange(c);
221
+ return c;
222
+ }),
223
+ };
224
+ this.#cached = entry;
225
+ this.#idle = entry.check.catch(() => {});
226
+ return entry.check;
227
+ }
228
+
229
+ /** A finished check stamped later than the wall clock now: the clock was set back, so its age can't be told. */
230
+ #fromTheFuture(entry: QueuedCheck): boolean {
231
+ const at = entry.done ? Date.parse(entry.done.checkedAt) : Number.NaN;
232
+ return at > this.#clock.now();
233
+ }
234
+
235
+ /**
236
+ * The last finished check, however old, or null before the first: never runs one. For views that must report only
237
+ * what is known (a starter's review and setup check); readiness (brainReadiness) says when it is too old to count.
238
+ */
239
+ peek(): CliCheck | null {
240
+ return this.#last;
241
+ }
242
+
243
+ /** A check that has not started yet already counts as "next time". */
244
+ invalidate(): void {
245
+ if (this.#cached?.started) this.#cached = null;
246
+ }
247
+
248
+ #noteChange(c: CliCheck) {
249
+ const prev = this.#last;
250
+ this.#last = c;
251
+ if (!prev || (prev.installed === c.installed && prev.loggedIn === c.loggedIn)) return;
252
+ const fields = { provider: this.#provider, installed: c.installed, loggedIn: c.loggedIn, version: c.version };
253
+ if (c.installed && c.loggedIn !== false) log.info(`${this.#provider} can run turns${c.reason ? ` (${c.reason})` : ''}`, fields);
254
+ else log.warn(`${this.#provider} can't run turns: ${c.reason}`, fields);
255
+ }
256
+ }
257
+
258
+ /** Claude needs socat and private network/process namespaces. No model or login is used. */
259
+ export function claudeWorkspaceProblem({ bwrap = 'bwrap', socat = 'socat' }: { bwrap?: string; socat?: string } = {}): string | null {
260
+ if (process.platform !== 'linux') return null;
261
+ const relay = spawnSync(socat, ['-V'], { timeout: CHECK_TIMEOUT_MS, encoding: 'utf8' });
262
+ if (relay.status !== 0) return 'Not available on this server: the Workspace sandbox needs socat installed and runnable.';
263
+ const probe = spawnSync(bwrap, ['--ro-bind', '/', '/', '--dev', '/dev', '--unshare-net', '--unshare-pid', '--proc', '/proc', '--', '/bin/true'], { timeout: CHECK_TIMEOUT_MS, encoding: 'utf8' });
264
+ return probe.status === 0 ? null : "Not available on this server: the Workspace sandbox cannot create its private network or process filesystem.";
265
+ }
@@ -0,0 +1,20 @@
1
+ import { join } from 'node:path';
2
+ import { cliHome, prepareCliHomes, type CliKind } from '../cli-homes.ts';
3
+ import { botonsHome } from '../config.ts';
4
+ import { privatePaths } from '../isolation.ts';
5
+ import { agentEnv } from './process.ts';
6
+ import type { TurnRequest } from './types.ts';
7
+
8
+ /** One synchronous snapshot after startup checks: sandbox paths and child environment must agree. */
9
+ export function cliTurn(req: TurnRequest, kind: CliKind, extra: Record<string, string> = {}) {
10
+ prepareCliHomes();
11
+ const env = agentEnv({ ...extra, ...req.env });
12
+ const personal = (['codex', 'claude'] as const).map(k => cliHome(k).personal);
13
+ const hidden = req.access === 'workspace' ? privatePaths({
14
+ workspace: req.cwd, cli: kind, botonsHome: botonsHome(), workspacesDir: join(botonsHome(), 'workspaces'),
15
+ userHome: process.env.HOME, env,
16
+ extra: [...req.privatePaths, ...personal.filter(p => p !== env.CODEX_HOME && p !== env.CLAUDE_CONFIG_DIR)],
17
+ shared: req.sharedDirs,
18
+ }) : req.privatePaths;
19
+ return { env, req: { ...req, privatePaths: hidden } };
20
+ }