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,131 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { log } from '../log.ts';
5
+
6
+ /*
7
+ * An API brain's sessions: each one's conversation as the provider's own items (so reasoning is passed back unchanged),
8
+ * kept by the server in BOTONS_HOME/api-sessions, a 0700 folder Workspace Botons can't read (isolation.ts hides
9
+ * everything in BOTONS_HOME). One JSON file per session, written whole and renamed into place. A session whose file is
10
+ * gone is lost, and the engine starts a new one with the chat replayed.
11
+ */
12
+
13
+ /** user: the owner's (or a run's) input; assistant: one answer of the model; tools: the results of its tool calls. */
14
+ export interface HistoryItem {
15
+ kind: 'user' | 'assistant' | 'tools';
16
+ data: unknown;
17
+ }
18
+
19
+ export interface History {
20
+ v: 1;
21
+ provider: string;
22
+ /** The tools the last request was made with (see toolsKey). */
23
+ toolsKey: string | null;
24
+ /** The model of the last answer. */
25
+ model: string | null;
26
+ items: HistoryItem[];
27
+ }
28
+
29
+ const ID = /^[a-z]+-[0-9a-f-]{36}$/;
30
+ /** Files not written for this long belong to sessions no Boton keeps. */
31
+ const STALE_MS = 120 * 86_400_000;
32
+
33
+ export class HistoryStore {
34
+ readonly dir: string;
35
+ constructor(dir: string) {
36
+ this.dir = dir;
37
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
38
+ chmodSync(dir, 0o700);
39
+ this.#prune();
40
+ }
41
+
42
+ newId(prefix: string): string {
43
+ return `${prefix}-${randomUUID()}`;
44
+ }
45
+
46
+ #file(id: string): string | null {
47
+ return ID.test(id) ? join(this.dir, `${id}.json`) : null;
48
+ }
49
+
50
+ /** The session's history; null when it is gone (or never was). */
51
+ load(id: string, provider: string): History | null {
52
+ const file = this.#file(id);
53
+ if (!file) return null;
54
+ try {
55
+ const h = JSON.parse(readFileSync(file, 'utf8')) as History;
56
+ if (h?.v !== 1 || h.provider !== provider || !Array.isArray(h.items)) return null;
57
+ return h;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ save(id: string, h: History): void {
64
+ const file = this.#file(id);
65
+ if (!file) throw new Error('not a session id');
66
+ const tmp = `${file}.${process.pid}.tmp`;
67
+ writeFileSync(tmp, JSON.stringify(h), { mode: 0o600 });
68
+ renameSync(tmp, file);
69
+ }
70
+
71
+ #prune() {
72
+ try {
73
+ const now = Date.now();
74
+ for (const name of readdirSync(this.dir)) {
75
+ const path = join(this.dir, name);
76
+ if (now - statSync(path).mtimeMs > STALE_MS) rmSync(path, { force: true });
77
+ }
78
+ } catch (err) {
79
+ log.debug('could not prune old API sessions', { err });
80
+ }
81
+ }
82
+ }
83
+
84
+ /** How big an item is, in bytes of its JSON (what the request carries of it, give or take its framing). */
85
+ export const sizeOf = (v: unknown) => Buffer.byteLength(JSON.stringify(v));
86
+
87
+ /** What a tool result becomes when the request can't carry it: the call and its result stay a pair. */
88
+ export const leftOut = (bytes: number) => `[This output (${bytes} bytes) was left out so the request stays within what Botons sends at once. Run the tool again for what you still need of it.]`;
89
+
90
+ export type Fit =
91
+ | { ok: true; items: HistoryItem[]; trimmed: boolean; compacted: number }
92
+ | { ok: false; over: number; why: 'input' | 'turn' };
93
+
94
+ /**
95
+ * The items a request carries, within `budget` bytes with the request's `fixed` part (instructions, tools, framing):
96
+ * whole older exchanges (each from a user item) go first, oldest first; then, in the newest exchange, the outputs of
97
+ * tool calls, oldest first, each replaced by a note (its call and result stay a valid pair). The newest user item and
98
+ * the model's own answers are never cut. When even that doesn't fit, nothing is sent: `why` says whether the new input
99
+ * alone is too big, or the turn has grown past the bound. `trimmed`: older exchanges went, so reasoning bound to the
100
+ * full history must go too.
101
+ */
102
+ export function fitRequest(items: HistoryItem[], budget: number, fixed: number): Fit {
103
+ const starts = items.flatMap((it, i) => (it.kind === 'user' ? [i] : []));
104
+ const sizes = items.map((it) => sizeOf(it.data));
105
+ let total = fixed + sizes.reduce((a, b) => a + b, 0);
106
+ // Before the first exchange: nothing a request can start with.
107
+ let from = starts.length ? starts[0] : items.length;
108
+ for (let i = 0; i < from; i++) total -= sizes[i];
109
+ for (let k = 0; total > budget && k < starts.length - 1; k++) {
110
+ for (let i = starts[k]; i < starts[k + 1]; i++) total -= sizes[i];
111
+ from = starts[k + 1];
112
+ }
113
+ const out = items.slice(from);
114
+ let compacted = 0;
115
+ if (total > budget) {
116
+ const first = out.findIndex((it) => it.kind === 'user');
117
+ if (first >= 0 && fixed + sizeOf(out[first].data) > budget) return { ok: false, over: fixed + sizeOf(out[first].data) - budget, why: 'input' };
118
+ for (let i = 0; i < out.length && total > budget; i++) {
119
+ const it = out[i];
120
+ if (it.kind !== 'tools') continue;
121
+ const data = it.data as { results?: { id: string; name: string; text: string; isError: boolean; leftOut?: boolean }[] };
122
+ if (!Array.isArray(data?.results)) continue;
123
+ const before = sizeOf(data);
124
+ const results = data.results.map((r) => (r.leftOut || Buffer.byteLength(r.text) <= 512 ? r : (compacted++, { ...r, text: leftOut(Buffer.byteLength(r.text)), leftOut: true })));
125
+ out[i] = { ...it, data: { ...data, results } };
126
+ total += sizeOf(out[i].data) - before;
127
+ }
128
+ if (total > budget) return { ok: false, over: total - budget, why: 'turn' };
129
+ }
130
+ return { ok: true, items: out, trimmed: from > 0, compacted };
131
+ }
@@ -0,0 +1,481 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { CLI_NAMES, withLegacyTurnNames } from '../../../shared/legacy-names.mjs';
3
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import type { TurnStep } from '../../../shared/types.ts';
5
+ import { instructionPaths } from '../instructions.ts';
6
+ import { holdsSecret, McpError, McpSession } from '../mcp-client.ts';
7
+ import { type RunOptions, runProcess, type SandboxPlan, sandboxProblem } from '../sandbox.ts';
8
+ import { redactJson } from '../secrets.ts';
9
+ import { shorten } from '../util.ts';
10
+ import { StdioMcp } from './mcp-stdio.ts';
11
+ import { agentEnv } from './process.ts';
12
+ import { type McpStartup, step, type ToolObservation, type TurnMcpServer, type TurnRequest } from './types.ts';
13
+
14
+ /*
15
+ * The tools an API brain's model can call, all run by the server: a shell, reading, writing and editing files (each one
16
+ * a process, in the OS sandbox for Workspace access: see sandbox.ts), `botons` commands (the Boton's own API, run as
17
+ * the CLIs run them: outside the sandbox, as this turn's token), and the tools of the Botons connectors the turn was
18
+ * given (OAuth and library ones only: connectors.resolve never hands over anything else). What a tool returns to the
19
+ * model is bounded, and never holds the API key (the caller redacts it from every result).
20
+ */
21
+
22
+ export interface ToolSpec {
23
+ name: string;
24
+ description: string;
25
+ /** JSON Schema of an object. */
26
+ schema: Record<string, unknown>;
27
+ }
28
+
29
+ export interface ToolCall {
30
+ id: string;
31
+ name: string;
32
+ input: unknown;
33
+ /** Its input held a known secret (taken out of it): it is never run. */
34
+ secret?: boolean;
35
+ }
36
+
37
+ export interface ToolOutcome {
38
+ text: string;
39
+ isError: boolean;
40
+ /** It may have changed something: a provider error after it can't safely run the turn again. */
41
+ acted: boolean;
42
+ }
43
+
44
+ export const SHELL_TIMEOUT_MS = 120_000;
45
+ export const SHELL_MAX_TIMEOUT_MS = 600_000;
46
+ const OUTPUT_MAX = 32 * 1024;
47
+ const READ_MAX = 256 * 1024;
48
+ const WRITE_MAX = 1024 * 1024;
49
+ const MCP_START_MS = 20_000;
50
+ const MCP_CALL_MS = 120_000;
51
+ /** Tool names: what both APIs take. */
52
+ const NAME = /^[a-zA-Z0-9_-]{1,64}$/;
53
+ const MAX_CONNECTOR_TOOLS = 128;
54
+ const MAX_DESCRIPTION = 1024;
55
+ /** A connector tool's input schema, as JSON; a bigger one leaves the tool out. */
56
+ const MAX_SCHEMA = 16 * 1024;
57
+ /** Every connector tool's definition together, as JSON: the rest are left out (a request carries them all). */
58
+ const MAX_CONNECTOR_SPECS = 200 * 1024;
59
+
60
+ // The models' tool descriptions are part of toolsKey: changing them drops the reasoning each session kept.
61
+ const NATIVE: ToolSpec[] = [
62
+ {
63
+ name: 'shell',
64
+ description:
65
+ 'Run a bash command in your workspace (the current folder). Output is cut to its start and end past 32 KB. A command that is only a `botons …` command (no pipes, redirects, ;, &&, $ or backticks) runs as your Botons CLI, which reaches the Botons server; with Workspace access everything else runs in a sandbox with no network, where only your workspace (and the shared folder) can be written.',
66
+ schema: {
67
+ type: 'object',
68
+ properties: {
69
+ command: { type: 'string', description: 'The bash command.' },
70
+ timeout_seconds: { type: 'integer', description: `At most ${SHELL_MAX_TIMEOUT_MS / 1000}; ${SHELL_TIMEOUT_MS / 1000} when left out.` },
71
+ },
72
+ required: ['command'],
73
+ additionalProperties: false,
74
+ },
75
+ },
76
+ {
77
+ name: 'read_file',
78
+ description: `Read a text file (a path relative to your workspace, or absolute). At most ${READ_MAX / 1024} KB is returned.`,
79
+ schema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false },
80
+ },
81
+ {
82
+ name: 'write_file',
83
+ description: `Write a text file (created with its folders, or replaced), at most ${WRITE_MAX / 1024} KB.`,
84
+ schema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'], additionalProperties: false },
85
+ },
86
+ {
87
+ name: 'edit_file',
88
+ description: 'Replace text in a file: old_text must appear exactly once, unless replace_all is true.',
89
+ schema: {
90
+ type: 'object',
91
+ properties: { path: { type: 'string' }, old_text: { type: 'string' }, new_text: { type: 'string' }, replace_all: { type: 'boolean' } },
92
+ required: ['path', 'old_text', 'new_text'],
93
+ additionalProperties: false,
94
+ },
95
+ },
96
+ ];
97
+
98
+ /** A tool-free turn's tools: described only (calls to them may be in the history), never called. */
99
+ export const DESCRIBED_ONLY = NATIVE;
100
+
101
+ /** `botons` (or a deprecated name of it) and its arguments, when `command` is that one simple command and nothing else; else null. */
102
+ export function botonArgv(command: string): string[] | null {
103
+ const words: string[] = [];
104
+ let cur: string | null = null;
105
+ let i = 0;
106
+ const s = command.trim();
107
+ while (i < s.length) {
108
+ const c = s[i];
109
+ if (c === ' ' || c === '\t') {
110
+ if (cur !== null) words.push(cur);
111
+ cur = null;
112
+ i++;
113
+ } else if (c === "'") {
114
+ const end = s.indexOf("'", i + 1);
115
+ if (end < 0) return null;
116
+ cur = (cur ?? '') + s.slice(i + 1, end);
117
+ i = end + 1;
118
+ } else if (c === '"') {
119
+ let j = i + 1;
120
+ let out = '';
121
+ for (; j < s.length && s[j] !== '"'; j++) {
122
+ if (s[j] === '$' || s[j] === '`') return null;
123
+ if (s[j] === '\\' && j + 1 < s.length && '"\\$`'.includes(s[j + 1])) out += s[++j];
124
+ else out += s[j];
125
+ }
126
+ if (j >= s.length) return null;
127
+ cur = (cur ?? '') + out;
128
+ i = j + 1;
129
+ } else if (c === '\\') {
130
+ if (i + 1 >= s.length || s[i + 1] === '\n') return null;
131
+ cur = (cur ?? '') + s[i + 1];
132
+ i += 2;
133
+ } else if (';&|<>()$`\n\r*?[]{}~#!'.includes(c)) return null;
134
+ else {
135
+ cur = (cur ?? '') + c;
136
+ i++;
137
+ }
138
+ }
139
+ if (cur !== null) words.push(cur);
140
+ // The command or one of its deprecated names (shared/legacy-names.mjs).
141
+ return words[0] !== undefined && (words[0] === 'botons' || CLI_NAMES.includes(words[0])) ? words.slice(1) : null;
142
+ }
143
+
144
+ interface Connector {
145
+ name: string;
146
+ /** What it sends that must not come back out (its headers' values, its environment's). */
147
+ secrets: string[];
148
+ tools: Map<string, { tool: string; spec: ToolSpec }>;
149
+ call(tool: string, args: Record<string, unknown>, signal: AbortSignal, timeoutMs: number): Promise<{ isError: boolean; text: string; structured: Record<string, unknown> | null }>;
150
+ close(): void;
151
+ }
152
+
153
+ export interface ToolsetOptions {
154
+ req: TurnRequest;
155
+ /** Where `botons` is (the server's own CLI). */
156
+ botonCli: string | null;
157
+ /** BOTONS_OAUTH_ALLOW: what connector requests may reach. */
158
+ allow: string[];
159
+ /** Takes the API key (and any other secret) out of text. */
160
+ redact: (text: string) => string;
161
+ }
162
+
163
+ /** A connector tool's name as the model sees it: mcp__<server>__<tool>, cut and hashed to fit when long. */
164
+ function connectorToolName(server: string, tool: string): string {
165
+ const raw = `mcp__${server}__${tool}`.replace(/[^a-zA-Z0-9_-]/g, '_');
166
+ if (raw.length <= 64) return raw;
167
+ const hash = createHash('sha256').update(`${server}\0${tool}`).digest('hex').slice(0, 8);
168
+ return `${raw.slice(0, 55)}_${hash}`;
169
+ }
170
+
171
+ function objectSchema(s: Record<string, unknown> | null): Record<string, unknown> {
172
+ if (s && s.type === 'object') return s;
173
+ return { type: 'object', properties: {} };
174
+ }
175
+
176
+ /** The turn's tools: what the model is told about them, and how each call runs. */
177
+ export class Toolset {
178
+ readonly specs: ToolSpec[];
179
+ readonly startup: McpStartup[] = [];
180
+ #opts: ToolsetOptions;
181
+ #plan: SandboxPlan | null;
182
+ #connectors: Connector[] = [];
183
+ #byName = new Map<string, { connector: Connector; tool: string }>();
184
+
185
+ private constructor(opts: ToolsetOptions, plan: SandboxPlan | null, specs: ToolSpec[]) {
186
+ this.#opts = opts;
187
+ this.#plan = plan;
188
+ this.specs = specs;
189
+ }
190
+
191
+ /**
192
+ * The tools for `req`; for Workspace access only when the sandbox is there (else throws with why). Connectors that
193
+ * don't start are left out, and said so in `startup`.
194
+ */
195
+ static async open(opts: ToolsetOptions): Promise<Toolset> {
196
+ const { req } = opts;
197
+ if (req.toolFree) return new Toolset(opts, null, [...DESCRIBED_ONLY]);
198
+ let plan: SandboxPlan | null = null;
199
+ if (req.access === 'workspace') {
200
+ const problem = sandboxProblem();
201
+ if (problem) throw new Error(problem);
202
+ const shared = req.sharedDirs ?? [];
203
+ plan = {
204
+ cwd: req.cwd,
205
+ writable: [req.cwd, ...shared],
206
+ hidden: req.privatePaths,
207
+ readOnly: [
208
+ ...[req.cwd, ...shared].map((d) => ({ path: join(d, '.claude'), dir: true })),
209
+ ...(req.protectInstructions ? instructionPaths(req.cwd).map((p) => ({ path: p, dir: false })) : []),
210
+ ],
211
+ env: sandboxEnv(req),
212
+ };
213
+ }
214
+ const set = new Toolset(opts, plan, [...NATIVE]);
215
+ await set.#openConnectors(req.mcpServers ?? []);
216
+ return set;
217
+ }
218
+
219
+ async #openConnectors(servers: TurnMcpServer[]) {
220
+ const { req } = this.#opts;
221
+ const opened = await Promise.all(
222
+ servers.map(async (s): Promise<{ s: TurnMcpServer; c?: Connector; tools?: { name: string; description?: string | null; inputSchema: Record<string, unknown> | null }[]; err?: string }> => {
223
+ try {
224
+ if (s.kind === 'http') {
225
+ const session = new McpSession(s.url, s.headers, this.#opts.allow, req.signal);
226
+ session.timeoutMs = MCP_START_MS;
227
+ await session.initialize();
228
+ const tools = await session.listTools();
229
+ const c: Connector = {
230
+ name: s.name,
231
+ secrets: session.secrets,
232
+ tools: new Map(),
233
+ call: async (tool, args, _signal, timeoutMs) => {
234
+ session.timeoutMs = timeoutMs;
235
+ const r = await session.callTool(tool, args, this.#opts.redact);
236
+ return { isError: r.isError, text: r.text ?? '', structured: r.structured };
237
+ },
238
+ close: () => {},
239
+ };
240
+ return { s, c, tools };
241
+ }
242
+ const mcp = new StdioMcp({ command: s.command, args: s.args, env: s.env, cwd: s.cwd ?? req.cwd, secrets: s.secrets }, agentEnv({}));
243
+ try {
244
+ await mcp.initialize(MCP_START_MS);
245
+ const tools = await mcp.listTools(MCP_START_MS);
246
+ const c: Connector = { name: s.name, secrets: mcp.secrets, tools: new Map(), call: async (tool, args, _signal, timeoutMs) => {
247
+ const r = await mcp.callTool(tool, args, timeoutMs, this.#opts.redact);
248
+ return { isError: r.isError, text: r.text ?? '', structured: r.structured };
249
+ },
250
+ close: () => mcp.close() };
251
+ return { s, c, tools };
252
+ } catch (err) {
253
+ mcp.close();
254
+ throw err;
255
+ }
256
+ } catch (err) {
257
+ const why = err instanceof McpError && err.kind === 'auth' ? 'the server refused its sign-in' : shorten(this.#opts.redact((err as Error).message), 160);
258
+ return { s, err: `Botons could not start it (${why})` };
259
+ }
260
+ }),
261
+ );
262
+ let count = 0;
263
+ let bytes = 0;
264
+ const redact = this.#opts.redact;
265
+ for (const o of opened) {
266
+ if (!o.c || !o.tools) {
267
+ this.startup.push({ name: o.s.name, started: false, tools: [], reason: o.err });
268
+ continue;
269
+ }
270
+ const names: string[] = [];
271
+ let tooBig = 0;
272
+ for (const t of o.tools) {
273
+ if (count >= MAX_CONNECTOR_TOOLS) break;
274
+ // The connector's own secrets went at its client (cleanTools); the API keys go here. A name holding one can't be called.
275
+ if (redact(t.name) !== t.name || holdsSecret(t.name, o.c.secrets)) continue;
276
+ const name = connectorToolName(o.s.name, t.name);
277
+ if (!NAME.test(name) || this.#byName.has(name)) continue;
278
+ const spec: ToolSpec = {
279
+ name,
280
+ description: `${o.s.name} connector: ${shorten(redact(t.description ?? t.name).replace(/\s+/g, ' '), MAX_DESCRIPTION)}`,
281
+ schema: objectSchema(redactJson(t.inputSchema, redact) as Record<string, unknown> | null),
282
+ };
283
+ const size = Buffer.byteLength(JSON.stringify(spec));
284
+ if (size - Buffer.byteLength(JSON.stringify(spec.description)) > MAX_SCHEMA || bytes + size > MAX_CONNECTOR_SPECS) {
285
+ tooBig++;
286
+ continue;
287
+ }
288
+ bytes += size;
289
+ o.c.tools.set(name, { tool: t.name, spec });
290
+ this.#byName.set(name, { connector: o.c, tool: t.name });
291
+ this.specs.push(spec);
292
+ names.push(t.name);
293
+ count++;
294
+ }
295
+ this.#connectors.push(o.c);
296
+ const reason = tooBig ? `${tooBig} of its tools were left out: their definitions are bigger than a request to the model carries` : undefined;
297
+ this.startup.push(
298
+ names.length
299
+ ? { name: o.s.name, started: true, tools: names, ...(reason ? { reason } : {}) }
300
+ : { name: o.s.name, started: false, tools: [], reason: reason ?? 'Botons connected to it, but it listed no tools' },
301
+ );
302
+ }
303
+ }
304
+
305
+ close(): void {
306
+ for (const c of this.#connectors) c.close();
307
+ }
308
+
309
+ /** What a call is, for the turn's steps and status. */
310
+ describe(call: ToolCall): { activity: string; step: TurnStep } {
311
+ const input = (call.input && typeof call.input === 'object' ? call.input : {}) as Record<string, unknown>;
312
+ const str = (v: unknown) => (typeof v === 'string' ? v : '');
313
+ switch (call.name) {
314
+ case 'shell': {
315
+ const label = `Running \`${shorten(str(input.command), 70)}\``;
316
+ return { activity: label, step: step('command', label, str(input.command)) };
317
+ }
318
+ case 'read_file':
319
+ return { activity: `Reading ${basename(str(input.path) || 'a file')}`, step: step('read', `Reading ${basename(str(input.path) || 'a file')}`, str(input.path)) };
320
+ case 'write_file':
321
+ case 'edit_file':
322
+ return { activity: `Editing ${basename(str(input.path) || 'a file')}`, step: step('edit', `Editing ${basename(str(input.path) || 'a file')}`, str(input.path)) };
323
+ default: {
324
+ const c = this.#byName.get(call.name);
325
+ const label = c ? `Using ${c.connector.name} ${c.tool}` : `Using ${call.name}`;
326
+ return { activity: label, step: step('tool', label, shorten(JSON.stringify(input), 2000)) };
327
+ }
328
+ }
329
+ }
330
+
331
+ /** Runs one call; never throws (a failure is the result the model gets). `observe` gets a connector call's result. */
332
+ async run(call: ToolCall, signal: AbortSignal, deadline: number, observe?: (obs: ToolObservation) => void): Promise<ToolOutcome> {
333
+ const input = (call.input && typeof call.input === 'object' && !Array.isArray(call.input) ? call.input : null) as Record<string, unknown> | null;
334
+ if (!input) return { text: 'The tool input must be a JSON object.', isError: true, acted: false };
335
+ const left = deadline - Date.now();
336
+ if (left <= 1000) return { text: 'The turn has no time left to run this.', isError: true, acted: false };
337
+ try {
338
+ switch (call.name) {
339
+ case 'shell':
340
+ return await this.#shell(input, signal, left);
341
+ case 'read_file':
342
+ return await this.#read(input, signal, left);
343
+ case 'write_file':
344
+ return await this.#write(input, signal, left);
345
+ case 'edit_file':
346
+ return await this.#edit(input, signal, left);
347
+ default:
348
+ return await this.#connector(call, input, signal, left, observe);
349
+ }
350
+ } catch (err) {
351
+ return { text: `The tool failed: ${shorten(this.#opts.redact((err as Error).message), 300)}`, isError: true, acted: true };
352
+ }
353
+ }
354
+
355
+ #process(argv: string[], o: { signal: AbortSignal; timeoutMs: number; stdin?: string | Buffer; headOnly?: boolean; maxBytes?: number; outside?: boolean }) {
356
+ const { req } = this.#opts;
357
+ const run: RunOptions = {
358
+ argv,
359
+ sandbox: o.outside ? null : this.#plan,
360
+ cwd: req.cwd,
361
+ env: agentEnv(req.env) as Record<string, string>,
362
+ stdin: o.stdin,
363
+ timeoutMs: o.timeoutMs,
364
+ signal: o.signal,
365
+ maxBytes: o.maxBytes ?? OUTPUT_MAX,
366
+ headOnly: o.headOnly,
367
+ };
368
+ return runProcess(run);
369
+ }
370
+
371
+ async #shell(input: Record<string, unknown>, signal: AbortSignal, left: number): Promise<ToolOutcome> {
372
+ const command = input.command;
373
+ if (typeof command !== 'string' || !command.trim()) return { text: 'command must be a non-empty string.', isError: true, acted: false };
374
+ const asked = typeof input.timeout_seconds === 'number' && input.timeout_seconds > 0 ? input.timeout_seconds * 1000 : SHELL_TIMEOUT_MS;
375
+ const timeoutMs = Math.min(asked, SHELL_MAX_TIMEOUT_MS, left - 500);
376
+ const boton = botonArgv(command);
377
+ let r;
378
+ if (boton && this.#opts.botonCli) r = await this.#process([this.#opts.botonCli, ...boton], { signal, timeoutMs, outside: true });
379
+ else r = await this.#process(['/bin/bash', '-c', command], { signal, timeoutMs });
380
+ const out = r.stdout.toString('utf8');
381
+ const lines = [
382
+ r.timedOut ? `Stopped after ${Math.round(timeoutMs / 1000)} s (its time ran out): what it did until then stands, and may be partial.` : r.aborted ? 'Stopped: the turn was stopped.' : `Exit code ${r.code ?? `none (killed by ${r.signal})`}.`,
383
+ out ? `stdout:\n${out}` : '',
384
+ r.stderr ? `stderr:\n${r.stderr}` : '',
385
+ ].filter(Boolean);
386
+ return { text: this.#opts.redact(lines.join('\n')), isError: r.code !== 0, acted: true };
387
+ }
388
+
389
+ #path(input: Record<string, unknown>): string | null {
390
+ const p = input.path;
391
+ if (typeof p !== 'string' || !p.trim() || p.includes('\0')) return null;
392
+ return isAbsolute(p) ? resolve(p) : resolve(this.#opts.req.cwd, p);
393
+ }
394
+
395
+ async #readBytes(path: string, max: number, signal: AbortSignal, timeoutMs: number): Promise<{ ok: true; data: Buffer; more: boolean } | { ok: false; why: string }> {
396
+ const r = await this.#process(['/bin/sh', '-c', 'exec head -c "$1" -- "$2"', 'sh', String(max + 1), path], { signal, timeoutMs, headOnly: true, maxBytes: max + 1 });
397
+ if (r.code !== 0) return { ok: false, why: r.stderr.trim().split('\n').pop()?.replace(/^head: /, '') || `could not read ${path}` };
398
+ return { ok: true, data: r.stdout.subarray(0, max), more: r.stdout.length > max };
399
+ }
400
+
401
+ async #read(input: Record<string, unknown>, signal: AbortSignal, left: number): Promise<ToolOutcome> {
402
+ const path = this.#path(input);
403
+ if (!path) return { text: 'path must be a file path.', isError: true, acted: false };
404
+ const r = await this.#readBytes(path, READ_MAX, signal, Math.min(30_000, left));
405
+ if (!r.ok) return { text: this.#opts.redact(r.why), isError: true, acted: false };
406
+ if (r.data.includes(0)) return { text: `${path} is not a text file.`, isError: true, acted: false };
407
+ const text = r.data.toString('utf8');
408
+ return { text: this.#opts.redact(r.more ? `${text}\n[… the file goes on past ${READ_MAX / 1024} KB]` : text), isError: false, acted: false };
409
+ }
410
+
411
+ async #writeBytes(path: string, content: string, signal: AbortSignal, left: number): Promise<string | null> {
412
+ const r = await this.#process(['/bin/sh', '-c', 'mkdir -p -- "$(dirname -- "$1")" && cat > "$1"', 'sh', path], { signal, timeoutMs: Math.min(30_000, left), stdin: content });
413
+ return r.code === 0 ? null : r.stderr.trim().split('\n').pop()?.replace(/^sh: \d+: /, '') || `could not write ${path}`;
414
+ }
415
+
416
+ async #write(input: Record<string, unknown>, signal: AbortSignal, left: number): Promise<ToolOutcome> {
417
+ const path = this.#path(input);
418
+ if (!path) return { text: 'path must be a file path.', isError: true, acted: false };
419
+ if (typeof input.content !== 'string') return { text: 'content must be text.', isError: true, acted: false };
420
+ if (Buffer.byteLength(input.content) > WRITE_MAX) return { text: `content is over ${WRITE_MAX / 1024} KB: write it in parts with the shell.`, isError: true, acted: false };
421
+ const why = await this.#writeBytes(path, input.content, signal, left);
422
+ return why ? { text: this.#opts.redact(why), isError: true, acted: true } : { text: `Wrote ${path} (${Buffer.byteLength(input.content)} bytes).`, isError: false, acted: true };
423
+ }
424
+
425
+ async #edit(input: Record<string, unknown>, signal: AbortSignal, left: number): Promise<ToolOutcome> {
426
+ const path = this.#path(input);
427
+ if (!path) return { text: 'path must be a file path.', isError: true, acted: false };
428
+ const { old_text: from, new_text: to } = input;
429
+ if (typeof from !== 'string' || !from || typeof to !== 'string') return { text: 'old_text (not empty) and new_text must be text.', isError: true, acted: false };
430
+ const r = await this.#readBytes(path, WRITE_MAX, signal, Math.min(30_000, left));
431
+ if (!r.ok) return { text: this.#opts.redact(r.why), isError: true, acted: false };
432
+ if (r.more) return { text: `${path} is over ${WRITE_MAX / 1024} KB: edit it with the shell.`, isError: true, acted: false };
433
+ const text = r.data.toString('utf8');
434
+ const count = text.split(from).length - 1;
435
+ if (count === 0) return { text: 'old_text is not in the file.', isError: true, acted: false };
436
+ if (count > 1 && input.replace_all !== true) return { text: `old_text is in the file ${count} times: give more of it, or set replace_all.`, isError: true, acted: false };
437
+ const next = input.replace_all === true ? text.split(from).join(to) : text.replace(from, () => to);
438
+ const why = await this.#writeBytes(path, next, signal, left);
439
+ return why ? { text: this.#opts.redact(why), isError: true, acted: true } : { text: `Edited ${path} (${count} replacement${count > 1 ? 's' : ''}).`, isError: false, acted: true };
440
+ }
441
+
442
+ async #connector(call: ToolCall, input: Record<string, unknown>, signal: AbortSignal, left: number, observe?: (obs: ToolObservation) => void): Promise<ToolOutcome> {
443
+ const found = this.#byName.get(call.name);
444
+ if (!found) return { text: `There is no tool named ${call.name}.`, isError: true, acted: false };
445
+ const timeoutMs = Math.min(MCP_CALL_MS, left - 500);
446
+ let r;
447
+ try {
448
+ r = await found.connector.call(found.tool, input, signal, timeoutMs);
449
+ } catch (err) {
450
+ // Sent and not answered: it may have run.
451
+ return { text: this.#opts.redact(`${found.connector.name} did not answer (${shorten(this.#opts.redact((err as Error).message), 200)}): the call may or may not have run.`), isError: true, acted: true };
452
+ }
453
+ // The connector's secrets and the API keys are out of its text already, before it was cut (cleanResult); the
454
+ // structured part has the keys taken out here, before it is serialized and cut.
455
+ const structured = r.structured ? (redactJson(r.structured, this.#opts.redact) as Record<string, unknown>) : null;
456
+ try {
457
+ observe?.({ callId: call.id, server: found.connector.name, tool: found.tool, ok: !r.isError, structured });
458
+ } catch {}
459
+ const json = structured ? JSON.stringify(structured) : '';
460
+ const text = r.text || (structured ? (json.length > OUTPUT_MAX ? `${json.slice(0, OUTPUT_MAX)}\n[… cut at ${OUTPUT_MAX / 1024} KB]` : json) : r.isError ? 'The tool reported an error.' : '(no output)');
461
+ return { text: this.#opts.redact(text), isError: r.isError, acted: true };
462
+ }
463
+ }
464
+
465
+ /** What a sandboxed command's environment holds: the agent CLIs' inherited variables, a /tmp and nothing of the server. */
466
+ function sandboxEnv(req: TurnRequest): Record<string, string> {
467
+ const env = agentEnv({}) as Record<string, string>;
468
+ // No token or API address: nothing in the sandbox can reach the Botons API (it has no network anyway).
469
+ const out: Record<string, string> = {};
470
+ for (const [k, v] of Object.entries(env)) if (typeof v === 'string' && k !== 'CLAUDE_CONFIG_DIR' && k !== 'CODEX_HOME') out[k] = v;
471
+ out.TMPDIR = '/tmp';
472
+ Object.assign(out, withLegacyTurnNames({ BOTON_WORKSPACE: req.cwd }));
473
+ // The server's own Node first: a version manager's shims need files the sandbox doesn't show.
474
+ out.PATH = [dirname(process.execPath), ...(out.PATH ?? '/usr/bin:/bin').split(':').filter((d) => d && d !== dirname(process.execPath))].join(':');
475
+ return out;
476
+ }
477
+
478
+ /** The tools' names and schemas, hashed: when they change, a provider may refuse reasoning made under the old ones. */
479
+ export function toolsKey(specs: ToolSpec[]): string {
480
+ return createHash('sha256').update(JSON.stringify(specs.map((s) => [s.name, s.description, s.schema]))).digest('hex').slice(0, 16);
481
+ }