@ikarmic/forge-core 5.2.0 → 5.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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Model limits and prices from models.dev, the catalog Kilo and OpenCode read at runtime. A surface calls
3
+ * loadModelCatalog() once at startup: it uses a cached copy (24 h), refreshes it from models.dev, and falls back to
4
+ * the snapshot bundled with this release. Until then the catalog is empty and the hand-written tables answer.
5
+ * Lookups match an exact model id only: a newer model is never priced like an older one (RB-34).
6
+ */
7
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { installedFetch } from '../policy/proxy.js';
11
+ import { MODELS_DEV_SNAPSHOT } from './models-dev-snapshot.js';
12
+ export const MODELS_DEV_URL = 'https://models.dev/api.json';
13
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
14
+ const FETCH_TIMEOUT_MS = 10_000;
15
+ /** models.dev provider keys Forge can reach (keep in step with scripts/models-dev-snapshot.mjs). */
16
+ const CATALOG_PROVIDERS = [
17
+ 'anthropic', 'openai', 'google', 'openrouter', 'minimax', 'amazon-bedrock', 'google-vertex', 'azure',
18
+ 'deepseek', 'moonshotai', 'xai', 'mistral', 'alibaba', 'zhipuai', 'groq',
19
+ ];
20
+ /** Forge provider -> the models.dev provider whose ids it uses. Others (openai-compatible…) match by id across all. */
21
+ const PROVIDER_KEY = {
22
+ anthropic: 'anthropic',
23
+ openai: 'openai',
24
+ google: 'google',
25
+ openrouter: 'openrouter',
26
+ minimax: 'minimax',
27
+ bedrock: 'amazon-bedrock',
28
+ vertex: 'google-vertex',
29
+ azure: 'azure',
30
+ };
31
+ /** Lower-cased id -> row, per provider key. */
32
+ let index;
33
+ function build(data) {
34
+ index = new Map();
35
+ for (const [provider, models] of Object.entries(data)) {
36
+ const byId = new Map();
37
+ for (const [id, row] of Object.entries(models))
38
+ byId.set(id.toLowerCase(), row);
39
+ index.set(provider, byId);
40
+ }
41
+ }
42
+ /** The fields Forge uses from a models.dev api.json, for the providers it can reach. */
43
+ export function trimModelsDev(api) {
44
+ const out = {};
45
+ const root = (api ?? {});
46
+ for (const key of CATALOG_PROVIDERS) {
47
+ const models = root[key]?.models;
48
+ if (!models || typeof models !== 'object')
49
+ continue;
50
+ out[key] = {};
51
+ for (const [id, m] of Object.entries(models)) {
52
+ const c = m?.cost ?? {};
53
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
54
+ const row = [num(m?.limit?.context), num(m?.limit?.output), num(c.input), num(c.output), num(c.cache_read), num(c.cache_write)];
55
+ while (row.length > 2 && row[row.length - 1] === null)
56
+ row.pop();
57
+ out[key][id] = row;
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ function entryOf(row) {
63
+ const at = (i) => (typeof row[i] === 'number' && row[i] > 0 ? row[i] : undefined);
64
+ const entry = {};
65
+ if (at(0))
66
+ entry.context = at(0);
67
+ if (at(1))
68
+ entry.output = at(1);
69
+ const input = row[2];
70
+ const output = row[3];
71
+ if (typeof input === 'number' && typeof output === 'number') {
72
+ entry.cost = { input, output, ...(typeof row[4] === 'number' ? { cacheRead: row[4] } : {}), ...(typeof row[5] === 'number' ? { cacheWrite: row[5] } : {}) };
73
+ }
74
+ return entry;
75
+ }
76
+ /** The catalog's entry for a model, or undefined (no catalog loaded, or an id it does not list exactly). */
77
+ export function catalogEntry(model) {
78
+ if (!index)
79
+ return undefined;
80
+ const id = model.model.toLowerCase().replace(/^models\//, '');
81
+ const key = PROVIDER_KEY[model.provider];
82
+ if (key) {
83
+ const row = index.get(key)?.get(id);
84
+ return row ? entryOf(row) : undefined;
85
+ }
86
+ for (const byId of index.values()) {
87
+ const row = byId.get(id);
88
+ if (row)
89
+ return entryOf(row);
90
+ }
91
+ return undefined;
92
+ }
93
+ /** Forget the loaded catalog (tests). */
94
+ export function resetModelCatalog() {
95
+ index = undefined;
96
+ }
97
+ export function defaultCatalogCacheFile() {
98
+ return path.join(os.homedir(), '.forge', 'cache', 'models-dev.json');
99
+ }
100
+ /**
101
+ * Load the catalog: a fresh cache, else models.dev (written back to the cache), else a stale cache, else the
102
+ * bundled snapshot. Never throws: a broken network or cache only means older data.
103
+ */
104
+ export async function loadModelCatalog(opts = {}) {
105
+ const cacheFile = opts.cacheFile ?? defaultCatalogCacheFile();
106
+ const now = opts.now ?? Date.now();
107
+ let cached;
108
+ try {
109
+ const parsed = JSON.parse(await readFile(cacheFile, 'utf8'));
110
+ if (typeof parsed.fetchedAt === 'number' && parsed.data && typeof parsed.data === 'object')
111
+ cached = parsed;
112
+ }
113
+ catch {
114
+ // No cache yet, or an unreadable one: fetch.
115
+ }
116
+ if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
117
+ build(cached.data);
118
+ return 'cache';
119
+ }
120
+ try {
121
+ const doFetch = opts.fetchImpl ?? installedFetch() ?? fetch;
122
+ const res = await doFetch(MODELS_DEV_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
123
+ if (!res.ok)
124
+ throw new Error(`models.dev answered ${res.status}`);
125
+ const data = trimModelsDev(await res.json());
126
+ if (Object.keys(data).length === 0)
127
+ throw new Error('models.dev returned no known provider');
128
+ build(data);
129
+ await mkdir(path.dirname(cacheFile), { recursive: true });
130
+ const tmpFile = `${cacheFile}.${process.pid}.tmp`;
131
+ await writeFile(tmpFile, JSON.stringify({ fetchedAt: now, data }));
132
+ await rename(tmpFile, cacheFile).catch(() => undefined);
133
+ return 'fetched';
134
+ }
135
+ catch {
136
+ if (cached) {
137
+ build(cached.data);
138
+ return 'stale-cache';
139
+ }
140
+ build(MODELS_DEV_SNAPSHOT);
141
+ return 'snapshot';
142
+ }
143
+ }
@@ -12,6 +12,8 @@
12
12
  * dedicated cheaper model or even a deterministic extractive summarizer.
13
13
  */
14
14
  import { generateText } from 'ai';
15
+ import { withModelRetry } from '../agent/retry.js';
16
+ import { catalogEntry } from '../catalog/models-dev.js';
15
17
  import { mediaPartLabel, mediaPartTokens } from '../attachments.js';
16
18
  import { stripThinkTags } from '../agent/think-tags.js';
17
19
  import { overrideContextTokens } from '../model-overrides.js';
@@ -88,6 +90,9 @@ export function getModelWindowTokens(model) {
88
90
  const overridden = overrideContextTokens(model);
89
91
  if (overridden)
90
92
  return overridden;
93
+ const listed = typeof model === 'string' ? undefined : catalogEntry(model)?.context;
94
+ if (listed)
95
+ return listed;
91
96
  const id = typeof model === 'string' ? model : model.model;
92
97
  const stripped = id.toLowerCase().replace(/^models\//, '');
93
98
  // Direct hit first.
@@ -201,11 +206,12 @@ Here is the conversation to summarize:
201
206
 
202
207
  ${truncateToBudget(transcript, 60_000)}`;
203
208
  const llm = factory(model);
204
- const result = await generateText({
209
+ const result = await withModelRetry(() => generateText({
205
210
  model: llm,
206
211
  prompt,
207
212
  maxOutputTokens: 2000,
208
- });
213
+ maxRetries: 0,
214
+ }));
209
215
  // Reasoning models put an inline <think> block in the reply; it must not be stored as the summary.
210
216
  return stripThinkTags(result.text).trim();
211
217
  };
@@ -27,11 +27,11 @@ export declare function commitDirty(cwd: string, options?: GitCommitOptions): Pr
27
27
  export declare function diffForFiles(cwd: string, files: string[]): Promise<string>;
28
28
  /** Deterministic message used when no model is available or it fails. */
29
29
  export declare function fallbackCommitMessage(files: string[]): string;
30
- export declare function commitMessagePrompt(diff: string, task?: string, instruction?: string): string;
30
+ export declare function commitMessagePrompt(diff: string, task?: string, instruction?: string, language?: string): string;
31
31
  /** Strip an inline `<think>` block (MiniMax and other reasoning models) and the fences/quotes a model may wrap around the message. */
32
32
  export declare function cleanCommitMessage(text: string): string;
33
33
  /** Draft a commit message with `model` (typically the cheap model); never throws. */
34
- export declare function generateCommitMessage(diff: string, files: string[], model: ModelConfig | undefined, task?: string, modelFactory?: (cfg: ModelConfig) => LanguageModel, instruction?: string): Promise<string>;
34
+ export declare function generateCommitMessage(diff: string, files: string[], model: ModelConfig | undefined, task?: string, modelFactory?: (cfg: ModelConfig) => LanguageModel, instruction?: string, language?: string): Promise<string>;
35
35
  export type UndoResult = {
36
36
  ok: true;
37
37
  commit: string;
@@ -60,4 +60,6 @@ export declare function afterRun(cwd: string, state: {
60
60
  model?: ModelConfig;
61
61
  task?: string;
62
62
  modelFactory?: (cfg: ModelConfig) => LanguageModel;
63
+ instruction?: string;
64
+ language?: string;
63
65
  }): Promise<ForgeCommit | null>;
@@ -6,6 +6,7 @@
6
6
  import { execFile } from 'node:child_process';
7
7
  import { promisify } from 'node:util';
8
8
  import { generateText } from 'ai';
9
+ import { withModelRetry } from '../agent/retry.js';
9
10
  import { stripThinkTags } from '../agent/think-tags.js';
10
11
  import { createLanguageModel } from '../providers.js';
11
12
  const execFileAsync = promisify(execFile);
@@ -112,10 +113,20 @@ export function fallbackCommitMessage(files) {
112
113
  return `forge: update ${shown}${more}`;
113
114
  }
114
115
  const MAX_DIFF_CHARS = 12_000;
115
- export function commitMessagePrompt(diff, task, instruction) {
116
+ const MAX_LANGUAGE_CHARS = 40;
117
+ /** One short line for the prompt, or '' when English or unset. The value is typed by a person and goes into a model prompt. */
118
+ function languageLine(language) {
119
+ const lang = (language ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_LANGUAGE_CHARS).trim();
120
+ if (!lang || /^(english|en)$/i.test(lang))
121
+ return '';
122
+ return `
123
+
124
+ Write the commit message in ${lang}.`;
125
+ }
126
+ export function commitMessagePrompt(diff, task, instruction, language) {
116
127
  const clipped = diff.length > MAX_DIFF_CHARS ? `${diff.slice(0, MAX_DIFF_CHARS)}\n[diff truncated]` : diff;
117
128
  const lead = instruction?.trim() || 'Write a git commit message for this diff in conventional-commit style (type(scope): subject, imperative mood, under 72 characters). Add a short body only if the change needs explaining. Reply with the message only, no quotes or code fences.';
118
- return `${lead}${task ? `\n\nThe change was made for this request: ${task}` : ''}\n\n${clipped}`;
129
+ return `${lead}${languageLine(language)}${task ? `\n\nThe change was made for this request: ${task}` : ''}\n\n${clipped}`;
119
130
  }
120
131
  /** Strip an inline `<think>` block (MiniMax and other reasoning models) and the fences/quotes a model may wrap around the message. */
121
132
  export function cleanCommitMessage(text) {
@@ -126,11 +137,11 @@ export function cleanCommitMessage(text) {
126
137
  .trim();
127
138
  }
128
139
  /** Draft a commit message with `model` (typically the cheap model); never throws. */
129
- export async function generateCommitMessage(diff, files, model, task, modelFactory = createLanguageModel, instruction) {
140
+ export async function generateCommitMessage(diff, files, model, task, modelFactory = createLanguageModel, instruction, language) {
130
141
  if (!model || !diff.trim())
131
142
  return fallbackCommitMessage(files);
132
143
  try {
133
- const result = await generateText({ model: modelFactory(model), prompt: commitMessagePrompt(diff, task, instruction) });
144
+ const result = await withModelRetry(() => generateText({ model: modelFactory(model), prompt: commitMessagePrompt(diff, task, instruction, language), maxRetries: 0 }), { maxRetries: 2 });
134
145
  const message = cleanCommitMessage(result.text);
135
146
  return message || fallbackCommitMessage(files);
136
147
  }
@@ -181,6 +192,6 @@ export async function afterRun(cwd, state, options) {
181
192
  if (files.length === 0)
182
193
  return null;
183
194
  const diff = await diffForFiles(cwd, files);
184
- const message = await generateCommitMessage(diff, files, options.model, options.task, options.modelFactory);
195
+ const message = await generateCommitMessage(diff, files, options.model, options.task, options.modelFactory, options.instruction, options.language);
185
196
  return commitFiles(cwd, files, message, options);
186
197
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { Agent, applySandbox } from './agent/agent.js';
2
2
  export { LoopGuard } from './agent/loop-guard.js';
3
3
  export { enhancePrompt } from './agent/enhance-prompt.js';
4
+ export { generateShellCommand, shellKindOf, commandRisk } from './agent/shell-command.js';
5
+ export type { ShellKind } from './agent/shell-command.js';
4
6
  export { stripThinkTags } from './agent/think-tags.js';
5
7
  export { createGitHubCreateTool, createGitHubReadTool, createGitHubTools, githubFromEnv, parseRemote, type GitHubConfig, type PullRequestReport, type RepoRef, } from './github/github-tools.js';
6
8
  export { createWebSearchTool, searchWeb, WEB_SEARCH_PROVIDERS, webSearchFromEnv, webSearchProblem, type WebSearchConfig, type WebSearchProvider, type WebSearchResult, } from './tools/web-search.js';
@@ -43,7 +45,7 @@ export { defaultSessionDir, generateSessionId, saveSession, loadSession, deleteS
43
45
  export type { Session, ListEntry, FormatOptions, SessionUpdate, WatchSessionOptions, SessionMatch, SearchSessionsOptions, SessionPruneOptions, SessionPruneResult } from './sessions/index.js';
44
46
  export { commandDirs, expandCustomCommand, formatCustomCommands, loadCustomCommands, parseCommandFile, type CustomCommand, } from './commands/custom-commands.js';
45
47
  export { BUILTIN_TOOLS, editFileTool, fetchUrlTool, findSymbolTool, gitDiffTool, dependencyAuditTool, listFilesTool, memoryReadTool, memoryForgetTool, memoryWriteTool, readFileTool, runCommandTool, searchFilesTool, subAgentTool, writeFileTool, checkPolicyViolation, globToRegex, mergeToolSandbox, normalizePathPattern, pathMatchesAny, redact, countRedactions, buildRules, ToolResultCache, buildCacheKey, cachedExecute, } from './tools/index.js';
46
- export type { AgentConfig, AgentEvent, AgentRunOptions, AgentRunReason, AgentRunResult, AnyLanguageModel, ApprovalDecision, CommandRunner, ModelRouter, VerifyConfig, ApprovalRequest, LanguageModelFactory, ModelConfig, PricePerMillionTokens, ProviderName, RedactOptions, TodoItem, ToolContext, UserQuestion, ToolDefinition, ToolPolicy, ToolSandbox, } from './types.js';
48
+ export type { AgentConfig, AgentEvent, ApprovalSource, AgentRunOptions, AgentRunReason, AgentRunResult, AnyLanguageModel, ApprovalDecision, CommandRunner, ModelRouter, VerifyConfig, ApprovalRequest, LanguageModelFactory, ModelConfig, PricePerMillionTokens, ProviderName, RedactOptions, TodoItem, ToolContext, UserQuestion, ToolDefinition, ToolPolicy, ToolSandbox, } from './types.js';
47
49
  export type { CacheKeyParts, CacheStats } from './tools/index.js';
48
50
  export type { RedactionRule, RedactionResult } from './tools/index.js';
49
51
  export { buildSkillsBlock, builtinSkills, composeSkillsPrompt, discoverSkills, fetchSkillsCatalog, installSkillFromCatalog, loadSkillsRegistry, loadSkillsRegistryAsync, parseSkill, parseSkillsDir, resolveSkillRoots, scoreSkill, searchSkills, selectSkills, skillsRegistryFromList, DEFAULT_SKILL_CATALOG_URL, GLOBAL_SKILLS_DIR, LOCAL_SKILLS_DIR, PROJECT_SKILLS_DIR, SKILL_CATALOG_VERSION, } from './skills/index.js';
@@ -100,6 +102,9 @@ export { detectStackFacts, stackBlock, MAX_STACK_LIBRARIES, type StackFacts } fr
100
102
  export { pickAuditors, type AuditFinding, type AuditorPlan } from './tools/dependency-audit.js';
101
103
  export { shellEnv } from './tools/shell-env.js';
102
104
  export { previewPatch } from './tools/apply-patch.js';
105
+ export { applyTextEdit } from './tools/edit-file.js';
106
+ export { catalogEntry, loadModelCatalog, type CatalogEntry, type CatalogSource } from './catalog/models-dev.js';
107
+ export { isContextOverflowError, retryDecision, withModelRetry } from './agent/retry.js';
103
108
  export type { PatchPreview } from './tools/apply-patch.js';
104
109
  export { applyPatchTool, parsePatch, patchedPaths } from './tools/apply-patch.js';
105
110
  export { buildEmbedder, type EmbedderSpec } from './semantic/embedder.js';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { Agent, applySandbox } from './agent/agent.js';
2
2
  export { LoopGuard } from './agent/loop-guard.js';
3
3
  export { enhancePrompt } from './agent/enhance-prompt.js';
4
+ export { generateShellCommand, shellKindOf, commandRisk } from './agent/shell-command.js';
4
5
  export { stripThinkTags } from './agent/think-tags.js';
5
6
  export { createGitHubCreateTool, createGitHubReadTool, createGitHubTools, githubFromEnv, parseRemote, } from './github/github-tools.js';
6
7
  export { createWebSearchTool, searchWeb, WEB_SEARCH_PROVIDERS, webSearchFromEnv, webSearchProblem, } from './tools/web-search.js';
@@ -80,6 +81,9 @@ export { detectStackFacts, stackBlock, MAX_STACK_LIBRARIES } from './agent/stack
80
81
  export { pickAuditors } from './tools/dependency-audit.js';
81
82
  export { shellEnv } from './tools/shell-env.js';
82
83
  export { previewPatch } from './tools/apply-patch.js';
84
+ export { applyTextEdit } from './tools/edit-file.js';
85
+ export { catalogEntry, loadModelCatalog } from './catalog/models-dev.js';
86
+ export { isContextOverflowError, retryDecision, withModelRetry } from './agent/retry.js';
83
87
  export { applyPatchTool, parsePatch, patchedPaths } from './tools/apply-patch.js';
84
88
  export { buildEmbedder } from './semantic/embedder.js';
85
89
  export { parseExtensions } from './semantic/semantic-index.js';
@@ -1,4 +1,5 @@
1
1
  import { generateText } from 'ai';
2
+ import { withModelRetry } from '../agent/retry.js';
2
3
  import { stripThinkTags } from '../agent/think-tags.js';
3
4
  import { createLanguageModel } from '../providers.js';
4
5
  const MAX_DIGEST_CHARS = 600;
@@ -38,7 +39,7 @@ export async function generateSessionDigest(items, model, modelFactory = createL
38
39
  if (!model || !shouldDigest(items))
39
40
  return undefined;
40
41
  try {
41
- const result = await generateText({ model: modelFactory(model), prompt: `${INSTRUCTION}\n\nConversation:\n${transcript(items)}` });
42
+ const result = await withModelRetry(() => generateText({ model: modelFactory(model), prompt: `${INSTRUCTION}\n\nConversation:\n${transcript(items)}`, maxRetries: 0 }));
42
43
  return cleanDigest(result.text);
43
44
  }
44
45
  catch {
package/dist/pricing.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { catalogEntry } from './catalog/models-dev.js';
1
2
  import { overrideCost } from './model-overrides.js';
2
3
  /**
3
4
  * Pricing table. Keys are matched with case-insensitive substring matching
@@ -120,6 +121,17 @@ export function lookupPricing(model, override) {
120
121
  return { ...cache, ...configured, known: true };
121
122
  if (typeof model !== 'string' && LOCAL_PROVIDERS.has(model.provider))
122
123
  return { input: 0, output: 0, known: true };
124
+ const listed = typeof model === 'string' ? undefined : catalogEntry(model)?.cost;
125
+ if (listed && listed.input > 0) {
126
+ return {
127
+ ...cache,
128
+ input: listed.input,
129
+ output: listed.output,
130
+ ...(listed.cacheRead !== undefined ? { cacheReadMultiplier: listed.cacheRead / listed.input } : {}),
131
+ ...(listed.cacheWrite !== undefined ? { cacheWriteMultiplier: listed.cacheWrite / listed.input } : {}),
132
+ known: true,
133
+ };
134
+ }
123
135
  const id = (typeof model === 'string' ? model : model.model).toLowerCase();
124
136
  // Strip only the OpenRouter-style 'models/' prefix so 'models/gemini-1.5-pro'
125
137
  // resolves the same way as 'gemini-1.5-pro'. We do NOT strip provider prefixes
@@ -0,0 +1,24 @@
1
+ import type { CommandSandbox } from '../types.js';
2
+ export type SandboxedCommand = {
3
+ kind: 'wrapped';
4
+ command: string;
5
+ } | {
6
+ kind: 'unavailable';
7
+ command: string;
8
+ } | {
9
+ kind: 'refused';
10
+ reason: string;
11
+ };
12
+ /** Quote one argument for /bin/sh. */
13
+ export declare function shellQuote(arg: string): string;
14
+ interface Options {
15
+ cwd: string;
16
+ sandbox: CommandSandbox;
17
+ platform: NodeJS.Platform;
18
+ tmpDir: string;
19
+ findBinary?: (name: string) => string | undefined;
20
+ realpath?: (p: string) => string;
21
+ }
22
+ /** The command line that runs `command` inside the sandbox, or why it cannot be sandboxed. */
23
+ export declare function sandboxCommand(command: string, opts: Options): SandboxedCommand;
24
+ export {};
@@ -0,0 +1,73 @@
1
+ /**
2
+ * OS sandbox for run_command (after Kilo's kilo-sandbox): bubblewrap on Linux, sandbox-exec (Seatbelt) on macOS.
3
+ * Writes are confined to the workspace, the temp folder and any extra folders the user named; the workspace's .git
4
+ * stays read-only, since a hook written there would later run outside the sandbox; the network is cut unless
5
+ * allowed. Windows has no comparable tool, so there the command runs as before (approval still applies).
6
+ */
7
+ import { existsSync, realpathSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ import { resolveOnPath } from '../lsp/client.js';
10
+ /** Quote one argument for /bin/sh. */
11
+ export function shellQuote(arg) {
12
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
13
+ }
14
+ function defaultFind(name) {
15
+ for (const dir of ['/usr/bin', '/bin', '/usr/local/bin']) {
16
+ const full = path.posix.join(dir, name);
17
+ if (existsSync(full))
18
+ return full;
19
+ }
20
+ return resolveOnPath(name, process.env, process.platform);
21
+ }
22
+ function safeRealpath(p) {
23
+ try {
24
+ return realpathSync(p);
25
+ }
26
+ catch {
27
+ return p;
28
+ }
29
+ }
30
+ /** The command line that runs `command` inside the sandbox, or why it cannot be sandboxed. */
31
+ export function sandboxCommand(command, opts) {
32
+ const find = opts.findBinary ?? defaultFind;
33
+ const real = opts.realpath ?? safeRealpath;
34
+ if (opts.platform === 'linux') {
35
+ const bwrap = find('bwrap');
36
+ if (!bwrap)
37
+ return { kind: 'refused', reason: 'The command sandbox is on, but bubblewrap (bwrap) is not installed. Install it (apt install bubblewrap) or turn the sandbox off.' };
38
+ const writable = [opts.cwd, opts.tmpDir, ...(opts.sandbox.extraWritable ?? [])];
39
+ const gitDir = path.join(opts.cwd, '.git');
40
+ const args = [
41
+ bwrap,
42
+ '--die-with-parent',
43
+ '--new-session',
44
+ '--unshare-pid',
45
+ ...(opts.sandbox.network ? [] : ['--unshare-net']),
46
+ '--ro-bind', '/', '/',
47
+ '--dev', '/dev',
48
+ '--proc', '/proc',
49
+ ...writable.flatMap((p) => ['--bind', p, p]),
50
+ ...(existsSync(gitDir) ? ['--ro-bind', gitDir, gitDir] : []),
51
+ '--chdir', opts.cwd,
52
+ '--', '/bin/sh', '-c', command,
53
+ ];
54
+ return { kind: 'wrapped', command: args.map(shellQuote).join(' ') };
55
+ }
56
+ if (opts.platform === 'darwin') {
57
+ const exec = find('sandbox-exec');
58
+ if (!exec)
59
+ return { kind: 'refused', reason: 'The command sandbox is on, but sandbox-exec was not found. Turn the sandbox off to run commands.' };
60
+ const quote = (p) => JSON.stringify(real(p));
61
+ const writable = [opts.cwd, opts.tmpDir, ...(opts.sandbox.extraWritable ?? [])].map((p) => `(subpath ${quote(p)})`);
62
+ const profile = [
63
+ '(version 1)',
64
+ '(allow default)',
65
+ '(deny file-write*)',
66
+ `(allow file-write* ${writable.join(' ')} (subpath "/private/var/folders") (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr") (regex #"^/dev/tty"))`,
67
+ `(deny file-write* (subpath ${quote(path.posix.join(opts.cwd, '.git'))}))`,
68
+ ...(opts.sandbox.network ? [] : ['(deny network* (remote ip))']),
69
+ ].join('\n');
70
+ return { kind: 'wrapped', command: [exec, '-p', profile, '/bin/sh', '-c', command].map(shellQuote).join(' ') };
71
+ }
72
+ return { kind: 'unavailable', command };
73
+ }
@@ -12,4 +12,12 @@ export interface EditFileResult {
12
12
  bytes: number;
13
13
  }
14
14
  export declare const editFileTool: ToolDefinition<z.infer<typeof params>, EditFileResult>;
15
+ /**
16
+ * What edit_file writes for `original`; throws the message the model sees when old_text cannot be placed.
17
+ * Shared with the chat's diff preview so the two never disagree.
18
+ */
19
+ export declare function applyTextEdit(original: string, oldTextIn: string, newTextIn: string, replaceAll: boolean, file: string): {
20
+ content: string;
21
+ replacements: number;
22
+ };
15
23
  export {};
@@ -24,29 +24,77 @@ export const editFileTool = {
24
24
  throw new Error(`File not found: ${args.path}`);
25
25
  throw err;
26
26
  });
27
- const occurrences = countOccurrences(original, args.old_text);
28
- if (occurrences === 0) {
29
- throw new Error(`old_text not found in ${args.path}. Re-read the file (it may have changed) and try again.`);
30
- }
31
- const replaceAll = args.replace_all === true;
32
- if (!replaceAll && occurrences > 1) {
33
- throw new Error(`old_text matches ${occurrences} times in ${args.path}. Pass more context to make it unique, or set replace_all=true to replace all occurrences.`);
34
- }
35
- const updated = replaceAll
36
- ? original.split(args.old_text).join(args.new_text)
37
- // Use a replacer FUNCTION so new_text is inserted literally: a plain
38
- // string 2nd arg to String.prototype.replace still honors special
39
- // patterns like $&, $1, $$ even though old_text isn't a regex.
40
- : original.replace(args.old_text, () => args.new_text);
41
- const bytes = encodeText(updated, encoding);
27
+ const { content, replacements } = applyTextEdit(original, args.old_text, args.new_text, args.replace_all === true, args.path);
28
+ const bytes = encodeText(content, encoding);
42
29
  await writeFile(resolved, bytes);
43
30
  return {
44
31
  path: resolved,
45
- replacements: replaceAll ? occurrences : 1,
32
+ replacements,
46
33
  bytes: bytes.length,
47
34
  };
48
35
  },
49
36
  };
37
+ /**
38
+ * What edit_file writes for `original`; throws the message the model sees when old_text cannot be placed.
39
+ * Shared with the chat's diff preview so the two never disagree.
40
+ */
41
+ export function applyTextEdit(original, oldTextIn, newTextIn, replaceAll, file) {
42
+ // read_file shows a CRLF file without its \r, so the model sends LF text: match on LF, write back the file's own ending.
43
+ const crlf = original.includes('\r\n');
44
+ const content = crlf ? toLf(original) : original;
45
+ const oldText = toLf(oldTextIn);
46
+ const newText = toLf(newTextIn);
47
+ const occurrences = countOccurrences(content, oldText);
48
+ let updated;
49
+ if (occurrences === 0) {
50
+ const span = replaceAll ? undefined : trimmedLineMatch(content, oldText, file);
51
+ if (!span) {
52
+ throw new Error(`old_text not found in ${file}. Re-read the file (it may have changed) and try again.`);
53
+ }
54
+ updated = content.slice(0, span.start) + newText + content.slice(span.end);
55
+ }
56
+ else {
57
+ if (!replaceAll && occurrences > 1) {
58
+ throw new Error(`old_text matches ${occurrences} times in ${file}. Pass more context to make it unique, or set replace_all=true to replace all occurrences.`);
59
+ }
60
+ updated = replaceAll
61
+ ? content.split(oldText).join(newText)
62
+ // Use a replacer FUNCTION so new_text is inserted literally: a plain
63
+ // string 2nd arg to String.prototype.replace still honors special
64
+ // patterns like $&, $1, $$ even though old_text isn't a regex.
65
+ : content.replace(oldText, () => newText);
66
+ }
67
+ return { content: crlf ? updated.replace(/\n/g, '\r\n') : updated, replacements: replaceAll ? occurrences : 1 };
68
+ }
69
+ function toLf(text) {
70
+ return text.replace(/\r\n/g, '\n');
71
+ }
72
+ /**
73
+ * Fallback when the exact text is absent: the block of whole lines whose trimmed text equals old_text's trimmed
74
+ * lines (a model often gets indentation or trailing spaces wrong). Only a unique block counts.
75
+ */
76
+ function trimmedLineMatch(content, oldText, file) {
77
+ const want = oldText.split('\n').map((l) => l.trim());
78
+ if (want.length > 1 && want[want.length - 1] === '')
79
+ want.pop();
80
+ if (want.every((l) => l === ''))
81
+ return undefined;
82
+ const lines = content.split('\n');
83
+ const starts = [];
84
+ for (let i = 0; i + want.length <= lines.length; i++) {
85
+ if (want.every((w, j) => lines[i + j].trim() === w))
86
+ starts.push(i);
87
+ }
88
+ if (starts.length === 0)
89
+ return undefined;
90
+ if (starts.length > 1) {
91
+ throw new Error(`old_text matches more than one place in ${file} once leading and trailing spaces are ignored. Pass more context to make it unique.`);
92
+ }
93
+ const offsetOf = (line) => lines.slice(0, line).reduce((n, l) => n + l.length + 1, 0);
94
+ const first = starts[0];
95
+ const last = first + want.length - 1;
96
+ return { start: offsetOf(first), end: offsetOf(last) + lines[last].length };
97
+ }
50
98
  function countOccurrences(haystack, needle) {
51
99
  if (needle.length === 0)
52
100
  return 0;
@@ -1,5 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { tmpdir } from 'node:os';
2
3
  import { z } from 'zod';
4
+ import { sandboxCommand } from './command-sandbox.js';
3
5
  import { checkPolicyViolation } from './policy.js';
4
6
  import { resolveAndCheckPath } from './shared.js';
5
7
  import { shellEnv } from './shell-env.js';
@@ -64,10 +66,18 @@ export const runCommandTool = {
64
66
  if (violation)
65
67
  throw new Error(`[sandbox] ${violation}`);
66
68
  const timeoutMs = args.timeout_ms ?? DEFAULT_TIMEOUT_MS;
69
+ // The OS sandbox wraps the command line; the result still reports the command the model asked for.
70
+ let commandLine = args.command;
71
+ if (ctx.commandSandbox) {
72
+ const boxed = sandboxCommand(args.command, { cwd, sandbox: ctx.commandSandbox, platform: process.platform, tmpDir: tmpdir() });
73
+ if (boxed.kind === 'refused')
74
+ throw new Error(`[sandbox] ${boxed.reason}`);
75
+ commandLine = boxed.command;
76
+ }
67
77
  if (ctx.commandRunner) {
68
78
  const started = Date.now();
69
79
  const out = await ctx.commandRunner({
70
- command: args.command,
80
+ command: commandLine,
71
81
  cwd,
72
82
  timeoutMs,
73
83
  ...(ctx.abortSignal ? { signal: ctx.abortSignal } : {}),
@@ -91,7 +101,7 @@ export const runCommandTool = {
91
101
  }
92
102
  return new Promise((resolve, reject) => {
93
103
  const started = Date.now();
94
- const child = spawn(args.command, {
104
+ const child = spawn(commandLine, {
95
105
  shell: true,
96
106
  cwd,
97
107
  // Windows: never run a same-named program the repository ships in its root (see shell-env.ts).