@nexrall/code-core 1.1.0 → 1.3.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.
@@ -1,3 +1,18 @@
1
1
  import type { Message, AgentLoopOptions } from '../types';
2
+ export declare function resolveMaxIterations(optionValue: number | undefined, settingsRaw: Record<string, unknown>): number;
3
+ /**
4
+ * Find the latest index ≤ maxIdx where history can be cut safely.
5
+ *
6
+ * A safe cut point is a **turn boundary**: an assistant message (which always
7
+ * begins a fresh turn after a user message). Cutting there guarantees that
8
+ * `messages[cut..]` starts with an assistant whose `tool_use` blocks are all
9
+ * answered by `tool_result`s that remain in the kept slice — so we never orphan
10
+ * a tool_result (which the API rejects). We deliberately allow cutting across
11
+ * tool_result-bearing user messages: the OLD implementation only cut at a
12
+ * *non*-tool_result user message, which never exists inside a single long
13
+ * agentic run (every user turn is a tool_result), so compaction was a no-op
14
+ * exactly when a long task needs it most.
15
+ */
16
+ export declare function findSafeCutIndex(messages: Message[], maxIdx: number): number;
2
17
  export declare function runAgentLoop(initialMessages: Message[], options: AgentLoopOptions): Promise<Message[]>;
3
18
  //# sourceMappingURL=loop.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,OAAO,EAMP,gBAAgB,EACjB,MAAM,UAAU,CAAC;AAuflB,wBAAsB,YAAY,CAChC,eAAe,EAAE,OAAO,EAAE,EAC1B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,OAAO,EAAE,CAAC,CAuWpB"}
