@kinqs/brainrouter-cli 0.3.4

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 (87) hide show
  1. package/.env.example +109 -0
  2. package/README.md +185 -0
  3. package/dist/agent/agent.d.ts +765 -0
  4. package/dist/agent/agent.js +1977 -0
  5. package/dist/cli/cliPrompt.d.ts +15 -0
  6. package/dist/cli/cliPrompt.js +62 -0
  7. package/dist/cli/commands/_context.d.ts +53 -0
  8. package/dist/cli/commands/_context.js +14 -0
  9. package/dist/cli/commands/_helpers.d.ts +45 -0
  10. package/dist/cli/commands/_helpers.js +140 -0
  11. package/dist/cli/commands/guard.d.ts +6 -0
  12. package/dist/cli/commands/guard.js +292 -0
  13. package/dist/cli/commands/memory.d.ts +12 -0
  14. package/dist/cli/commands/memory.js +263 -0
  15. package/dist/cli/commands/obs.d.ts +6 -0
  16. package/dist/cli/commands/obs.js +208 -0
  17. package/dist/cli/commands/orchestration.d.ts +6 -0
  18. package/dist/cli/commands/orchestration.js +218 -0
  19. package/dist/cli/commands/session.d.ts +6 -0
  20. package/dist/cli/commands/session.js +191 -0
  21. package/dist/cli/commands/ui.d.ts +6 -0
  22. package/dist/cli/commands/ui.js +477 -0
  23. package/dist/cli/commands/workflow.d.ts +6 -0
  24. package/dist/cli/commands/workflow.js +691 -0
  25. package/dist/cli/repl.d.ts +12 -0
  26. package/dist/cli/repl.js +894 -0
  27. package/dist/config/config.d.ts +22 -0
  28. package/dist/config/config.js +105 -0
  29. package/dist/config/workspace.d.ts +7 -0
  30. package/dist/config/workspace.js +62 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +610 -0
  33. package/dist/memory/briefing.d.ts +46 -0
  34. package/dist/memory/briefing.js +152 -0
  35. package/dist/memory/consolidation.d.ts +60 -0
  36. package/dist/memory/consolidation.js +208 -0
  37. package/dist/memory/formatters.d.ts +38 -0
  38. package/dist/memory/formatters.js +102 -0
  39. package/dist/memory/mentions.d.ts +10 -0
  40. package/dist/memory/mentions.js +72 -0
  41. package/dist/orchestration/orchestrator.d.ts +36 -0
  42. package/dist/orchestration/orchestrator.js +71 -0
  43. package/dist/orchestration/roles.d.ts +11 -0
  44. package/dist/orchestration/roles.js +117 -0
  45. package/dist/orchestration/tools.d.ts +244 -0
  46. package/dist/orchestration/tools.js +528 -0
  47. package/dist/prompt/breadthHint.d.ts +48 -0
  48. package/dist/prompt/breadthHint.js +93 -0
  49. package/dist/prompt/compactor.d.ts +31 -0
  50. package/dist/prompt/compactor.js +112 -0
  51. package/dist/prompt/initAgentMd.d.ts +13 -0
  52. package/dist/prompt/initAgentMd.js +194 -0
  53. package/dist/prompt/skillRunner.d.ts +34 -0
  54. package/dist/prompt/skillRunner.js +146 -0
  55. package/dist/prompt/systemPrompt.d.ts +10 -0
  56. package/dist/prompt/systemPrompt.js +171 -0
  57. package/dist/runtime/clipboard.d.ts +17 -0
  58. package/dist/runtime/clipboard.js +52 -0
  59. package/dist/runtime/llmSemaphore.d.ts +30 -0
  60. package/dist/runtime/llmSemaphore.js +67 -0
  61. package/dist/runtime/loopRunner.d.ts +25 -0
  62. package/dist/runtime/loopRunner.js +79 -0
  63. package/dist/runtime/mcpClient.d.ts +156 -0
  64. package/dist/runtime/mcpClient.js +234 -0
  65. package/dist/runtime/mcpUtils.d.ts +36 -0
  66. package/dist/runtime/mcpUtils.js +64 -0
  67. package/dist/runtime/sandbox.d.ts +48 -0
  68. package/dist/runtime/sandbox.js +156 -0
  69. package/dist/runtime/tracing.d.ts +25 -0
  70. package/dist/runtime/tracing.js +91 -0
  71. package/dist/state/cliState.d.ts +59 -0
  72. package/dist/state/cliState.js +311 -0
  73. package/dist/state/goalStore.d.ts +174 -0
  74. package/dist/state/goalStore.js +410 -0
  75. package/dist/state/hookifyStore.d.ts +80 -0
  76. package/dist/state/hookifyStore.js +237 -0
  77. package/dist/state/hooksStore.d.ts +42 -0
  78. package/dist/state/hooksStore.js +71 -0
  79. package/dist/state/preferencesStore.d.ts +41 -0
  80. package/dist/state/preferencesStore.js +25 -0
  81. package/dist/state/sessionStore.d.ts +42 -0
  82. package/dist/state/sessionStore.js +193 -0
  83. package/dist/state/taskStore.d.ts +23 -0
  84. package/dist/state/taskStore.js +80 -0
  85. package/dist/state/workflowArtifacts.d.ts +33 -0
  86. package/dist/state/workflowArtifacts.js +139 -0
  87. package/package.json +71 -0
