@blockrun/runcode 2.0.0 → 2.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.
@@ -136,10 +136,11 @@ async function compactHistory(history, model, client, debug) {
136
136
  * Keeps the most recent tool exchange + the last few user/assistant turns.
137
137
  */
138
138
  function findKeepBoundary(history) {
139
- // Keep at least the last 6 messages (3 turns), or ~30% of history
140
- const minKeep = Math.min(6, history.length);
141
- const pctKeep = Math.ceil(history.length * 0.3);
142
- let keep = Math.max(minKeep, pctKeep);
139
+ // Keep the last 8-20 messages (absolute range, not percentage)
140
+ // Prevents "never compacts" bug when history grows large
141
+ const minKeep = Math.min(8, history.length);
142
+ const maxKeep = Math.min(20, history.length - 1);
143
+ let keep = Math.max(minKeep, Math.min(maxKeep, Math.ceil(history.length * 0.3)));
143
144
  // Make sure we don't split in the middle of a tool exchange
144
145
  // (assistant with tool_use must be followed by user with tool_result)
145
146
  while (keep < history.length) {
package/dist/agent/llm.js CHANGED
@@ -129,7 +129,12 @@ export class ModelClient {
129
129
  try {
130
130
  parsedInput = JSON.parse(currentToolInput || '{}');
131
131
  }
132
- catch { /* empty */ }
132
+ catch (parseErr) {
133
+ // Log malformed JSON instead of silently defaulting to {}
134
+ if (this.debug) {
135
+ console.error(`[runcode] Malformed tool input JSON for ${currentToolName}: ${parseErr.message}`);
136
+ }
137
+ }
133
138
  const toolInvocation = {
134
139
  type: 'tool_use',
135
140
  id: currentToolId,
@@ -215,7 +215,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
215
215
  // Session persistence
216
216
  const sessionId = createSessionId();
217
217
  let turnCount = 0;
218
- pruneOldSessions(); // Cleanup old sessions on start
218
+ pruneOldSessions(sessionId); // Cleanup old sessions on start, protect current
219
219
  while (true) {
220
220
  let input = await getUserInput();
221
221
  if (input === null)
@@ -49,7 +49,12 @@ export function budgetToolResults(history) {
49
49
  // Per-tool cap
50
50
  if (size > MAX_TOOL_RESULT_CHARS) {
51
51
  modified = true;
52
- const preview = content.slice(0, PREVIEW_CHARS);
52
+ // Truncate at line boundary for cleaner output
53
+ let preview = content.slice(0, PREVIEW_CHARS);
54
+ const lastNewline = preview.lastIndexOf('\n');
55
+ if (lastNewline > PREVIEW_CHARS * 0.5) {
56
+ preview = preview.slice(0, lastNewline);
57
+ }
53
58
  budgeted.push({
54
59
  type: 'tool_result',
55
60
  tool_use_id: part.tool_use_id,
@@ -23,19 +23,26 @@ export function updateActualTokens(inputTokens, outputTokens, messageCount) {
23
23
  * More accurate than pure estimation because it's grounded in actual API counts.
24
24
  */
25
25
  export function getAnchoredTokenCount(history) {
26
- if (lastApiInputTokens > 0 && history.length >= lastApiMessageCount) {
27
- // Anchor to API count, estimate only new messages
28
- const newMessages = history.slice(lastApiMessageCount);
29
- let newTokens = 0;
30
- for (const msg of newMessages) {
31
- newTokens += estimateDialogueTokens(msg);
26
+ if (lastApiInputTokens > 0 && lastApiMessageCount > 0 && history.length >= lastApiMessageCount) {
27
+ // Sanity check: if history was mutated (compaction, micro-compact), anchor may be stale.
28
+ // Detect by checking if new messages were only appended (length grew), not if content changed.
29
+ // If history grew by more than expected (e.g., resume injected many messages), fall through to estimation.
30
+ const growth = history.length - lastApiMessageCount;
31
+ if (growth <= 20) { // Reasonable growth since last API call
32
+ const newMessages = history.slice(lastApiMessageCount);
33
+ let newTokens = 0;
34
+ for (const msg of newMessages) {
35
+ newTokens += estimateDialogueTokens(msg);
36
+ }
37
+ const total = lastApiInputTokens + newTokens;
38
+ return {
39
+ estimated: total,
40
+ apiAnchored: true,
41
+ contextUsagePct: 0,
42
+ };
32
43
  }
33
- const total = lastApiInputTokens + newTokens;
34
- return {
35
- estimated: total,
36
- apiAnchored: true,
37
- contextUsagePct: 0, // Will be calculated by caller with model context window
38
- };
44
+ // Too much growth anchor is unreliable, fall through to estimation
45
+ resetTokenAnchor();
39
46
  }
40
47
  // No anchor — pure estimation
41
48
  return {
@@ -27,6 +27,7 @@ export interface McpConfig {
27
27
  }
28
28
  /**
29
29
  * Connect to all configured MCP servers and return discovered tools.
30
+ * Each connection has a 5s timeout to avoid blocking startup.
30
31
  */
31
32
  export declare function connectMcpServers(config: McpConfig, debug?: boolean): Promise<CapabilityHandler[]>;
32
33
  /**
@@ -21,7 +21,17 @@ async function connectStdio(name, config) {
21
21
  env: { ...process.env, ...(config.env || {}) },
22
22
  });
23
23
  const client = new Client({ name: `runcode-mcp-${name}`, version: '1.0.0' }, { capabilities: {} });
24
- await client.connect(transport);
24
+ try {
25
+ await client.connect(transport);
26
+ }
27
+ catch (err) {
28
+ // Clean up transport if connect fails to prevent resource leak
29
+ try {
30
+ await transport.close();
31
+ }
32
+ catch { /* ignore */ }
33
+ throw err;
34
+ }
25
35
  // Discover tools
26
36
  const { tools: mcpTools } = await client.listTools();
27
37
  const capabilities = [];
@@ -38,11 +48,12 @@ async function connectStdio(name, config) {
38
48
  },
39
49
  },
40
50
  execute: async (input, _ctx) => {
51
+ const MCP_TOOL_TIMEOUT = 30_000;
41
52
  try {
42
- const result = await client.callTool({
43
- name: tool.name,
44
- arguments: input,
45
- });
53
+ // Timeout protection: if tool hangs, don't block the agent forever
54
+ const callPromise = client.callTool({ name: tool.name, arguments: input });
55
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`MCP tool timeout after ${MCP_TOOL_TIMEOUT / 1000}s`)), MCP_TOOL_TIMEOUT));
56
+ const result = await Promise.race([callPromise, timeoutPromise]);
46
57
  // Extract text content from MCP response
47
58
  const output = result.content
48
59
  ?.filter(c => c.type === 'text')
@@ -70,6 +81,11 @@ async function connectStdio(name, config) {
70
81
  /**
71
82
  * Connect to all configured MCP servers and return discovered tools.
72
83
  */
84
+ const MCP_CONNECT_TIMEOUT = 5_000; // 5s per server connection
85
+ /**
86
+ * Connect to all configured MCP servers and return discovered tools.
87
+ * Each connection has a 5s timeout to avoid blocking startup.
88
+ */
73
89
  export async function connectMcpServers(config, debug) {
74
90
  const allTools = [];
75
91
  for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
@@ -79,17 +95,16 @@ export async function connectMcpServers(config, debug) {
79
95
  if (debug) {
80
96
  console.error(`[runcode] Connecting to MCP server: ${name}...`);
81
97
  }
82
- let connected;
83
- if (serverConfig.transport === 'stdio') {
84
- connected = await connectStdio(name, serverConfig);
85
- }
86
- else {
87
- // HTTP transport — TODO: implement SSE/HTTP transport
98
+ if (serverConfig.transport !== 'stdio') {
88
99
  if (debug) {
89
100
  console.error(`[runcode] MCP HTTP transport not yet supported for ${name}`);
90
101
  }
91
102
  continue;
92
103
  }
104
+ // Timeout: don't let a slow server block startup
105
+ const connectPromise = connectStdio(name, serverConfig);
106
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('connection timeout (5s)')), MCP_CONNECT_TIMEOUT));
107
+ const connected = await Promise.race([connectPromise, timeoutPromise]);
93
108
  allTools.push(...connected.tools);
94
109
  if (debug) {
95
110
  console.error(`[runcode] MCP ${name}: ${connected.tools.length} tools discovered`);
@@ -5,10 +5,6 @@
5
5
  * 2. Project: .mcp.json in working directory
6
6
  */
7
7
  import type { McpConfig, McpServerConfig } from './client.js';
8
- /**
9
- * Load MCP server configurations from global + project files.
10
- * Project config overrides global for same server name.
11
- */
12
8
  export declare function loadMcpConfig(workDir: string): McpConfig;
13
9
  /**
14
10
  * Save a server config to the global MCP config.
@@ -18,3 +14,7 @@ export declare function saveMcpServer(name: string, config: McpServerConfig): vo
18
14
  * Remove a server from the global MCP config.
19
15
  */
20
16
  export declare function removeMcpServer(name: string): boolean;
17
+ /**
18
+ * Trust a project directory to load its .mcp.json.
19
+ */
20
+ export declare function trustProjectDir(workDir: string): void;
@@ -12,8 +12,18 @@ const GLOBAL_MCP_FILE = path.join(BLOCKRUN_DIR, 'mcp.json');
12
12
  * Load MCP server configurations from global + project files.
13
13
  * Project config overrides global for same server name.
14
14
  */
15
+ // Built-in MCP server: @blockrun/mcp is always available (zero config)
16
+ const BUILTIN_MCP_SERVERS = {
17
+ blockrun: {
18
+ transport: 'stdio',
19
+ command: 'npx',
20
+ args: ['-y', '@blockrun/mcp'],
21
+ label: 'BlockRun (built-in)',
22
+ },
23
+ };
15
24
  export function loadMcpConfig(workDir) {
16
- const servers = {};
25
+ // Start with built-in servers
26
+ const servers = { ...BUILTIN_MCP_SERVERS };
17
27
  // 1. Global config
18
28
  try {
19
29
  if (fs.existsSync(GLOBAL_MCP_FILE)) {
@@ -27,14 +37,28 @@ export function loadMcpConfig(workDir) {
27
37
  // Ignore corrupt global config
28
38
  }
29
39
  // 2. Project config (.mcp.json in working directory)
40
+ // Security: project configs can execute arbitrary commands via stdio transport.
41
+ // Only load if a trust marker exists (user has explicitly opted in).
30
42
  const projectMcpFile = path.join(workDir, '.mcp.json');
43
+ const trustMarker = path.join(BLOCKRUN_DIR, 'trusted-projects.json');
31
44
  try {
32
45
  if (fs.existsSync(projectMcpFile)) {
33
- const raw = JSON.parse(fs.readFileSync(projectMcpFile, 'utf-8'));
34
- if (raw.mcpServers && typeof raw.mcpServers === 'object') {
35
- // Project overrides global for same name
36
- Object.assign(servers, raw.mcpServers);
46
+ // Check if this project directory is trusted
47
+ let trusted = false;
48
+ try {
49
+ if (fs.existsSync(trustMarker)) {
50
+ const trustedDirs = JSON.parse(fs.readFileSync(trustMarker, 'utf-8'));
51
+ trusted = Array.isArray(trustedDirs) && trustedDirs.includes(workDir);
52
+ }
53
+ }
54
+ catch { /* not trusted */ }
55
+ if (trusted) {
56
+ const raw = JSON.parse(fs.readFileSync(projectMcpFile, 'utf-8'));
57
+ if (raw.mcpServers && typeof raw.mcpServers === 'object') {
58
+ Object.assign(servers, raw.mcpServers);
59
+ }
37
60
  }
61
+ // If not trusted, silently skip project config (user must run /mcp trust)
38
62
  }
39
63
  }
40
64
  catch {
@@ -62,6 +86,24 @@ export function removeMcpServer(name) {
62
86
  fs.writeFileSync(GLOBAL_MCP_FILE, JSON.stringify(existing, null, 2) + '\n');
63
87
  return true;
64
88
  }
89
+ /**
90
+ * Trust a project directory to load its .mcp.json.
91
+ */
92
+ export function trustProjectDir(workDir) {
93
+ const trustMarker = path.join(BLOCKRUN_DIR, 'trusted-projects.json');
94
+ let trusted = [];
95
+ try {
96
+ if (fs.existsSync(trustMarker)) {
97
+ trusted = JSON.parse(fs.readFileSync(trustMarker, 'utf-8'));
98
+ }
99
+ }
100
+ catch { /* fresh */ }
101
+ if (!trusted.includes(workDir)) {
102
+ trusted.push(workDir);
103
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
104
+ fs.writeFileSync(trustMarker, JSON.stringify(trusted, null, 2));
105
+ }
106
+ }
65
107
  function loadGlobalMcpConfig() {
66
108
  try {
67
109
  if (fs.existsSync(GLOBAL_MCP_FILE)) {
@@ -39,4 +39,8 @@ export declare function listSessions(): SessionMeta[];
39
39
  /**
40
40
  * Prune old sessions beyond MAX_SESSIONS.
41
41
  */
42
- export declare function pruneOldSessions(): void;
42
+ /**
43
+ * Prune old sessions beyond MAX_SESSIONS.
44
+ * Accepts optional activeSessionId to protect from deletion.
45
+ */
46
+ export declare function pruneOldSessions(activeSessionId?: string): void;
@@ -67,7 +67,17 @@ export function loadSessionHistory(sessionId) {
67
67
  try {
68
68
  const content = fs.readFileSync(sessionPath(sessionId), 'utf-8');
69
69
  const lines = content.trim().split('\n').filter(Boolean);
70
- return lines.map(line => JSON.parse(line));
70
+ const results = [];
71
+ for (const line of lines) {
72
+ try {
73
+ results.push(JSON.parse(line));
74
+ }
75
+ catch {
76
+ // Skip corrupted lines — partial writes from crashes
77
+ continue;
78
+ }
79
+ }
80
+ return results;
71
81
  }
72
82
  catch {
73
83
  return [];
@@ -98,11 +108,17 @@ export function listSessions() {
98
108
  /**
99
109
  * Prune old sessions beyond MAX_SESSIONS.
100
110
  */
101
- export function pruneOldSessions() {
111
+ /**
112
+ * Prune old sessions beyond MAX_SESSIONS.
113
+ * Accepts optional activeSessionId to protect from deletion.
114
+ */
115
+ export function pruneOldSessions(activeSessionId) {
102
116
  const sessions = listSessions();
103
117
  if (sessions.length <= MAX_SESSIONS)
104
118
  return;
105
- const toDelete = sessions.slice(MAX_SESSIONS);
119
+ const toDelete = sessions
120
+ .slice(MAX_SESSIONS)
121
+ .filter(s => s.id !== activeSessionId); // Never delete active session
106
122
  for (const s of toDelete) {
107
123
  try {
108
124
  fs.unlinkSync(sessionPath(s.id));
@@ -70,8 +70,11 @@ async function execute(input, ctx) {
70
70
  fs.writeFileSync(outPath, buffer);
71
71
  }
72
72
  else if (imageData.url) {
73
- // Download from URL
74
- const imgResp = await fetch(imageData.url);
73
+ // Download from URL (with 30s timeout)
74
+ const dlCtrl = new AbortController();
75
+ const dlTimeout = setTimeout(() => dlCtrl.abort(), 30_000);
76
+ const imgResp = await fetch(imageData.url, { signal: dlCtrl.signal });
77
+ clearTimeout(dlTimeout);
75
78
  const buffer = Buffer.from(await imgResp.arrayBuffer());
76
79
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
77
80
  fs.writeFileSync(outPath, buffer);
@@ -40,6 +40,16 @@ async function execute(input, ctx) {
40
40
  }
41
41
  }
42
42
  catch { /* parent doesn't exist yet, will be created */ }
43
+ // Also check if target file itself is a symlink to a sensitive location
44
+ try {
45
+ if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) {
46
+ const realTarget = fs.realpathSync(resolved);
47
+ if (checkPath(realTarget)) {
48
+ return { output: `Error: refusing to write — symlink resolves to sensitive location: ${realTarget}`, isError: true };
49
+ }
50
+ }
51
+ }
52
+ catch { /* file doesn't exist yet, ok */ }
43
53
  try {
44
54
  // Ensure parent directory exists
45
55
  const parentDir = path.dirname(resolved);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/runcode",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "RunCode — AI coding agent powered by 41+ models. Pay per use with USDC.",
5
5
  "type": "module",
6
6
  "bin": {