@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.
- package/.env.example +109 -0
- package/README.md +185 -0
- package/dist/agent/agent.d.ts +765 -0
- package/dist/agent/agent.js +1977 -0
- package/dist/cli/cliPrompt.d.ts +15 -0
- package/dist/cli/cliPrompt.js +62 -0
- package/dist/cli/commands/_context.d.ts +53 -0
- package/dist/cli/commands/_context.js +14 -0
- package/dist/cli/commands/_helpers.d.ts +45 -0
- package/dist/cli/commands/_helpers.js +140 -0
- package/dist/cli/commands/guard.d.ts +6 -0
- package/dist/cli/commands/guard.js +292 -0
- package/dist/cli/commands/memory.d.ts +12 -0
- package/dist/cli/commands/memory.js +263 -0
- package/dist/cli/commands/obs.d.ts +6 -0
- package/dist/cli/commands/obs.js +208 -0
- package/dist/cli/commands/orchestration.d.ts +6 -0
- package/dist/cli/commands/orchestration.js +218 -0
- package/dist/cli/commands/session.d.ts +6 -0
- package/dist/cli/commands/session.js +191 -0
- package/dist/cli/commands/ui.d.ts +6 -0
- package/dist/cli/commands/ui.js +477 -0
- package/dist/cli/commands/workflow.d.ts +6 -0
- package/dist/cli/commands/workflow.js +691 -0
- package/dist/cli/repl.d.ts +12 -0
- package/dist/cli/repl.js +894 -0
- package/dist/config/config.d.ts +22 -0
- package/dist/config/config.js +105 -0
- package/dist/config/workspace.d.ts +7 -0
- package/dist/config/workspace.js +62 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +610 -0
- package/dist/memory/briefing.d.ts +46 -0
- package/dist/memory/briefing.js +152 -0
- package/dist/memory/consolidation.d.ts +60 -0
- package/dist/memory/consolidation.js +208 -0
- package/dist/memory/formatters.d.ts +38 -0
- package/dist/memory/formatters.js +102 -0
- package/dist/memory/mentions.d.ts +10 -0
- package/dist/memory/mentions.js +72 -0
- package/dist/orchestration/orchestrator.d.ts +36 -0
- package/dist/orchestration/orchestrator.js +71 -0
- package/dist/orchestration/roles.d.ts +11 -0
- package/dist/orchestration/roles.js +117 -0
- package/dist/orchestration/tools.d.ts +244 -0
- package/dist/orchestration/tools.js +528 -0
- package/dist/prompt/breadthHint.d.ts +48 -0
- package/dist/prompt/breadthHint.js +93 -0
- package/dist/prompt/compactor.d.ts +31 -0
- package/dist/prompt/compactor.js +112 -0
- package/dist/prompt/initAgentMd.d.ts +13 -0
- package/dist/prompt/initAgentMd.js +194 -0
- package/dist/prompt/skillRunner.d.ts +34 -0
- package/dist/prompt/skillRunner.js +146 -0
- package/dist/prompt/systemPrompt.d.ts +10 -0
- package/dist/prompt/systemPrompt.js +171 -0
- package/dist/runtime/clipboard.d.ts +17 -0
- package/dist/runtime/clipboard.js +52 -0
- package/dist/runtime/llmSemaphore.d.ts +30 -0
- package/dist/runtime/llmSemaphore.js +67 -0
- package/dist/runtime/loopRunner.d.ts +25 -0
- package/dist/runtime/loopRunner.js +79 -0
- package/dist/runtime/mcpClient.d.ts +156 -0
- package/dist/runtime/mcpClient.js +234 -0
- package/dist/runtime/mcpUtils.d.ts +36 -0
- package/dist/runtime/mcpUtils.js +64 -0
- package/dist/runtime/sandbox.d.ts +48 -0
- package/dist/runtime/sandbox.js +156 -0
- package/dist/runtime/tracing.d.ts +25 -0
- package/dist/runtime/tracing.js +91 -0
- package/dist/state/cliState.d.ts +59 -0
- package/dist/state/cliState.js +311 -0
- package/dist/state/goalStore.d.ts +174 -0
- package/dist/state/goalStore.js +410 -0
- package/dist/state/hookifyStore.d.ts +80 -0
- package/dist/state/hookifyStore.js +237 -0
- package/dist/state/hooksStore.d.ts +42 -0
- package/dist/state/hooksStore.js +71 -0
- package/dist/state/preferencesStore.d.ts +41 -0
- package/dist/state/preferencesStore.js +25 -0
- package/dist/state/sessionStore.d.ts +42 -0
- package/dist/state/sessionStore.js +193 -0
- package/dist/state/taskStore.d.ts +23 -0
- package/dist/state/taskStore.js +80 -0
- package/dist/state/workflowArtifacts.d.ts +33 -0
- package/dist/state/workflowArtifacts.js +139 -0
- 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
|
+
}
|