1
+ {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,OAAO,EAMP,gBAAgB,EACjB,MAAM,UAAU,CAAC;AAmKlB,wBAAgB,oBAAoB,CAClC,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAWR;AAgRD;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAK5E;AA6ED,wBAAsB,YAAY,CAChC,eAAe,EAAE,OAAO,EAAE,EAC1B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,OAAO,EAAE,CAAC,CA+WpB"}
@@ -33,6 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.resolveMaxIterations = resolveMaxIterations;
37
+ exports.findSafeCutIndex = findSafeCutIndex;
36
38
  exports.runAgentLoop = runAgentLoop;
37
39
  const client_1 = require("../api/client");
38
40
  const executor_1 = require("../tools/executor");
@@ -89,13 +91,20 @@ function runToolHooks(entries, phase, toolName, input, workDir, result) {
89
91
  for (const hook of entry.hooks ?? []) {
90
92
  if (hook.type !== 'command' || !hook.command)
91
93
  continue;
94
+ // Per-hook timeout. Default 60s (was a hard 10s that made the canonical
95
+ // "auto-run tests on PostToolUse" use-case useless — any real suite is
96
+ // slower). Configurable via `timeout_ms` on the hook, capped at 10min.
97
+ const hookTimeout = typeof hook.timeout_ms === 'number' && hook.timeout_ms > 0
98
+ ? Math.min(hook.timeout_ms, 600000)
99
+ : 60000;
92
100
  let r;
93
101
  try {
94
102
  r = (0, child_process_1.spawnSync)(hook.command, {
95
103
  shell: true,
96
104
  cwd: workDir,
97
- timeout: 10000,
105
+ timeout: hookTimeout,
98
106
  encoding: 'utf-8',
107
+ maxBuffer: 16 * 1024 * 1024,
99
108
  input: payload,
100
109
  env: {
101
110
  ...process.env,
@@ -152,18 +161,29 @@ function runSimpleHooks(defs, workDir) {
152
161
  // fix → …). A too-low cap makes the agent appear to "freeze" mid-task. Keep the
153
162
  // default high and let projects raise it further via settings / env.
154
163
  const DEFAULT_MAX_ITERATIONS = 500;
155
- const MAX_ITERATIONS_CEILING = 2000; // absolute backstop — auto-continue never goes past this
164
+ const MAX_ITERATIONS_CEILING = 2000; // default auto-continue backstop (no explicit opt-in)
165
+ const HARD_ITERATIONS_CAP = 100000; // absolute safety cap — even explicit opt-in can't exceed this
156
166
  const STALL_LIMIT = 8; // consecutive all-failed tool rounds → give up (runaway guard)
157
167
  // Resolve the soft iteration budget. Precedence:
158
168
  // options.maxIterations → env NEXRALL_MAX_ITERATIONS → settings.maxIterations → default
169
+ //
170
+ // The DEFAULT (nothing set) is clamped to MAX_ITERATIONS_CEILING so an ordinary
171
+ // run can never spin past 2000 rounds by accident. But an EXPLICIT value from
172
+ // any of the three opt-in channels is honoured up to HARD_ITERATIONS_CAP — this
173
+ // is what lets a genuinely long task (thousands of rounds) run when the user has
174
+ // deliberately asked for it, instead of dying at a hidden 2000 wall while the
175
+ // error message misleadingly tells them to "raise the limit".
159
176
  function resolveMaxIterations(optionValue, settingsRaw) {
160
177
  const fromEnv = Number(process.env.NEXRALL_MAX_ITERATIONS);
161
178
  const fromSettings = Number(settingsRaw.maxIterations);
162
- const candidate = (typeof optionValue === 'number' && optionValue > 0) ? optionValue
179
+ const explicit = (typeof optionValue === 'number' && optionValue > 0) ? optionValue
163
180
  : Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv
164
181
  : Number.isFinite(fromSettings) && fromSettings > 0 ? fromSettings
165
- : DEFAULT_MAX_ITERATIONS;
166
- return Math.min(Math.floor(candidate), MAX_ITERATIONS_CEILING);
182
+ : undefined;
183
+ if (explicit === undefined)
184
+ return DEFAULT_MAX_ITERATIONS;
185
+ // Explicit opt-in: honour it, only bounded by the absolute safety cap.
186
+ return Math.min(Math.floor(explicit), HARD_ITERATIONS_CAP);
167
187
  }
168
188
  // When the soft budget is exhausted with work still pending, keep going instead
169
189
  // of stopping. Precedence: options.autoContinue → env NEXRALL_AUTO_CONTINUE →
@@ -342,11 +362,20 @@ async function runSubTask(input, options, agentTypes) {
342
362
  });
343
363
  // Extract final assistant text as the task output
344
364
  const lastAssistant = [...result].reverse().find((m) => m.role === 'assistant');
345
- const text = (lastAssistant?.content ?? [])
365
+ let text = (lastAssistant?.content ?? [])
346
366
  .filter((b) => b.type === 'text')
347
367
  .map((b) => b.text)
348
368
  .join('')
349
369
  .trim();
370
+ // Cap the sub-agent's returned text so a verbose sub-task (e.g. "list every
371
+ // usage") can't blow up the PARENT's context in a single tool_result. Keep
372
+ // the head + tail — the conclusion is usually at the end.
373
+ const SUBTASK_MAX = 48000; // chars (~12k tokens)
374
+ if (text.length > SUBTASK_MAX) {
375
+ const head = text.slice(0, Math.floor(SUBTASK_MAX * 0.6));
376
+ const tail = text.slice(text.length - Math.floor(SUBTASK_MAX * 0.4));
377
+ text = `${head}\n\n[… sub-task output truncated (${text.length} chars) — kept the beginning and end …]\n\n${tail}`;
378
+ }
350
379
  return { output: text || '(sub-task completed with no text output)' };
351
380
  }
352
381
  catch (err) {
@@ -382,14 +411,22 @@ function resolveAutoCompact(fromOptions, rawSettings) {
382
411
  return s;
383
412
  return true;
384
413
  }
385
- /** Find the latest index ≤ maxIdx where history can be cut safely (user msg, no tool_results). */
414
+ /**
415
+ * Find the latest index ≤ maxIdx where history can be cut safely.
416
+ *
417
+ * A safe cut point is a **turn boundary**: an assistant message (which always
418
+ * begins a fresh turn after a user message). Cutting there guarantees that
419
+ * `messages[cut..]` starts with an assistant whose `tool_use` blocks are all
420
+ * answered by `tool_result`s that remain in the kept slice — so we never orphan
421
+ * a tool_result (which the API rejects). We deliberately allow cutting across
422
+ * tool_result-bearing user messages: the OLD implementation only cut at a
423
+ * *non*-tool_result user message, which never exists inside a single long
424
+ * agentic run (every user turn is a tool_result), so compaction was a no-op
425
+ * exactly when a long task needs it most.
426
+ */
386
427
  function findSafeCutIndex(messages, maxIdx) {
387
- for (let i = Math.min(maxIdx, messages.length - 1); i > 0; i--) {
388
- const m = messages[i];
389
- if (m.role !== 'user')
390
- continue;
391
- const hasToolResult = m.content.some((b) => b.type === 'tool_result');
392
- if (!hasToolResult)
428
+ for (let i = Math.min(maxIdx, messages.length - 1); i >= 2; i--) {
429
+ if (messages[i].role === 'assistant')
393
430
  return i;
394
431
  }
395
432
  return -1;
@@ -445,7 +482,13 @@ async function autoCompactMessages(messages, options) {
445
482
  }
446
483
  if (!summary)
447
484
  return false;
448
- messages.splice(0, cut, { role: 'user', content: [{ type: 'text', text: `[Auto-compacted ${toSummarize.length} earlier messages]\n\nSummary of the earlier conversation:\n${summary}` }] }, { role: 'assistant', content: [{ type: 'text', text: 'Understood — I have the summary of our earlier work and will continue from there.' }] });
485
+ // Replace the summarized head with a single user summary message. The cut is
486
+ // at a turn boundary (kept[0] is an assistant message — see findSafeCutIndex),
487
+ // so `user(summary) → assistant(kept[0])` is a valid, well-ordered sequence
488
+ // and no orphaned tool_result is left behind. We intentionally do NOT insert
489
+ // an assistant-ack here: that would put two assistant messages back-to-back
490
+ // (kept[0] is already an assistant), which the API rejects.
491
+ messages.splice(0, cut, { role: 'user', content: [{ type: 'text', text: `[Auto-compacted ${toSummarize.length} earlier messages]\n\nSummary of the earlier conversation so far:\n${summary}\n\nContinue the work from here.` }] });
449
492
  // `kept` follows automatically since splice only replaced the head.
450
493
  void kept;
451
494
  return true;
@@ -710,8 +753,11 @@ async function runAgentLoop(initialMessages, options) {
710
753
  const toolResultBlocks = toolResults.map(({ block, result }) => ({
711
754
  type: 'tool_result',
712
755
  tool_use_id: block.id,
756
+ // When a tool returns BOTH an error and partial output (e.g. a bash
757
+ // command that timed out mid-test-run), surface both so the model can
758
+ // see WHY it hung — the failure detail is usually in the output tail.
713
759
  content: result.error
714
- ? `Error: ${result.error}`
760
+ ? (result.output ? `Error: ${result.error}\n\n${result.output}` : `Error: ${result.error}`)
715
761
  : result.output ?? '',
716
762
  is_error: result.error !== undefined,
717
763
  }));
@@ -731,6 +777,10 @@ async function runAgentLoop(initialMessages, options) {
731
777
  content: toolResultContent,
732
778
  };
733
779
  messages.push(toolResultMessage);
780
+ // Incremental persistence hook: history is valid here (ends on a
781
+ // tool_result user turn). Let the caller checkpoint progress so a crash
782
+ // mid-run loses only the in-flight step, not the whole session.
783
+ options.onProgress?.(messages);
734
784
  // Runaway guard: if every tool call in this round failed, count it. Enough
735
785
  // consecutive all-failed rounds (e.g. a command that always errors, or the
736
786
  // user denying every permission) means we're stuck — stop instead of
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAA8B,UAAU,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAKrH,eAAO,MAAM,QAAQ,4BAA4B,CAAC;AAiBlD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;IACnC,oFAAoF;IACpF,UAAU,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IACjG,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAWD,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,OAAO,EAAE,EACnB,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,CAAC,CAAC,EAAE,QAAQ,KAAK,IAAI,GAC7B,OAAO,CAAC,OAAO,CAAC,CAkVlB;AAID,wBAAsB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CAalD;AAID,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAgB1E;AAID,wBAAsB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAkBhF"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAA8B,UAAU,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAKrH,eAAO,MAAM,QAAQ,4BAA4B,CAAC;AAiBlD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,UAAU,CAAC;IACjB,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;IACnC,oFAAoF;IACpF,UAAU,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IACjG,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAWD,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,OAAO,EAAE,EACnB,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,CAAC,CAAC,EAAE,QAAQ,KAAK,IAAI,GAC7B,OAAO,CAAC,OAAO,CAAC,CA0WlB;AAID,wBAAsB,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CAalD;AAID,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAgB1E;AAID,wBAAsB,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAkBhF"}
@@ -316,7 +316,19 @@ async function streamChat(messages, options, onEvent) {
316
316
  }
317
317
  parser.feed(chunk.toString('utf-8'));
318
318
  });
319
- stream.on('end', () => { clearInterval(heartbeatWatchdog); resolve(); });
319
+ stream.on('end', () => {
320
+ clearInterval(heartbeatWatchdog);
321
+ // A clean 'end' with ZERO model events means the connection was dropped before
322
+ // the backend produced anything (deploy restart, proxy reset). Without this,
323
+ // the fallback below returns an EMPTY assistant message and the agent loop
324
+ // treats it as a silent no-op — the turn just vanishes. Nothing was emitted,
325
+ // so a retry cannot duplicate output → tag retryable.
326
+ if (!sawModelEvent && !completedMessage) {
327
+ reject(Object.assign(new Error('Connection closed before the model responded. Retrying…'), { retryable: true }));
328
+ return;
329
+ }
330
+ resolve();
331
+ });
320
332
  // Treat stream destruction from abort as a clean exit, not an error
321
333
  stream.on('error', (err) => {
322
334
  clearInterval(heartbeatWatchdog);
@@ -324,6 +336,15 @@ async function streamChat(messages, options, onEvent) {
324
336
  resolve();
325
337
  return;
326
338
  }
339
+ // Transport-level failures ("Premature close" / ECONNRESET / socket hang up)
340
+ // happen whenever the backend restarts mid-deploy or a proxy drops the socket.
341
+ // They are exactly as transient as an overloaded_error — if nothing has been
342
+ // emitted to the caller yet, retry the attempt transparently instead of
343
+ // surfacing "Stream failed: Premature close" and killing the whole turn.
344
+ if (!emittedToCaller) {
345
+ reject(Object.assign(err, { retryable: true }));
346
+ return;
347
+ }
327
348
  reject(err);
328
349
  });
329
350
  });
@@ -1 +1 @@
1
- {"version":3,"file":"rules.d.ts","sourceRoot":"","sources":["../../src/permissions/rules.ts"],"names":[],"mappings":"AAwBA,MAAM,MAAM,kBAAkB,GAAG,OAAO,GAAG,KAAK,GAAG,MAAM,CAAC;AAE1D,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,GAAG,EAAE,MAAM,EAAE,CAAC;IACd,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,eAAe,CAAC;IAC7B,yDAAyD;IACzD,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC9B;AAsBD,oEAAoE;AACpE,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,cAAc,CAoB5D;AAqHD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,eAAe,EACtB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,OAAO,EAAE,MAAM,GACd,kBAAkB,GAAG,IAAI,CAK3B"}
1
+ {"version":3,"file":"rules.d.ts","sourceRoot":"","sources":["../../src/permissions/rules.ts"],"names":[],"mappings":"AAwBA,MAAM,MAAM,kBAAkB,GAAG,OAAO,GAAG,KAAK,GAAG,MAAM,CAAC;AAE1D,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,GAAG,EAAE,MAAM,EAAE,CAAC;IACd,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,eAAe,CAAC;IAC7B,yDAAyD;IACzD,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC9B;AAsBD,oEAAoE;AACpE,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,cAAc,CAoB5D;AAgID;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,eAAe,EACtB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,OAAO,EAAE,MAAM,GACd,kBAAkB,GAAG,IAAI,CAK3B"}
@@ -118,7 +118,17 @@ function matchPattern(pattern, value) {
118
118
  // "name:*" → prefix match (Claude Bash semantics)
119
119
  if (pattern.endsWith(':*')) {
120
120
  const prefix = pattern.slice(0, -2);
121
- return value === prefix || value.startsWith(prefix);
121
+ if (value === prefix)
122
+ return true;
123
+ // Token-boundary prefix: "npm run test:*" must match "npm run test foo"
124
+ // but NOT "npm run testify-malware". The char right after the prefix has
125
+ // to be a shell token boundary (whitespace / end), otherwise it's a
126
+ // different command that merely shares a textual prefix.
127
+ if (value.startsWith(prefix)) {
128
+ const next = value.charAt(prefix.length);
129
+ return next === '' || /\s/.test(next);
130
+ }
131
+ return false;
122
132
  }
123
133
  if (pattern.includes('*') || pattern.includes('?')) {
124
134
  try {
@@ -186,15 +196,17 @@ function ruleMatches(rule, tool, input, workDir, opts) {
186
196
  (aliases[ruleTool]?.includes(tool) ?? false);
187
197
  if (!matchesTool)
188
198
  return false;
189
- // Multi-line guard for ALLOW rules on bash: a patterned allow rule
190
- // (prefix or glob) must never auto-approve a multi-line command a newline
191
- // acts like `;`, so "npm run test\nrm -rf ~" would otherwise ride an
199
+ // Chaining guard for ALLOW rules on bash: a patterned allow rule (prefix or
200
+ // glob) must never auto-approve a command that chains a SECOND command onto
201
+ // the matched one. A newline acts like `;`, and `;`, `&&`, `||`, `|`, `&`,
202
+ // `$(...)`, `` `...` `` all let "npm run test; rm -rf ~" ride an
192
203
  // "Bash(npm run test:*)" allow rule straight past the user prompt.
193
- // Deny rules stay aggressive (they SHOULD match multi-line commands), and a
204
+ // Deny rules stay aggressive (they SHOULD match chained commands), and a
194
205
  // bare "Bash" allow (pattern === null) is an explicit "allow everything".
195
206
  if (opts?.forAllow && tool === 'bash' && pattern !== null && pattern !== '' && pattern !== '*') {
196
207
  const cmd = typeof input.command === 'string' ? input.command : '';
197
- if (/[\r\n]/.test(cmd))
208
+ // newline / ; / && / || / | / trailing & / command-substitution / backtick
209
+ if (/[\r\n;`]|&&|\|\||\||&\s*$|\$\(/.test(cmd))
198
210
  return false;
199
211
  }
200
212
  if (pattern === null || pattern === '' || pattern === '*')
@@ -33,6 +33,28 @@ export interface InstallResult {
33
33
  scope: 'project' | 'global';
34
34
  inspection: PluginInspection;
35
35
  }
36
+ export interface RegistryPlugin {
37
+ name: string;
38
+ display_name: string;
39
+ description: string;
40
+ source: string;
41
+ author: string;
42
+ homepage?: string;
43
+ keywords: string[];
44
+ has_commands: boolean;
45
+ has_agents: boolean;
46
+ has_hooks: boolean;
47
+ has_mcp: boolean;
48
+ verified: boolean;
49
+ official: boolean;
50
+ install_count: number;
51
+ }
52
+ /** Search the registry (empty query = list all, official first). */
53
+ export declare function searchRegistry(query?: string, limit?: number): Promise<RegistryPlugin[]>;
54
+ /** Fetch one plugin's registry entry, or null when it isn't listed. */
55
+ export declare function getRegistryPlugin(name: string): Promise<RegistryPlugin | null>;
56
+ /** Fire-and-forget install counter. Never throws, never blocks an install. */
57
+ export declare function reportInstall(name: string): void;
36
58
  /** Parse a user-supplied plugin spec into a structured source. Throws on junk. */
37
59
  export declare function parsePluginSource(spec: string): PluginSource;
38
60
  /** What does this plugin contain? Callers must warn on hasHooks/hasMcp. */
@@ -1 +1 @@
1
- {"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../../src/plugins/installer.ts"],"names":[],"mappings":"AA4BA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC;IACzB,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,4CAA4C;IAC5C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+DAA+D;IAC/D,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACjE;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,SAAS,GAAG,QAAQ,CAAC;IAC5B,UAAU,EAAE,gBAAgB,CAAC;CAC9B;AAMD,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CA+C5D;AAYD,2EAA2E;AAC3E,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,gBAAgB,CAiB9D;AAyDD,MAAM,WAAW,cAAc;IAC7B,sFAAsF;IACtF,KAAK,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kGAAkG;IAClG,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,qDAAqD;IACrD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,UAAU,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC;CACtF;AAQD,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,cAAmB,GAAG,OAAO,CAAC,aAAa,CAAC,CA+EnG;AAED,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CAOpE;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,MAAM,CAYhH;AAED,wBAAsB,YAAY,CAChC,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,cAAc,CAAC,SAAS,CAAC,CAAA;CAAO,GACjG,OAAO,CAAC,aAAa,CAAC,CAexB"}
1
+ {"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../../src/plugins/installer.ts"],"names":[],"mappings":"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"}
@@ -33,6 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.searchRegistry = searchRegistry;
37
+ exports.getRegistryPlugin = getRegistryPlugin;
38
+ exports.reportInstall = reportInstall;
36
39
  exports.parsePluginSource = parsePluginSource;
37
40
  exports.inspectPluginDir = inspectPluginDir;
38
41
  exports.installPlugin = installPlugin;
@@ -43,7 +46,39 @@ const fs = __importStar(require("fs"));
43
46
  const path = __importStar(require("path"));
44
47
  const os = __importStar(require("os"));
45
48
  const child_process_1 = require("child_process");
49
+ const client_1 = require("../api/client");
46
50
  const RECEIPT_FILE = '_install.json';
51
+ /** Search the registry (empty query = list all, official first). */
52
+ async function searchRegistry(query, limit = 50) {
53
+ const u = new URL(`${client_1.API_BASE}/api/code/plugins`);
54
+ if (query)
55
+ u.searchParams.set('q', query);
56
+ u.searchParams.set('limit', String(limit));
57
+ const res = await fetch(u, { headers: { Accept: 'application/json' } });
58
+ if (!res.ok)
59
+ throw new Error(`Registry unavailable (${res.status}).`);
60
+ const j = (await res.json());
61
+ return j.plugins ?? [];
62
+ }
63
+ /** Fetch one plugin's registry entry, or null when it isn't listed. */
64
+ async function getRegistryPlugin(name) {
65
+ if (!/^[\w.-]{1,64}$/.test(name))
66
+ return null;
67
+ const res = await fetch(`${client_1.API_BASE}/api/code/plugins/${encodeURIComponent(name)}`, {
68
+ headers: { Accept: 'application/json' },
69
+ });
70
+ if (res.status === 404)
71
+ return null;
72
+ if (!res.ok)
73
+ throw new Error(`Registry unavailable (${res.status}).`);
74
+ const j = (await res.json());
75
+ return j.plugin ?? null;
76
+ }
77
+ /** Fire-and-forget install counter. Never throws, never blocks an install. */
78
+ function reportInstall(name) {
79
+ fetch(`${client_1.API_BASE}/api/code/plugins/${encodeURIComponent(name)}/installed`, { method: 'POST' })
80
+ .catch(() => { });
81
+ }
47
82
  // ─── Source parsing ───────────────────────────────────────────────────────────
48
83
  /** Parse a user-supplied plugin spec into a structured source. Throws on junk. */
49
84
  function parsePluginSource(spec) {
@@ -1 +1 @@
1
- {"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../src/tools/executor.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AA46CtE,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,EAClC,OAAO,CAAC,EAAE,aAAa,EACvB,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,UAAU,CAAC,CAiBrB"}
1
+ {"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../src/tools/executor.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AA6qDtE,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,EAClC,OAAO,CAAC,EAAE,aAAa,EACvB,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,UAAU,CAAC,CAiBrB"}
@@ -39,6 +39,7 @@ const path = __importStar(require("path"));
39
39
  const os = __importStar(require("os"));
40
40
  const https = __importStar(require("https"));
41
41
  const http = __importStar(require("http"));
42
+ const dns = __importStar(require("dns"));
42
43
  const child_process_1 = require("child_process");
43
44
  const sandbox_1 = require("./sandbox");
44
45
  const auth_1 = require("../auth");
@@ -69,15 +70,31 @@ const BLOCKED_REGEXES = [
69
70
  /dd\s+if=\/dev\/(zero|random|urandom)/,
70
71
  // Fork bomb
71
72
  /:\(\)\s*\{.*:\|:&/,
72
- // Pipe execution from network. [^|\n]* between the fetcher and the pipe
73
- // covers the URL/flags ("curl https://x.sh | sh"), which the old \s*-only
74
- // version missed entirely; no-space variants (curl|bash) are still caught.
75
- /curl[^|\n]*\|\s*(bash|sh|python\d*|node|perl|ruby)\b/,
76
- /wget[^|\n]*\|\s*(bash|sh|python\d*|node|perl|ruby)\b/,
77
- /curl\s+.*-[oO]\s*-.*\|\s*(bash|sh)/, // curl -o - … | bash
78
73
  // Credential theft
79
74
  /cat\s+\/etc\/(shadow|passwd)/,
80
75
  ];
76
+ // Network-pipe rules (curl … | sh) get their own tier. Testing them against the
77
+ // FULL string caused constant false positives: the pattern showing up as DATA —
78
+ // a heredoc'd README (`cat > README.md <<EOF … install.sh | sh … EOF`), a
79
+ // commit message (`git commit -m "docs: explain curl … | sh risk"`), an echo —
80
+ // is never executed by this bash invocation. So these run against the command
81
+ // with heredoc bodies AND quoted strings stripped (stripDataSections).
82
+ // The one real execution path that lives INSIDE quotes — `bash -c "curl … |
83
+ // sh"` — is covered by the dedicated SHELL_DASH_C rule below, which only fires
84
+ // when the quoted pipe is the argument of an interpreter's -c flag.
85
+ // [^|\n]* between the fetcher and the pipe covers the URL/flags ("curl
86
+ // https://x.sh | sh"); no-space variants (curl|bash) are still caught.
87
+ // python is exempted for `-m json.tool` (harmless pretty-printer: the
88
+ // downloaded bytes are parsed as JSON, never executed).
89
+ const NETWORK_PIPE_REGEXES = [
90
+ /curl[^|\n]*\|\s*(bash|sh|python\d*(?!\s+-m\s+json\.tool)|node|perl|ruby)\b/,
91
+ /wget[^|\n]*\|\s*(bash|sh|python\d*(?!\s+-m\s+json\.tool)|node|perl|ruby)\b/,
92
+ /curl\s+.*-[oO]\s*-.*\|\s*(bash|sh)\b/, // curl -o - … | bash
93
+ ];
94
+ // `bash -c "curl … | sh"` / `sh -c 'wget … | bash'` — quoted, but executed for
95
+ // real. Matched at a command position on the raw (heredoc-stripped) string so
96
+ // the quotes are still visible to the regex.
97
+ const SHELL_DASH_C_PIPE = /(?:^|[;&|`\n(]|\$\(|\bsudo\s+|\bexec\s+)\s*(?:bash|sh|zsh|dash)\s+(?:-\w+\s+)*-c\s+(["'])[^"']*(?:curl|wget)[^"']*\|[^"']*\1?/;
81
98
  // Tier 2 — words that are dangerous only when INVOKED as a command. A plain
82
99
  // \b-word match caused constant false positives once heredocs were allowed:
83
100
  // `grep reboot README.md`, `git log --grep shutdown`, or a heredoc body that
@@ -90,11 +107,13 @@ const BLOCKED_COMMAND_WORDS = /(?:^|[;&|`\n(]|\$\(|\bsudo\s+|\bdoas\s+|\bexec\s+
90
107
  // Heredoc bodies first (<<TAG / <<-TAG / <<'TAG' / <<"TAG" … up to the line
91
108
  // that equals TAG), then double- and single-quoted strings. Intentionally
92
109
  // naive — this feeds a blocklist heuristic, not an executor.
110
+ function stripHeredocs(command) {
111
+ // Heredoc: the (['"]?) quote group always participates, so the \2 tag
112
+ // backreference is safe (JS backrefs to non-participating groups match "").
113
+ return command.replace(/<<-?\s*(['"]?)(\w+)\1[\s\S]*?\n\2(?=\s|;|&|$)/g, ' ');
114
+ }
93
115
  function stripDataSections(command) {
94
- return command
95
- // Heredoc: the (['"]?) quote group always participates, so the \2 tag
96
- // backreference is safe (JS backrefs to non-participating groups match "").
97
- .replace(/<<-?\s*(['"]?)(\w+)\1[\s\S]*?\n\2(?=\s|;|&|$)/g, ' ')
116
+ return stripHeredocs(command)
98
117
  .replace(/"(?:[^"\\]|\\[\s\S])*"/g, '""')
99
118
  .replace(/'[^']*'/g, "''");
100
119
  }
@@ -104,6 +123,19 @@ function isCommandBlocked(command) {
104
123
  if (re.test(lower))
105
124
  return re.toString();
106
125
  }
126
+ // Network-pipe rules see the command with data sections (heredoc bodies,
127
+ // quoted strings) removed — mentions in docs/commit messages are data, not
128
+ // something this invocation executes.
129
+ const noData = stripDataSections(lower);
130
+ for (const re of NETWORK_PIPE_REGEXES) {
131
+ if (re.test(noData))
132
+ return re.toString();
133
+ }
134
+ // …but an interpreter -c argument IS executed: check it on the raw string
135
+ // (heredoc-stripped only, quotes intact).
136
+ if (SHELL_DASH_C_PIPE.test(stripHeredocs(lower))) {
137
+ return SHELL_DASH_C_PIPE.toString();
138
+ }
107
139
  if (BLOCKED_COMMAND_WORDS.test(stripDataSections(lower))) {
108
140
  return BLOCKED_COMMAND_WORDS.toString();
109
141
  }
@@ -297,21 +329,26 @@ const _bgShells = new Map();
297
329
  let _bgCounter = 0;
298
330
  function startBackgroundShell(command, displayCommand, workDir, note) {
299
331
  const id = `bg_${++_bgCounter}`;
300
- const child = (0, child_process_1.spawn)(command, { shell: true, cwd: workDir ?? process.cwd(), env: process.env });
332
+ // detached:true own process group so kill_shell can signal the whole tree
333
+ // (a background dev server usually spawns children that must die with it).
334
+ const child = (0, child_process_1.spawn)(command, { shell: true, cwd: workDir ?? process.cwd(), env: process.env, detached: true });
301
335
  const shell = {
302
336
  id, command: displayCommand, child, output: '', readCursor: 0, truncated: false,
303
337
  status: 'running', exitCode: null,
304
338
  };
339
+ // Rolling buffer: keep the LAST MAX_OUTPUT_CHARS instead of the first, so a
340
+ // long-lived watcher/dev-server's recent output (the useful part) is never
341
+ // permanently lost behind an early cap.
305
342
  const append = (chunk) => {
306
- if (shell.truncated)
307
- return;
308
- const remaining = MAX_OUTPUT_CHARS - shell.output.length;
309
- if (chunk.length >= remaining) {
310
- shell.output += chunk.slice(0, remaining);
343
+ shell.output += chunk;
344
+ if (shell.output.length > MAX_OUTPUT_CHARS) {
345
+ // Drop from the front but never behind the read cursor's logical position;
346
+ // readCursor is an absolute offset so we track how much we've discarded.
347
+ const overflow = shell.output.length - MAX_OUTPUT_CHARS;
348
+ shell.output = shell.output.slice(overflow);
349
+ shell.readCursor = Math.max(0, shell.readCursor - overflow);
311
350
  shell.truncated = true;
312
351
  }
313
- else
314
- shell.output += chunk;
315
352
  };
316
353
  child.stdout?.on('data', (c) => append(c.toString('utf-8')));
317
354
  child.stderr?.on('data', (c) => append(c.toString('utf-8')));
@@ -357,18 +394,22 @@ async function killShell(input) {
357
394
  return { error: `No background shell with id "${id}".` };
358
395
  if (shell.status === 'running') {
359
396
  shell.status = 'killed';
360
- try {
361
- shell.child.kill('SIGTERM');
362
- }
363
- catch { /* ignore */ }
364
- // Escalate to SIGKILL after 3 s if process is still alive
365
- setTimeout(() => {
397
+ const killTree = (sig) => {
366
398
  try {
367
- if (shell.status === 'killed')
368
- shell.child.kill('SIGKILL');
399
+ if (shell.child.pid)
400
+ process.kill(-shell.child.pid, sig);
369
401
  }
370
- catch { /* ignore */ }
371
- }, 3000);
402
+ catch {
403
+ try {
404
+ shell.child.kill(sig);
405
+ }
406
+ catch { /* already dead */ }
407
+ }
408
+ };
409
+ killTree('SIGTERM');
410
+ // Escalate to SIGKILL after 3 s if process group is still alive
411
+ setTimeout(() => { if (shell.status === 'killed')
412
+ killTree('SIGKILL'); }, 3000);
372
413
  }
373
414
  return { output: `Background shell ${id} ${shell.status}.` };
374
415
  }
@@ -416,43 +457,80 @@ async function bash(input, abortSignal, sandbox, workDir) {
416
457
  r.output = `[${sandboxNote}]\n${r.output}`;
417
458
  return r;
418
459
  };
460
+ // detached:true puts the child in its own process group so we can signal the
461
+ // WHOLE tree (shell + its children — dev servers, test runners) on timeout /
462
+ // abort. Without it, kill() only hits the /bin/sh wrapper and leaves orphaned
463
+ // grandchildren holding ports (→ EADDRINUSE on the next run).
419
464
  const child = (0, child_process_1.spawn)(effectiveCommand, {
420
465
  shell: true,
421
466
  cwd: workDir ?? process.cwd(),
422
467
  env: process.env,
468
+ detached: true,
423
469
  });
424
- let output = '';
470
+ // Kill the child's entire process group (negative pid). Falls back to a
471
+ // plain kill if the group signal fails (e.g. process already gone).
472
+ const killTree = (sig) => {
473
+ try {
474
+ if (child.pid)
475
+ process.kill(-child.pid, sig);
476
+ }
477
+ catch {
478
+ try {
479
+ child.kill(sig);
480
+ }
481
+ catch { /* already dead */ }
482
+ }
483
+ };
484
+ // Keep HEAD + TAIL rather than head-only: test runners print the failure
485
+ // summary at the END, so a large passing-then-failing suite must not lose its
486
+ // tail. We retain the first HEAD_CHARS and the last TAIL_CHARS.
487
+ const HEAD_CHARS = Math.floor(MAX_OUTPUT_CHARS * 0.25);
488
+ const TAIL_CHARS = MAX_OUTPUT_CHARS - HEAD_CHARS;
489
+ let head = '';
490
+ let tail = '';
491
+ let totalLen = 0;
425
492
  let truncated = false;
426
493
  let settled = false;
427
- // Cap output in the data handler so we never accumulate more than MAX_OUTPUT_CHARS in RAM
428
494
  const appendOutput = (chunk) => {
429
- if (truncated)
430
- return;
431
- const remaining = MAX_OUTPUT_CHARS - output.length;
432
- if (chunk.length >= remaining) {
433
- output += chunk.slice(0, remaining);
434
- truncated = true;
495
+ totalLen += chunk.length;
496
+ if (head.length < HEAD_CHARS) {
497
+ const room = HEAD_CHARS - head.length;
498
+ head += chunk.slice(0, room);
499
+ chunk = chunk.slice(room);
500
+ if (!chunk)
501
+ return;
435
502
  }
436
- else {
437
- output += chunk;
503
+ // Remaining goes into a rolling tail buffer.
504
+ tail += chunk;
505
+ if (tail.length > TAIL_CHARS) {
506
+ tail = tail.slice(tail.length - TAIL_CHARS);
507
+ truncated = true;
438
508
  }
439
509
  };
510
+ const collect = () => {
511
+ if (!truncated)
512
+ return head + tail;
513
+ const omitted = totalLen - head.length - tail.length;
514
+ return `${head}\n\n[… ${omitted} chars omitted (kept first ${HEAD_CHARS / 1024}KB + last ${Math.round(TAIL_CHARS / 1024)}KB) …]\n\n${tail}`;
515
+ };
440
516
  const done = (code, signal, timedOut = false) => {
441
517
  if (settled)
442
518
  return;
443
519
  settled = true;
444
- const truncNote = truncated ? '\n[Output truncated at 100KB]' : '';
520
+ const body = collect();
445
521
  if (timedOut) {
446
- resolve({ error: `Command timed out after ${timeoutMs}ms`, interrupted: true });
522
+ // Return the partial output alongside the error it usually holds the
523
+ // reason the command hung (a stuck test, a prompt, a slow step).
524
+ resolve(prependNote({ error: `Command timed out after ${timeoutMs}ms`, interrupted: true, output: body ? `Partial output before timeout:\n${body}` : undefined }));
447
525
  }
448
526
  else if (abortSignal?.aborted) {
449
- resolve({ error: 'Command stopped by user', interrupted: true });
527
+ resolve({ error: 'Command stopped by user', interrupted: true, output: body || undefined });
450
528
  }
451
529
  else if (code !== 0) {
452
- resolve(prependNote({ output: output + `\n[Exit code: ${code ?? signal ?? 'unknown'}]` + truncNote }));
530
+ resolve(prependNote({ output: body + `\n[Exit code: ${code ?? signal ?? 'unknown'}]` }));
453
531
  }
454
532
  else {
455
- resolve(prependNote({ output: output + truncNote }));
533
+ resolve(prependNote({ output: body }));
456
534
  }
457
535
  };
458
536
  child.stdout?.on('data', (chunk) => appendOutput(chunk.toString('utf-8')));
@@ -464,13 +542,13 @@ async function bash(input, abortSignal, sandbox, workDir) {
464
542
  clearTimeout(timer);
465
543
  resolve({ error: err.message });
466
544
  });
467
- // Timeout kill: SIGTERM first, escalate to SIGKILL after 3 s if still alive
545
+ // Timeout kill: SIGTERM the whole group, escalate to SIGKILL after 3 s
468
546
  const timer = setTimeout(() => {
469
547
  if (settled)
470
548
  return;
471
- child.kill('SIGTERM');
549
+ killTree('SIGTERM');
472
550
  setTimeout(() => { if (!settled)
473
- child.kill('SIGKILL'); }, 3000);
551
+ killTree('SIGKILL'); }, 3000);
474
552
  done(null, null, true);
475
553
  }, timeoutMs);
476
554
  child.on('close', () => clearTimeout(timer));
@@ -478,7 +556,7 @@ async function bash(input, abortSignal, sandbox, workDir) {
478
556
  const pollAbort = setInterval(() => {
479
557
  if (abortSignal?.aborted && !settled) {
480
558
  clearInterval(pollAbort);
481
- child.kill('SIGTERM');
559
+ killTree('SIGTERM');
482
560
  done(null, null, false);
483
561
  }
484
562
  }, 200);
@@ -528,6 +606,11 @@ async function searchFiles(input, workDir) {
528
606
  else {
529
607
  // Content search — prefer ripgrep (faster, .gitignore-aware) when
530
608
  // installed, fall back to plain grep with hardcoded excludes otherwise.
609
+ // maxBuffer is raised well past the default 1 MB: on a large repo a common
610
+ // pattern can emit tens of MB and the default silently truncates + sets
611
+ // status:null (ENOBUFS), which used to surface as an empty "search failed".
612
+ const SEARCH_MAX_BUFFER = 64 * 1024 * 1024; // 64 MB
613
+ const spawnOpts = { encoding: 'utf-8', timeout: DEFAULT_TIMEOUT_MS, maxBuffer: SEARCH_MAX_BUFFER };
531
614
  let result;
532
615
  if (ripgrepAvailable()) {
533
616
  const args = ['--line-number', '--no-heading', '--color=never', '--hidden', '--glob', '!.git'];
@@ -537,8 +620,10 @@ async function searchFiles(input, workDir) {
537
620
  args.push(`-C${contextLines}`);
538
621
  if (include)
539
622
  args.push('--glob', include);
623
+ // -m is PER-FILE in rg/grep; the real global cap is applied on the
624
+ // output below. Keep a generous per-file cap so no single file floods.
540
625
  args.push('-m', '200', '--regexp', pattern, resolved);
541
- result = (0, child_process_1.spawnSync)('rg', args, { encoding: 'utf-8', timeout: DEFAULT_TIMEOUT_MS });
626
+ result = (0, child_process_1.spawnSync)('rg', args, spawnOpts);
542
627
  }
543
628
  else {
544
629
  const args = ['-rn', '--binary-files=without-match', '--color=never'];
@@ -549,17 +634,31 @@ async function searchFiles(input, workDir) {
549
634
  if (include)
550
635
  args.push(`--include=${include}`);
551
636
  args.push('--exclude-dir=.git', '--exclude-dir=node_modules', '--exclude-dir=dist', '--exclude-dir=.next', '--exclude-dir=__pycache__', '--exclude-dir=.turbo', '--exclude-dir=coverage', '--exclude-dir=.cache', '-m', '200', pattern, resolved);
552
- result = (0, child_process_1.spawnSync)('grep', args, { encoding: 'utf-8', timeout: DEFAULT_TIMEOUT_MS });
637
+ result = (0, child_process_1.spawnSync)('grep', args, spawnOpts);
553
638
  }
554
639
  let output = result.stdout ?? '';
555
640
  const stderr = result.stderr ?? '';
641
+ // Distinguish real failure modes. spawnSync sets `.error` (not `.status`)
642
+ // for timeout (ETIMEDOUT), buffer overflow (ENOBUFS) and spawn failures.
643
+ const spawnErr = result.error;
644
+ if (spawnErr) {
645
+ if (spawnErr.code === 'ETIMEDOUT' || result.signal === 'SIGTERM') {
646
+ const partial = output ? `\n\nPartial results before timeout:\n${globalCapMatches(output)}` : '';
647
+ return { error: `Search timed out after ${Math.round(DEFAULT_TIMEOUT_MS / 1000)}s — narrow the path or pattern (or add an "include" filter).${partial}` };
648
+ }
649
+ if (spawnErr.code === 'ENOBUFS') {
650
+ // We still captured up to maxBuffer of output — return the capped head
651
+ // instead of failing blind.
652
+ return { output: `${globalCapMatches(output)}\n[Too many matches — output exceeded ${SEARCH_MAX_BUFFER / (1024 * 1024)}MB and was capped. Narrow the pattern/path.]` };
653
+ }
654
+ return { error: `search failed: ${spawnErr.message}` };
655
+ }
556
656
  // Both rg and grep use exit code 1 = "no matches", 2 = error.
557
657
  if (result.status === 1 && !output)
558
658
  return { output: 'No matches found.' };
559
659
  if (result.status !== 0 && result.status !== 1)
560
660
  return { error: stderr || 'search failed' };
561
- if (output.length > MAX_OUTPUT_CHARS)
562
- output = output.slice(0, MAX_OUTPUT_CHARS) + '\n[Output truncated at 100KB]';
661
+ output = globalCapMatches(output);
563
662
  return { output: output || 'No matches found.' };
564
663
  }
565
664
  }
@@ -567,6 +666,29 @@ async function searchFiles(input, workDir) {
567
666
  return { error: err.message };
568
667
  }
569
668
  }
669
+ /**
670
+ * Enforce the documented global cap on grep/rg output: at most 200 matched
671
+ * lines (the tool contract) AND at most MAX_OUTPUT_CHARS bytes. The `-m 200`
672
+ * flag on rg/grep is PER-FILE, so a large repo can blow well past 200 total —
673
+ * this applies the real global ceiling after the fact.
674
+ */
675
+ function globalCapMatches(output) {
676
+ if (!output)
677
+ return output;
678
+ const GLOBAL_MATCH_CAP = 200;
679
+ const lines = output.split('\n');
680
+ let capped = output;
681
+ let note = '';
682
+ if (lines.length > GLOBAL_MATCH_CAP) {
683
+ capped = lines.slice(0, GLOBAL_MATCH_CAP).join('\n');
684
+ note = `\n[Showing first ${GLOBAL_MATCH_CAP} of ${lines.length} matched lines — narrow the pattern/path for the rest.]`;
685
+ }
686
+ if (capped.length > MAX_OUTPUT_CHARS) {
687
+ capped = capped.slice(0, MAX_OUTPUT_CHARS);
688
+ note = `\n[Output truncated at ${MAX_OUTPUT_CHARS / 1024}KB.]`;
689
+ }
690
+ return capped + note;
691
+ }
570
692
  function matchesGlob(name, pattern) {
571
693
  // Convert simple glob (* → .*, ? → .) to regex
572
694
  try {
@@ -694,6 +816,42 @@ function literalReplace(haystack, needle, replacement) {
694
816
  return haystack;
695
817
  return haystack.slice(0, idx) + replacement + haystack.slice(idx + needle.length);
696
818
  }
819
+ // Max size an edit tool will load into memory. Guards against OOM on a huge file
820
+ // and mirrors read_file's cap (edits target source, not multi-MB assets).
821
+ const MAX_EDIT_BYTES = 10 * 1024 * 1024; // 10 MB
822
+ /**
823
+ * Guard a path for in-place editing: reject missing files, oversize files, and
824
+ * binaries (a text edit on a binary corrupts it, and CRLF-normalize mangles
825
+ * embedded \r\n). Returns an error string, or the file's mode + content.
826
+ */
827
+ function preflightEdit(resolved) {
828
+ let stat;
829
+ try {
830
+ stat = fs.statSync(resolved);
831
+ }
832
+ catch {
833
+ return { error: `File not found: ${resolved}. Read the file first to verify the path.` };
834
+ }
835
+ if (stat.isDirectory())
836
+ return { error: `Path is a directory, not a file: ${resolved}.` };
837
+ if (stat.size > MAX_EDIT_BYTES) {
838
+ return { error: `File too large to edit (${(stat.size / 1024 / 1024).toFixed(1)} MB, max ${MAX_EDIT_BYTES / 1024 / 1024} MB). Use bash (sed/awk) for very large files.` };
839
+ }
840
+ if (isBinaryFile(resolved)) {
841
+ return { error: `Refusing to edit binary file ${resolved} — a text replace would corrupt it.` };
842
+ }
843
+ return { mode: stat.mode, content: fs.readFileSync(resolved, 'utf-8') };
844
+ }
845
+ /** Atomic write that preserves the original file's permission bits (mode). */
846
+ function atomicWritePreservingMode(resolved, data, mode) {
847
+ const tmp = resolved + '.nexrall_tmp';
848
+ fs.writeFileSync(tmp, data, 'utf-8');
849
+ try {
850
+ fs.chmodSync(tmp, mode);
851
+ }
852
+ catch { /* non-fatal — keep going */ }
853
+ fs.renameSync(tmp, resolved);
854
+ }
697
855
  // Normalize CRLF → LF so Windows files match LF old_strings.
698
856
  function normalizeLF(s) {
699
857
  return s.replace(/\r\n/g, '\n');
@@ -708,7 +866,10 @@ async function editFile(input, workDir) {
708
866
  return { error: 'Missing required parameter: old_string' };
709
867
  try {
710
868
  const resolved = resolvePath(filePath, workDir);
711
- const original = fs.readFileSync(resolved, 'utf-8');
869
+ const pre = preflightEdit(resolved);
870
+ if ('error' in pre)
871
+ return { error: pre.error };
872
+ const original = pre.content;
712
873
  // Normalize CRLF → LF for matching; restore on write if needed
713
874
  const wasCRLF = original.includes('\r\n');
714
875
  const origNorm = normalizeLF(original);
@@ -724,11 +885,9 @@ async function editFile(input, workDir) {
724
885
  let updated = literalReplace(origNorm, oldNorm, newNorm);
725
886
  if (wasCRLF)
726
887
  updated = updated.replace(/\n/g, '\r\n');
727
- // Atomic write: write to a temp file then rename so a crash mid-write never
728
- // leaves the original file truncated or partially overwritten.
729
- const tmp = resolved + '.nexrall_tmp';
730
- fs.writeFileSync(tmp, updated, 'utf-8');
731
- fs.renameSync(tmp, resolved);
888
+ // Atomic write (temp + rename) so a crash mid-write never leaves the file
889
+ // truncated, AND preserve the original mode bits (+x on scripts etc.).
890
+ atomicWritePreservingMode(resolved, updated, pre.mode);
732
891
  const diff = buildDiff(filePath, oldNorm, newNorm, origNorm);
733
892
  const linesBefore = origNorm.split('\n').length;
734
893
  const linesAfter = updated.split('\n').length;
@@ -802,6 +961,95 @@ function stripHtml(html) {
802
961
  }
803
962
  const MAX_REDIRECTS = 10;
804
963
  const FETCH_USER_AGENT = 'Nexrall-Code/1.0 (+https://nexrall.com)';
964
+ /** True if an IP literal is loopback / private / link-local / unique-local. */
965
+ function isPrivateIp(ip) {
966
+ const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
967
+ if (v4) {
968
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
969
+ if (a === 10)
970
+ return true; // 10.0.0.0/8
971
+ if (a === 127)
972
+ return true; // loopback
973
+ if (a === 0)
974
+ return true; // 0.0.0.0/8
975
+ if (a === 172 && b >= 16 && b <= 31)
976
+ return true; // 172.16.0.0/12
977
+ if (a === 192 && b === 168)
978
+ return true; // 192.168.0.0/16
979
+ if (a === 169 && b === 254)
980
+ return true; // link-local (cloud metadata!)
981
+ if (a === 100 && b >= 64 && b <= 127)
982
+ return true; // CGNAT 100.64.0.0/10
983
+ return false;
984
+ }
985
+ const low = ip.toLowerCase().replace(/^\[|\]$/g, '');
986
+ if (low === '::1' || low === '::')
987
+ return true; // IPv6 loopback / unspecified
988
+ if (low.startsWith('fe80'))
989
+ return true; // link-local
990
+ if (low.startsWith('fc') || low.startsWith('fd'))
991
+ return true; // unique-local fc00::/7
992
+ if (low.startsWith('::ffff:'))
993
+ return isPrivateIp(low.slice(7)); // IPv4-mapped
994
+ return false;
995
+ }
996
+ /**
997
+ * SSRF guard: reject a URL whose host is (or resolves to) a private / internal
998
+ * address. Blocks the classic prompt-injection → cloud-metadata exfiltration
999
+ * (http://169.254.169.254/…) and internal-service probing. Returns an error
1000
+ * string when the host is unsafe, or null when it's OK to fetch.
1001
+ */
1002
+ async function ssrfCheck(parsedUrl) {
1003
+ // Escape hatch for local dev-server previews (fetch_url http://localhost:3000):
1004
+ // opt-in only, since it re-opens the SSRF surface.
1005
+ const env = (process.env.NEXRALL_ALLOW_LOCAL_FETCH ?? '').toLowerCase();
1006
+ if (env === '1' || env === 'true' || env === 'on')
1007
+ return null;
1008
+ const host = parsedUrl.hostname.replace(/^\[|\]$/g, '');
1009
+ const lower = host.toLowerCase();
1010
+ if (lower === 'localhost' || lower.endsWith('.localhost') || lower === 'metadata.google.internal') {
1011
+ return `Refusing to fetch internal host "${host}" (SSRF guard).`;
1012
+ }
1013
+ // Only SHORT-CIRCUIT on a form we can evaluate exactly: a valid dotted-quad
1014
+ // IPv4 or an IPv6 literal. Numeric shorthands like decimal (2130706433) or
1015
+ // octal (0177.0.0.1) must NOT be checked with isPrivateIp — they'd read as
1016
+ // "not private" and slip through, yet getaddrinfo expands them to 127.0.0.1 /
1017
+ // 169.254.169.254 at connect time. Those fall through to DNS below, which
1018
+ // canonicalizes them via the same resolver the real request uses.
1019
+ if (isValidDottedQuad(host) || host.includes(':')) {
1020
+ return isPrivateIp(host) ? `Refusing to fetch private/internal address "${host}" (SSRF guard).` : null;
1021
+ }
1022
+ // Hostname (or numeric shorthand) → resolve and reject if ANY resolved
1023
+ // address is private. getaddrinfo handles inet_aton forms (decimal/octal/hex),
1024
+ // so a disguised metadata IP resolves to its real address here and is caught.
1025
+ try {
1026
+ const addrs = await new Promise((res, rej) => {
1027
+ dns.lookup(host, { all: true }, (err, a) => (err ? rej(err) : res(a)));
1028
+ });
1029
+ if (!addrs.length)
1030
+ return null;
1031
+ for (const { address } of addrs) {
1032
+ if (isPrivateIp(address))
1033
+ return `Refusing to fetch "${host}" — it resolves to a private/internal address (${address}) (SSRF guard).`;
1034
+ }
1035
+ }
1036
+ catch {
1037
+ // DNS failure. For a plain hostname, let the real request surface the network
1038
+ // error. But for a NUMERIC shorthand we couldn't resolve, fail closed — we
1039
+ // can't prove it's public and it might be a disguised private literal.
1040
+ if (/^[\d.]+$|^0[xX]/.test(host)) {
1041
+ return `Refusing to fetch numeric host "${host}" — could not verify it is a public address (SSRF guard).`;
1042
+ }
1043
+ }
1044
+ return null;
1045
+ }
1046
+ /** True only for a canonical dotted-quad IPv4 (4 octets, each 0–255). */
1047
+ function isValidDottedQuad(host) {
1048
+ const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
1049
+ if (!m)
1050
+ return false;
1051
+ return m.slice(1).every((o) => Number(o) <= 255);
1052
+ }
805
1053
  async function fetchUrl(input, _workDir, _redirectCount = 0) {
806
1054
  const url = typeof input.url === 'string' ? input.url : '';
807
1055
  if (!url)
@@ -819,10 +1067,33 @@ async function fetchUrl(input, _workDir, _redirectCount = 0) {
819
1067
  if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
820
1068
  return { error: `Unsupported protocol: ${parsedUrl.protocol}. Only http and https are supported.` };
821
1069
  }
1070
+ // SSRF guard — runs on every hop (redirects recurse through fetchUrl), so a
1071
+ // public URL that 302-redirects to 169.254.169.254 is still blocked.
1072
+ const ssrfError = await ssrfCheck(parsedUrl);
1073
+ if (ssrfError)
1074
+ return { error: ssrfError };
1075
+ const allowLocal = ['1', 'true', 'on'].includes((process.env.NEXRALL_ALLOW_LOCAL_FETCH ?? '').toLowerCase());
822
1076
  return new Promise((resolve) => {
823
1077
  const transport = parsedUrl.protocol === 'https:' ? https : http;
1078
+ // Re-validate the address the socket ACTUALLY connects to. ssrfCheck() above
1079
+ // resolves at check-time; this closes the TOCTOU / DNS-rebinding window where
1080
+ // a hostname flips to a private IP between the check and the connect.
1081
+ const guardedLookup = ((hostname, opts, cb) => {
1082
+ const callback = (typeof opts === 'function' ? opts : cb);
1083
+ const options = (typeof opts === 'function' ? {} : opts);
1084
+ return dns.lookup(hostname, options, (err, address, family) => {
1085
+ if (err)
1086
+ return callback(err, address, family);
1087
+ const addrs = Array.isArray(address) ? address.map((a) => a.address) : [address];
1088
+ if (!allowLocal && addrs.some((a) => isPrivateIp(a))) {
1089
+ return callback(Object.assign(new Error('SSRF guard: host resolved to a private address at connect time'), { code: 'ESSRFBLOCKED' }), address, family);
1090
+ }
1091
+ return callback(null, address, family);
1092
+ });
1093
+ });
824
1094
  const req = transport.get(url, {
825
1095
  timeout: DEFAULT_TIMEOUT_MS,
1096
+ lookup: guardedLookup,
826
1097
  headers: {
827
1098
  'User-Agent': FETCH_USER_AGENT,
828
1099
  'Accept-Encoding': 'identity', // opt-out of gzip/deflate — we read raw Buffer as UTF-8
@@ -919,7 +1190,10 @@ async function multiEdit(input, workDir) {
919
1190
  return { error: 'Missing required parameter: edits (must be a non-empty array)' };
920
1191
  try {
921
1192
  const resolved = resolvePath(filePath, workDir);
922
- const rawFile = fs.readFileSync(resolved, 'utf-8');
1193
+ const pre = preflightEdit(resolved);
1194
+ if ('error' in pre)
1195
+ return { error: pre.error };
1196
+ const rawFile = pre.content;
923
1197
  // Work in LF-normalised space so CRLF files match LF old_strings
924
1198
  const wasCRLF = rawFile.includes('\r\n');
925
1199
  let content = normalizeLF(rawFile);
@@ -945,11 +1219,10 @@ async function multiEdit(input, workDir) {
945
1219
  diffs.push(buildDiff(filePath, oldStr, newStr, content));
946
1220
  content = literalReplace(content, oldStr, newStr);
947
1221
  }
948
- // Restore original line endings before writing (atomic temp+rename)
1222
+ // Restore original line endings before writing (atomic temp+rename),
1223
+ // preserving the original mode bits (+x on scripts etc.).
949
1224
  const finalContent = wasCRLF ? content.replace(/\n/g, '\r\n') : content;
950
- const tmp = resolved + '.nexrall_tmp';
951
- fs.writeFileSync(tmp, finalContent, 'utf-8');
952
- fs.renameSync(tmp, resolved);
1225
+ atomicWritePreservingMode(resolved, finalContent, pre.mode);
953
1226
  return {
954
1227
  output: `Applied ${edits.length} edit(s) to ${resolved}:\n` +
955
1228
  diffs.map((d, i) => `\n--- edit #${i + 1} ---\n${d}`).join('\n'),
package/dist/types.d.ts CHANGED
@@ -151,6 +151,14 @@ export interface AgentLoopOptions {
151
151
  takePendingInput?: () => string[];
152
152
  /** Notify that a queued follow-up was injected into the conversation (for UI echo/logging). */
153
153
  onInjectedInput?: (text: string) => void;
154
+ /**
155
+ * Fired at every turn boundary inside the loop with a snapshot of the current
156
+ * message history. Lets the caller persist progress INCREMENTALLY so a crash
157
+ * (or `kill`) hours into a long run doesn't lose the whole session — only the
158
+ * in-flight step. The array is a live reference; treat it as read-only and
159
+ * copy if you need to retain it. Cheap: callers should debounce heavy writes.
160
+ */
161
+ onProgress?: (messages: Message[]) => void;
154
162
  /**
155
163
  * Max agent iterations (model response + tool round) before the runaway-loop
156
164
  * backstop trips. Overrides the NEXRALL_MAX_ITERATIONS env var and the
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexrall/code-core",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Core agent loop, tools, and extension primitives for Nexrall Code — embed an AI coding agent in any Node.js application.",
5
5
  "license": "MIT",
6
6
  "author": "Nexrall <support@nexrall.com> (https://nexrall.com)",