@@ -0,0 +1,234 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
5
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
6
+ export class McpClientWrapper {
7
+ client;
8
+ transport = null;
9
+ /**
10
+ * True only after a successful `connect()`. Lets the CLI run in a degraded
11
+ * "offline" mode when the MCP server is unreachable at startup — `listTools`
12
+ * returns an empty list and `callTool` returns an error envelope instead of
13
+ * blowing up, which the agent's existing try/catch wrappers already handle.
14
+ */
15
+ connected = false;
16
+ constructor() {
17
+ this.client = new Client({ name: 'brainrouter-cli', version: '0.3.4' }, { capabilities: {} });
18
+ }
19
+ /** Whether this wrapper has an active MCP transport. */
20
+ isConnected() {
21
+ return this.connected;
22
+ }
23
+ async connect(serverConfig, llmConfig) {
24
+ if (serverConfig.type === 'stdio') {
25
+ if (!serverConfig.command) {
26
+ throw new Error('Stdio server configuration missing "command".');
27
+ }
28
+ // Merge environment variables safely. The CLI and MCP server have
29
+ // separate `.env` files (brainrouter-cli/.env vs brainrouter/.env); we
30
+ // do NOT want CLI-specific knobs (sandbox, tool-loop limit, web search
31
+ // backend) leaking into the MCP child, and we do NOT want
32
+ // process-specific vars where each side wants its own default (e.g.
33
+ // LLM_MAX_CONCURRENT defaults to 4 in the CLI and 2 in the MCP). The
34
+ // MCP child's own `dotenv/config` will load brainrouter/.env via the
35
+ // cwd hint below, so those vars come in from the right source.
36
+ const CLI_ONLY_VARS = new Set([
37
+ 'BRAINROUTER_MCP_TIMEOUT_MS',
38
+ 'BRAINROUTER_MAX_TOOL_RESULT_CHARS',
39
+ 'BRAINROUTER_AUTO_COMPACT_TOKENS',
40
+ 'BRAINROUTER_MAX_TOOL_LOOPS',
41
+ 'BRAINROUTER_TRACE_LOG',
42
+ 'BRAINROUTER_SANDBOX',
43
+ 'BRAINROUTER_SANDBOX_NETWORK',
44
+ 'BRAINROUTER_SANDBOX_READ_PATHS',
45
+ 'BRAINROUTER_SANDBOX_WRITE_PATHS',
46
+ 'BRAINROUTER_WEB_SEARCH_ENDPOINT',
47
+ ]);
48
+ // Process-specific: same var name, but each process has its own
49
+ // semantic / default. Don't propagate — let brainrouter/.env decide.
50
+ const PROCESS_SPECIFIC_VARS = new Set([
51
+ 'BRAINROUTER_LLM_MAX_CONCURRENT',
52
+ 'BRAINROUTER_LLM_TIMEOUT_MS',
53
+ ]);
54
+ const mergedEnv = {};
55
+ for (const [k, v] of Object.entries(process.env)) {
56
+ if (v === undefined)
57
+ continue;
58
+ if (CLI_ONLY_VARS.has(k))
59
+ continue;
60
+ if (PROCESS_SPECIFIC_VARS.has(k))
61
+ continue;
62
+ mergedEnv[k] = v;
63
+ }
64
+ if (serverConfig.env) {
65
+ for (const [k, v] of Object.entries(serverConfig.env)) {
66
+ if (v !== undefined) {
67
+ // If the shell process environment has a valid key, do not overwrite it with the default config placeholder.
68
+ if (k === 'BRAINROUTER_API_KEY' && process.env.BRAINROUTER_API_KEY && v === 'br_admin_key_placeholder') {
69
+ continue;
70
+ }
71
+ mergedEnv[k] = v;
72
+ }
73
+ }
74
+ }
75
+ // Auto-propagate the CLI's configured LLM settings to the MCP child so
76
+ // server-side memory extraction can share the same credentials/endpoint/model.
77
+ // Existing env vars always win — explicit shell config beats CLI defaults.
78
+ //
79
+ // Critical: when only `OPENAI_API_KEY` is set in the user's shell (which
80
+ // the CLI itself accepts as a fallback in callOpenAI), the MCP child
81
+ // inherits nothing — its cognitive extractor then silently disables,
82
+ // sensory rows pile up, the cognitive table stays empty, and every
83
+ // future recall returns 0 records. The fallback chain below makes the
84
+ // MCP child see whatever credential the CLI itself would have used.
85
+ // API-key resolution must use truthy checks, not `??`. The config file
86
+ // ships with `llm.apiKey: ''` by default — an empty string — and `??`
87
+ // only falls back on null/undefined. The earlier `??` form let the
88
+ // empty config string beat the OPENAI_API_KEY env fallback, leaving
89
+ // the MCP child with no credential, which silently disabled cognitive
90
+ // extraction. Sensory captures still landed, so the CLI happily
91
+ // emitted "💾 Captured turn" while 79 extractions failed in the
92
+ // background. (Verified against scheduler_state.extraction_errors.)
93
+ if (!mergedEnv.BRAINROUTER_LLM_API_KEY) {
94
+ const apiKey = (llmConfig?.apiKey && llmConfig.apiKey.trim()) ||
95
+ process.env.OPENAI_API_KEY ||
96
+ process.env.BRAINROUTER_LLM_API_KEY;
97
+ if (apiKey) {
98
+ mergedEnv.BRAINROUTER_LLM_API_KEY = apiKey;
99
+ }
100
+ }
101
+ if (llmConfig?.endpoint && !mergedEnv.BRAINROUTER_LLM_ENDPOINT) {
102
+ const ep = llmConfig.endpoint.replace(/\/$/, '');
103
+ mergedEnv.BRAINROUTER_LLM_ENDPOINT = ep.endsWith('/chat/completions')
104
+ ? ep
105
+ : `${ep}/chat/completions`;
106
+ }
107
+ if (llmConfig?.model && !mergedEnv.BRAINROUTER_LLM_MODEL) {
108
+ mergedEnv.BRAINROUTER_LLM_MODEL = llmConfig.model;
109
+ }
110
+ // Loud diagnostic: if NO LLM key reached the child, server-side
111
+ // memory extraction is dead — every sensory capture will pile up
112
+ // un-extracted. Print a yellow banner so the user knows BEFORE they
113
+ // see "0 records" in every briefing.
114
+ if (!mergedEnv.BRAINROUTER_LLM_API_KEY) {
115
+ console.warn('\n⚠️ No LLM API key reached the MCP child. Sensory turns will be ' +
116
+ 'captured but cognitive extraction (the thing that makes them ' +
117
+ 'searchable) will fail silently. Set OPENAI_API_KEY or ' +
118
+ 'BRAINROUTER_LLM_API_KEY before starting brainrouter.\n');
119
+ }
120
+ // Spawn the MCP child with cwd set to the MCP package directory if we
121
+ // can find it from the first arg (typically
122
+ // `node /path/to/BrainRouter/brainrouter/dist/index.js`). The child
123
+ // uses `import "dotenv/config"` which resolves `.env` relative to
124
+ // `process.cwd()` — defaulting to the user's launch dir meant
125
+ // `brainrouter/.env` was never read. With cwd hinted, dotenv finds
126
+ // the canonical config without the user having to copy/symlink files.
127
+ const firstArg = serverConfig.args?.[0];
128
+ let childCwd;
129
+ if (firstArg && firstArg.endsWith('.js')) {
130
+ try {
131
+ // brainrouter/dist/index.js → brainrouter/
132
+ const distDir = path.dirname(firstArg);
133
+ const pkgRoot = path.resolve(distDir, '..');
134
+ // Sanity: only set if the directory contains a `.env` or `package.json`
135
+ // (avoid pointing the child at /usr/local/lib by accident).
136
+ if (fs.existsSync(path.join(pkgRoot, '.env')) ||
137
+ fs.existsSync(path.join(pkgRoot, 'package.json'))) {
138
+ childCwd = pkgRoot;
139
+ }
140
+ }
141
+ catch {
142
+ // Best-effort; if path resolution fails we just don't set cwd.
143
+ }
144
+ }
145
+ this.transport = new StdioClientTransport({
146
+ command: serverConfig.command,
147
+ args: serverConfig.args ?? [],
148
+ env: mergedEnv,
149
+ cwd: childCwd,
150
+ });
151
+ await this.client.connect(this.transport);
152
+ this.connected = true;
153
+ }
154
+ else if (serverConfig.type === 'http') {
155
+ if (!serverConfig.url) {
156
+ throw new Error('HTTP server configuration missing "url".');
157
+ }
158
+ const url = new URL(serverConfig.url);
159
+ const transportOpts = {};
160
+ if (serverConfig.apiKey) {
161
+ transportOpts.requestInit = {
162
+ headers: {
163
+ 'Authorization': `Bearer ${serverConfig.apiKey}`,
164
+ },
165
+ };
166
+ }
167
+ const httpTransport = new StreamableHTTPClientTransport(url, transportOpts);
168
+ this.transport = httpTransport;
169
+ await this.client.connect(httpTransport);
170
+ this.connected = true;
171
+ }
172
+ else {
173
+ throw new Error(`Unsupported connection type: ${serverConfig.type}`);
174
+ }
175
+ }
176
+ async listTools() {
177
+ // Offline mode: return an empty tool list so the agent's runTurn proceeds
178
+ // with only local tools instead of crashing when it tries to enumerate.
179
+ if (!this.connected)
180
+ return { tools: [] };
181
+ return this.client.listTools({});
182
+ }
183
+ async callTool(name, args) {
184
+ // Offline mode: synthesize an error envelope that downstream consumers
185
+ // (callMcpTool, agent.captureTurn, memory_recall pipelines) already know
186
+ // how to ignore via their existing isError checks. Without this the SDK
187
+ // would throw "Not connected" from inside transport code, which surfaces
188
+ // as a hard crash instead of a graceful degradation.
189
+ if (!this.connected) {
190
+ return {
191
+ isError: true,
192
+ content: [{
193
+ type: 'text',
194
+ text: `MCP server is not connected. Tool "${name}" is unavailable in offline mode. Start the BrainRouter MCP server and reconnect (or restart the CLI) to use memory, skills, and recall.`,
195
+ }],
196
+ };
197
+ }
198
+ // A hung MCP server used to hang the entire runTurn forever — there was
199
+ // no per-tool timeout, and the LLM call timeout only fired between tool
200
+ // rounds. Race the tool call against a configurable timeout so a flaky
201
+ // child server can't lock up the whole CLI.
202
+ const timeoutMs = Number(process.env.BRAINROUTER_MCP_TIMEOUT_MS) || 60_000;
203
+ return Promise.race([
204
+ this.client.callTool({ name, arguments: args }),
205
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`MCP tool "${name}" timed out after ${timeoutMs}ms`)), timeoutMs)),
206
+ ]);
207
+ }
208
+ async close() {
209
+ if (this.transport) {
210
+ if (this.transport instanceof StreamableHTTPClientTransport) {
211
+ try {
212
+ await this.transport.terminateSession();
213
+ }
214
+ catch {
215
+ // ignore session termination errors
216
+ }
217
+ }
218
+ try {
219
+ await this.transport.close();
220
+ }
221
+ catch {
222
+ // ignore
223
+ }
224
+ }
225
+ try {
226
+ await this.client.close();
227
+ }
228
+ catch {
229
+ // ignore
230
+ }
231
+ this.transport = null;
232
+ this.connected = false;
233
+ }
234
+ }
@@ -0,0 +1,36 @@
1
+ import type { McpClientWrapper } from './mcpClient.js';
2
+ /**
3
+ * Centralized helpers for talking to the BrainRouter MCP server.
4
+ *
5
+ * Every MCP `callTool` response shares the same wire shape — an `isError`
6
+ * boolean plus a `content` array of `{ type, text }` entries — and most
7
+ * callers do the same three things with it: join the text, optionally
8
+ * `JSON.parse`, and tolerate failures. Centralizing those mechanics here
9
+ * avoids ~5 nearly-identical extractors scattered across the codebase and
10
+ * gives us one place to fix bugs (e.g., result shape changes upstream).
11
+ */
12
+ /** Join the `text` parts of an MCP tool result into a single string. Tolerates non-content payloads. */
13
+ export declare function extractToolText(result: any): string;
14
+ /** JSON.parse that never throws. Returns `undefined` (or the provided fallback) on failure. */
15
+ export declare function safeJsonParse<T = any>(text: string, fallback?: T): T | undefined;
16
+ export interface McpCallResult<T = any> {
17
+ isError: boolean;
18
+ text: string;
19
+ /** Parsed JSON when the tool returned JSON; undefined otherwise. */
20
+ parsed: T | undefined;
21
+ /** The raw response object, in case a caller needs metadata we didn't normalize. */
22
+ raw: any;
23
+ }
24
+ /**
25
+ * Call an MCP tool and normalize the response into `{ isError, text, parsed }`.
26
+ *
27
+ * Network and protocol errors are converted to `{ isError: true, text: errorMessage }`
28
+ * so callers can branch on a single shape instead of mixing try/catch with isError checks.
29
+ */
30
+ export declare function callMcpTool<T = any>(client: McpClientWrapper, name: string, args: Record<string, unknown>): Promise<McpCallResult<T>>;
31
+ /**
32
+ * Canonical convention for naming a child agent's session key relative to its
33
+ * parent: `<parent>:child:<id>`. Centralized so a future change (e.g. switching
34
+ * to UUIDs or namespacing per-role) is a one-file edit, not a sweep.
35
+ */
36
+ export declare function childSessionKey(parentSessionKey: string, childId: string): string;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Centralized helpers for talking to the BrainRouter MCP server.
3
+ *
4
+ * Every MCP `callTool` response shares the same wire shape — an `isError`
5
+ * boolean plus a `content` array of `{ type, text }` entries — and most
6
+ * callers do the same three things with it: join the text, optionally
7
+ * `JSON.parse`, and tolerate failures. Centralizing those mechanics here
8
+ * avoids ~5 nearly-identical extractors scattered across the codebase and
9
+ * gives us one place to fix bugs (e.g., result shape changes upstream).
10
+ */
11
+ /** Join the `text` parts of an MCP tool result into a single string. Tolerates non-content payloads. */
12
+ export function extractToolText(result) {
13
+ if (Array.isArray(result?.content)) {
14
+ return result.content.map((entry) => entry?.text || '').join('\n');
15
+ }
16
+ if (typeof result === 'string')
17
+ return result;
18
+ return JSON.stringify(result ?? '');
19
+ }
20
+ /** JSON.parse that never throws. Returns `undefined` (or the provided fallback) on failure. */
21
+ export function safeJsonParse(text, fallback) {
22
+ if (!text)
23
+ return fallback;
24
+ try {
25
+ return JSON.parse(text);
26
+ }
27
+ catch {
28
+ return fallback;
29
+ }
30
+ }
31
+ /**
32
+ * Call an MCP tool and normalize the response into `{ isError, text, parsed }`.
33
+ *
34
+ * Network and protocol errors are converted to `{ isError: true, text: errorMessage }`
35
+ * so callers can branch on a single shape instead of mixing try/catch with isError checks.
36
+ */
37
+ export async function callMcpTool(client, name, args) {
38
+ try {
39
+ const raw = await client.callTool(name, args);
40
+ const text = extractToolText(raw);
41
+ return {
42
+ isError: Boolean(raw?.isError),
43
+ text,
44
+ parsed: safeJsonParse(text),
45
+ raw,
46
+ };
47
+ }
48
+ catch (err) {
49
+ return {
50
+ isError: true,
51
+ text: err?.message ?? String(err),
52
+ parsed: undefined,
53
+ raw: undefined,
54
+ };
55
+ }
56
+ }
57
+ /**
58
+ * Canonical convention for naming a child agent's session key relative to its
59
+ * parent: `<parent>:child:<id>`. Centralized so a future change (e.g. switching
60
+ * to UUIDs or namespacing per-role) is a one-file edit, not a sweep.
61
+ */
62
+ export function childSessionKey(parentSessionKey, childId) {
63
+ return `${parentSessionKey}:child:${childId}`;
64
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Optional sandboxing for `run_command`.
3
+ *
4
+ * Activated by setting `BRAINROUTER_SANDBOX=on`. When inactive, commands run
5
+ * exactly as before (with the existing user confirmation prompt). When
6
+ * active, the command is wrapped in the platform's native sandboxer:
7
+ *
8
+ * - macOS: `sandbox-exec -f <profile>` with a generated `.sb` profile that
9
+ * denies network by default, restricts writes to the workspace, and
10
+ * allows reads of `/usr`, `/bin`, `/etc`, the workspace, and any
11
+ * extra paths in `BRAINROUTER_SANDBOX_READ_PATHS`.
12
+ * - Linux: `bwrap` (bubblewrap) when available; falls back to `firejail`.
13
+ * Sets up a fresh mount namespace with the workspace mounted rw and
14
+ * the rest of the FS bind-mounted ro.
15
+ * - Windows: no native sandbox in stdlib; falls back to unsandboxed run with
16
+ * a clear warning so the user knows the flag was honored as a no-op.
17
+ *
18
+ * The sandbox is intentionally an *additional* layer on top of the existing
19
+ * user-confirmation step — confirmation guards intent, sandboxing guards blast
20
+ * radius if the user approves something they shouldn't have.
21
+ */
22
+ export interface SandboxConfig {
23
+ enabled: boolean;
24
+ workspaceRoot: string;
25
+ /** Extra read-only paths to allow. */
26
+ readPaths: string[];
27
+ /** Extra write-allowed paths beyond the workspace. */
28
+ writePaths: string[];
29
+ /** If true, allow outbound network. Off by default. */
30
+ allowNetwork: boolean;
31
+ }
32
+ export declare function resolveSandboxConfig(workspaceRoot: string, persistedExtras?: {
33
+ readPaths?: string[];
34
+ writePaths?: string[];
35
+ }): SandboxConfig;
36
+ export interface SandboxRunResult {
37
+ stdout: string;
38
+ stderr: string;
39
+ exitCode: number;
40
+ sandboxed: boolean;
41
+ sandboxTool?: 'sandbox-exec' | 'bwrap' | 'firejail' | 'none';
42
+ notice?: string;
43
+ }
44
+ /**
45
+ * Execute `command` (a shell string) with optional sandboxing. Returns a
46
+ * normalized result. Always returns; never throws on non-zero exit.
47
+ */
48
+ export declare function runShell(command: string, config: SandboxConfig, timeoutMs?: number): Promise<SandboxRunResult>;
@@ -0,0 +1,156 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ export function resolveSandboxConfig(workspaceRoot, persistedExtras) {
6
+ const enabled = (process.env.BRAINROUTER_SANDBOX ?? '').toLowerCase() === 'on';
7
+ const envReads = (process.env.BRAINROUTER_SANDBOX_READ_PATHS ?? '')
8
+ .split(path.delimiter).map((p) => p.trim()).filter(Boolean);
9
+ const envWrites = (process.env.BRAINROUTER_SANDBOX_WRITE_PATHS ?? '')
10
+ .split(path.delimiter).map((p) => p.trim()).filter(Boolean);
11
+ const readPaths = Array.from(new Set([...(persistedExtras?.readPaths ?? []), ...envReads]));
12
+ const writePaths = Array.from(new Set([...(persistedExtras?.writePaths ?? []), ...envWrites]));
13
+ const allowNetwork = (process.env.BRAINROUTER_SANDBOX_NETWORK ?? '').toLowerCase() === 'on';
14
+ return { enabled, workspaceRoot, readPaths, writePaths, allowNetwork };
15
+ }
16
+ /**
17
+ * Execute `command` (a shell string) with optional sandboxing. Returns a
18
+ * normalized result. Always returns; never throws on non-zero exit.
19
+ */
20
+ export async function runShell(command, config, timeoutMs = 120_000) {
21
+ // Always pin cwd to the workspace root so `run_command` never inherits a
22
+ // drifted process.cwd() (and writes test files into ~/.brainrouter).
23
+ const cwd = config.workspaceRoot;
24
+ if (!config.enabled) {
25
+ return execShell(command, undefined, cwd, timeoutMs, false, 'none');
26
+ }
27
+ if (process.platform === 'darwin') {
28
+ const profilePath = writeMacSandboxProfile(config);
29
+ const wrapped = ['sandbox-exec', '-f', profilePath, '/bin/sh', '-c', command];
30
+ return execShell(wrapped[0], wrapped.slice(1), cwd, timeoutMs, true, 'sandbox-exec');
31
+ }
32
+ if (process.platform === 'linux') {
33
+ if (await binaryAvailable('bwrap')) {
34
+ const args = buildBwrapArgs(config, command);
35
+ return execShell('bwrap', args, cwd, timeoutMs, true, 'bwrap');
36
+ }
37
+ if (await binaryAvailable('firejail')) {
38
+ const args = buildFirejailArgs(config, command);
39
+ return execShell('firejail', args, cwd, timeoutMs, true, 'firejail');
40
+ }
41
+ const fallback = await execShell('/bin/sh', ['-c', command], cwd, timeoutMs, false, 'none');
42
+ fallback.notice = 'BRAINROUTER_SANDBOX=on but neither bwrap nor firejail is installed — command ran UNSANDBOXED.';
43
+ return fallback;
44
+ }
45
+ // Windows / other — no portable sandbox. Run unsandboxed with a notice.
46
+ const fallback = await execShell(command, undefined, cwd, timeoutMs, false, 'none');
47
+ fallback.notice = `BRAINROUTER_SANDBOX=on but no sandbox tool is available on ${process.platform} — command ran UNSANDBOXED.`;
48
+ return fallback;
49
+ }
50
+ function execShell(cmd, args, cwd, timeoutMs, sandboxed, tool) {
51
+ return new Promise((resolve) => {
52
+ const useShell = !args; // when no args provided, run as a single shell string
53
+ const child = useShell
54
+ ? spawn(cmd, { cwd, shell: true })
55
+ : spawn(cmd, args, { cwd });
56
+ let stdout = '';
57
+ let stderr = '';
58
+ const timer = setTimeout(() => {
59
+ try {
60
+ child.kill('SIGKILL');
61
+ }
62
+ catch { /* noop */ }
63
+ }, timeoutMs);
64
+ child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
65
+ child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
66
+ child.on('close', (code) => {
67
+ clearTimeout(timer);
68
+ resolve({ stdout, stderr, exitCode: code ?? 0, sandboxed, sandboxTool: tool });
69
+ });
70
+ child.on('error', (err) => {
71
+ clearTimeout(timer);
72
+ resolve({ stdout: '', stderr: err.message, exitCode: 127, sandboxed, sandboxTool: tool });
73
+ });
74
+ });
75
+ }
76
+ function binaryAvailable(name) {
77
+ return new Promise((resolve) => {
78
+ const child = spawn('command', ['-v', name], { shell: true });
79
+ child.on('close', (code) => resolve(code === 0));
80
+ child.on('error', () => resolve(false));
81
+ });
82
+ }
83
+ /**
84
+ * Generate a macOS sandbox-exec profile and write it to a temp file. The
85
+ * profile starts from `(deny default)` and explicitly allows the syscalls a
86
+ * normal build/test command needs.
87
+ */
88
+ function writeMacSandboxProfile(config) {
89
+ const lines = [
90
+ '(version 1)',
91
+ '(deny default)',
92
+ '(allow process-fork process-exec)',
93
+ '(allow signal (target self))',
94
+ '(allow sysctl-read)',
95
+ '(allow mach-lookup)',
96
+ '(allow ipc-posix-shm)',
97
+ '(allow file-read*)', // permissive on reads — sandboxing writes is the priority
98
+ `(allow file-write* (subpath "${escapeSb(config.workspaceRoot)}"))`,
99
+ '(allow file-write* (subpath "/tmp"))',
100
+ `(allow file-write* (subpath "${escapeSb(os.tmpdir())}"))`,
101
+ ];
102
+ for (const p of config.writePaths) {
103
+ lines.push(`(allow file-write* (subpath "${escapeSb(p)}"))`);
104
+ }
105
+ if (config.allowNetwork) {
106
+ lines.push('(allow network*)');
107
+ }
108
+ const profile = lines.join('\n');
109
+ const file = path.join(os.tmpdir(), `brainrouter-sandbox-${process.pid}.sb`);
110
+ fs.writeFileSync(file, profile, 'utf8');
111
+ return file;
112
+ }
113
+ function escapeSb(p) {
114
+ return p.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
115
+ }
116
+ function buildBwrapArgs(config, command) {
117
+ const args = [
118
+ '--ro-bind', '/usr', '/usr',
119
+ '--ro-bind', '/lib', '/lib',
120
+ '--ro-bind', '/lib64', '/lib64',
121
+ '--ro-bind', '/etc', '/etc',
122
+ '--ro-bind', '/bin', '/bin',
123
+ '--proc', '/proc',
124
+ '--dev', '/dev',
125
+ '--tmpfs', '/tmp',
126
+ '--bind', config.workspaceRoot, config.workspaceRoot,
127
+ '--chdir', config.workspaceRoot,
128
+ ];
129
+ for (const p of config.readPaths) {
130
+ args.push('--ro-bind', p, p);
131
+ }
132
+ for (const p of config.writePaths) {
133
+ args.push('--bind', p, p);
134
+ }
135
+ if (!config.allowNetwork) {
136
+ args.push('--unshare-net');
137
+ }
138
+ args.push('/bin/sh', '-c', command);
139
+ return args;
140
+ }
141
+ function buildFirejailArgs(config, command) {
142
+ const args = [
143
+ '--quiet',
144
+ `--whitelist=${config.workspaceRoot}`,
145
+ `--read-only=/usr`,
146
+ `--read-only=/etc`,
147
+ ];
148
+ for (const p of config.readPaths)
149
+ args.push(`--read-only=${p}`);
150
+ for (const p of config.writePaths)
151
+ args.push(`--whitelist=${p}`);
152
+ if (!config.allowNetwork)
153
+ args.push('--net=none');
154
+ args.push('/bin/sh', '-c', command);
155
+ return args;
156
+ }
@@ -0,0 +1,25 @@
1
+ export declare function traceEnabled(): boolean;
2
+ export declare function newTraceId(): string;
3
+ export declare function newSpanId(): string;
4
+ /**
5
+ * Emit a one-shot event (no duration). Cheap; the file is opened+appended
6
+ * synchronously then closed. For higher throughput, swap to a buffered writer
7
+ * later — this is intentionally simple.
8
+ */
9
+ export declare function traceEvent(name: string, attributes?: Record<string, unknown>, options?: {
10
+ traceId?: string;
11
+ spanId?: string;
12
+ parentSpanId?: string;
13
+ }): void;
14
+ /**
15
+ * Open a span. Call `end(extraAttrs?)` on the returned handle when finished.
16
+ * Returns a no-op handle when tracing is disabled.
17
+ */
18
+ export declare function startSpan(name: string, attributes?: Record<string, unknown>, options?: {
19
+ traceId?: string;
20
+ parentSpanId?: string;
21
+ }): {
22
+ end: (extra?: Record<string, unknown>) => void;
23
+ traceId: string;
24
+ spanId: string;
25
+ };
@@ -0,0 +1,91 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ let cachedLogPath;
5
+ function resolveLogPath() {
6
+ if (cachedLogPath !== undefined)
7
+ return cachedLogPath;
8
+ const raw = process.env.BRAINROUTER_TRACE_LOG?.trim();
9
+ cachedLogPath = raw ? path.resolve(raw) : null;
10
+ if (cachedLogPath) {
11
+ try {
12
+ fs.mkdirSync(path.dirname(cachedLogPath), { recursive: true });
13
+ }
14
+ catch { /* noop */ }
15
+ }
16
+ return cachedLogPath;
17
+ }
18
+ export function traceEnabled() {
19
+ return resolveLogPath() !== null;
20
+ }
21
+ export function newTraceId() {
22
+ return randomUUID().replace(/-/g, '').slice(0, 32);
23
+ }
24
+ export function newSpanId() {
25
+ return randomUUID().replace(/-/g, '').slice(0, 16);
26
+ }
27
+ /**
28
+ * Emit a one-shot event (no duration). Cheap; the file is opened+appended
29
+ * synchronously then closed. For higher throughput, swap to a buffered writer
30
+ * later — this is intentionally simple.
31
+ */
32
+ export function traceEvent(name, attributes = {}, options) {
33
+ const logPath = resolveLogPath();
34
+ if (!logPath)
35
+ return;
36
+ const evt = {
37
+ ts: new Date().toISOString(),
38
+ trace_id: options?.traceId ?? newTraceId(),
39
+ span_id: options?.spanId ?? newSpanId(),
40
+ parent_span_id: options?.parentSpanId,
41
+ name,
42
+ attributes,
43
+ };
44
+ try {
45
+ fs.appendFileSync(logPath, JSON.stringify(evt) + '\n', 'utf8');
46
+ }
47
+ catch { /* tracing must never break the CLI */ }
48
+ }
49
+ /**
50
+ * Open a span. Call `end(extraAttrs?)` on the returned handle when finished.
51
+ * Returns a no-op handle when tracing is disabled.
52
+ */
53
+ export function startSpan(name, attributes = {}, options) {
54
+ if (!traceEnabled()) {
55
+ return { end: () => { }, traceId: '', spanId: '' };
56
+ }
57
+ const traceId = options?.traceId ?? newTraceId();
58
+ const spanId = newSpanId();
59
+ const startedAt = Date.now();
60
+ return {
61
+ traceId,
62
+ spanId,
63
+ end: (extra) => {
64
+ traceEvent(name, { ...attributes, ...(extra ?? {}) }, {
65
+ traceId,
66
+ spanId,
67
+ parentSpanId: options?.parentSpanId,
68
+ });
69
+ // Overwrite ts with start time? — keep ts as end-of-span for simplicity;
70
+ // duration_ms gives the start. Some collectors prefer this shape.
71
+ const logPath = resolveLogPath();
72
+ if (logPath) {
73
+ try {
74
+ // Best-effort patch: write a second line with duration_ms so the
75
+ // duration is queryable without re-deriving from start/end events.
76
+ const dur = Date.now() - startedAt;
77
+ fs.appendFileSync(logPath, JSON.stringify({
78
+ ts: new Date().toISOString(),
79
+ trace_id: traceId,
80
+ span_id: spanId,
81
+ parent_span_id: options?.parentSpanId,
82
+ name: `${name}.end`,
83
+ duration_ms: dur,
84
+ attributes: { ...attributes, ...(extra ?? {}) },
85
+ }) + '\n', 'utf8');
86
+ }
87
+ catch { /* noop */ }
88
+ }
89
+ },
90
+ };
91
+ }