@bevel-software/platform-core-backend 0.24.0 → 0.25.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.
Files changed (29) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/modules/database/migrate.d.ts +87 -1
  6. package/dist/modules/database/migrate.d.ts.map +1 -1
  7. package/dist/modules/database/migrate.js +166 -48
  8. package/dist/modules/database/migrate.js.map +1 -1
  9. package/dist/modules/kb-fs/locking-filesystem.d.ts +17 -0
  10. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  11. package/dist/modules/kb-fs/locking-filesystem.js +29 -0
  12. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  13. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  14. package/dist/modules/tool-manuals/tool-manuals.service.js +30 -0
  15. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  16. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  17. package/dist/modules/workspace/workspace.tools.js +58 -21
  18. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  19. package/kb-template/AGENTS.md +40 -0
  20. package/package.json +3 -3
  21. package/src/index.ts +7 -0
  22. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +223 -4
  23. package/src/modules/database/migrate.ts +238 -52
  24. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +93 -0
  25. package/src/modules/kb-fs/locking-filesystem.ts +37 -0
  26. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +151 -0
  27. package/src/modules/tool-manuals/tool-manuals.service.ts +37 -0
  28. package/src/modules/workspace/__tests__/workspace.tools.test.ts +112 -1
  29. package/src/modules/workspace/workspace.tools.ts +59 -21
@@ -183,6 +183,157 @@ describe('ToolManualService', () => {
183
183
  expect(manual).toBeNull();
184
184
  });
185
185
 
186
+ test('resolves an inline tool that calls Google as a service account, and surfaces the key variable', async () => {
187
+ // The auth block is what makes the call; the key it names has to reach the
188
+ // secrets UI as a shared variable or nobody can store it.
189
+ await writeFile(
190
+ join(root, wsId, KB_DIR, 'Plugins', 'google_ads.tool'),
191
+ JSON.stringify({
192
+ id: 'google_ads',
193
+ type: 'inline',
194
+ tools: [
195
+ {
196
+ name: 'list_accessible_customers',
197
+ description: 'List the customers the service account can reach.',
198
+ inputs: { type: 'object', properties: {} },
199
+ outputs: { type: 'object', properties: {} },
200
+ tool_call_template: {
201
+ call_template_type: 'http',
202
+ http_method: 'GET',
203
+ url: 'https://googleads.googleapis.com/v22/customers:listAccessibleCustomers',
204
+ headers: { 'developer-token': '${DEVELOPER_TOKEN}' },
205
+ auth: {
206
+ auth_type: 'google_service_account',
207
+ credentials: '${GOOGLE_SA_KEY}',
208
+ scopes: 'https://www.googleapis.com/auth/adwords',
209
+ },
210
+ },
211
+ },
212
+ ],
213
+ }),
214
+ );
215
+
216
+ const manual = await svc().resolveInlineManual('user@x.eu', 'google_ads');
217
+ const tools = (manual as { tools?: { name?: string; tool_call_template?: { auth?: unknown } }[] } | null)?.tools ?? [];
218
+ expect(tools.map((t) => t.name)).toEqual(['list_accessible_customers']);
219
+ // The auth block is what makes the call, so resolving must carry it through intact.
220
+ expect(tools[0]?.tool_call_template?.auth).toEqual({
221
+ auth_type: 'google_service_account',
222
+ credentials: '${GOOGLE_SA_KEY}',
223
+ scopes: 'https://www.googleapis.com/auth/adwords',
224
+ });
225
+
226
+ const summary = (await svc().listAccessible('user@x.eu')).find((m) => m.name === 'google_ads')!;
227
+ expect(summary.variables?.find((v) => v.name === 'GOOGLE_SA_KEY')?.scope).toBe('admin');
228
+ });
229
+
230
+ test('refuses a Google service-account auth block where no token would be sent, and says where', async () => {
231
+ // Only the `http` protocol mints the token. On any other template the
232
+ // block validates and is then sent as no credentials at all, so the file
233
+ // is refused when it is read rather than left to fail at Google.
234
+ const serviceAccount = {
235
+ auth_type: 'google_service_account',
236
+ credentials: '${GOOGLE_SA_KEY}',
237
+ scopes: 'https://www.googleapis.com/auth/adwords',
238
+ };
239
+ const inlineOver = (callTemplateType: string) =>
240
+ JSON.stringify({
241
+ type: 'inline',
242
+ tools: [
243
+ {
244
+ name: 'events',
245
+ description: 'Stream events.',
246
+ inputs: { type: 'object', properties: {} },
247
+ outputs: { type: 'object', properties: {} },
248
+ tool_call_template: { call_template_type: callTemplateType, url: 'https://api.example.com/events', auth: serviceAccount },
249
+ },
250
+ ],
251
+ });
252
+ const plugins = join(root, wsId, KB_DIR, 'Plugins');
253
+ await writeFile(join(plugins, 'over_sse.tool'), inlineOver('sse'));
254
+ await writeFile(join(plugins, 'over_streamable.tool'), inlineOver('streamable_http'));
255
+ // Not on a call template at all: a discovered manual takes only a url and headers.
256
+ await writeFile(
257
+ join(plugins, 'discovered.tool'),
258
+ JSON.stringify({ type: 'http', url: 'https://api.example.com/utcp', auth: serviceAccount }),
259
+ );
260
+ // Looking like an `http` call template is not enough: a discovered manual
261
+ // reads neither a block at its root nor a `tools` list, whatever they say.
262
+ await writeFile(
263
+ join(plugins, 'dressed_root.tool'),
264
+ JSON.stringify({ type: 'http', url: 'https://api.example.com/utcp', call_template_type: 'http', auth: serviceAccount }),
265
+ );
266
+ await writeFile(
267
+ join(plugins, 'unread_tools.tool'),
268
+ JSON.stringify({ ...JSON.parse(inlineOver('http')), type: 'http', url: 'https://api.example.com/utcp' }),
269
+ );
270
+ await writeFile(join(plugins, 'over_http.tool'), inlineOver('http'));
271
+
272
+ const catalog = await svc().listAccessibleCatalog('user@x.eu');
273
+
274
+ const reasonFor = (file: string) => catalog.invalid.find((i) => i.path === `Plugins/${file}`)?.reason ?? '';
275
+ expect(reasonFor('over_sse.tool')).toContain('a `sse` call template');
276
+ expect(reasonFor('over_streamable.tool')).toContain('a `streamable_http` call template');
277
+ expect(reasonFor('discovered.tool')).toContain("not a call template's `auth`");
278
+ expect(reasonFor('dressed_root.tool')).toContain('no tool is called through');
279
+ expect(reasonFor('unread_tools.tool')).toContain('no tool is called through');
280
+ for (const file of ['over_sse.tool', 'over_streamable.tool', 'discovered.tool', 'dressed_root.tool', 'unread_tools.tool']) {
281
+ expect(reasonFor(file)).toContain('`call_template_type: http`');
282
+ }
283
+ // The one place it works is left alone.
284
+ expect(reasonFor('over_http.tool')).toBe('');
285
+ expect(catalog.tools.map((m) => m.name)).toContain('overhttp');
286
+ });
287
+
288
+ test('refuses a `.tool` that holds a service-account key itself, wherever the block sits, and never repeats it', async () => {
289
+ // A `.tool` is committed and read by everyone who can read the knowledge
290
+ // base, so the key lives in the vault and the file only names it.
291
+ const key = JSON.stringify({ type: 'service_account', client_email: 'ads@proj.iam.gserviceaccount.com', private_key: 'MIIEvQIBADANBgkq-NOT-A-REAL-KEY' });
292
+ const withCredentials = (credentials: unknown, callTemplateType = 'http') =>
293
+ JSON.stringify({
294
+ type: 'inline',
295
+ tools: [
296
+ {
297
+ name: 'list',
298
+ description: 'List.',
299
+ inputs: { type: 'object', properties: {} },
300
+ outputs: { type: 'object', properties: {} },
301
+ tool_call_template: {
302
+ call_template_type: callTemplateType,
303
+ http_method: 'GET',
304
+ url: 'https://googleads.googleapis.com/v22/customers',
305
+ auth: { auth_type: 'google_service_account', credentials, scopes: 'https://www.googleapis.com/auth/adwords' },
306
+ },
307
+ },
308
+ ],
309
+ });
310
+ const plugins = join(root, wsId, KB_DIR, 'Plugins');
311
+ await writeFile(join(plugins, 'pasted_key.tool'), withCredentials(key));
312
+ await writeFile(join(plugins, 'pasted_base64.tool'), withCredentials(Buffer.from(key).toString('base64')));
313
+ // A variable with the key, or anything else, written around it is still text in the file.
314
+ await writeFile(join(plugins, 'beside_a_variable.tool'), withCredentials('${GOOGLE_SA_KEY}' + key));
315
+ // In a block nothing acts on, the key is just as readable: that it is there is said first.
316
+ await writeFile(join(plugins, 'key_over_sse.tool'), withCredentials(key, 'sse'));
317
+ await writeFile(join(plugins, 'braced.tool'), withCredentials('${GOOGLE_SA_KEY}'));
318
+ await writeFile(join(plugins, 'bare.tool'), withCredentials(' $GOOGLE_SA_KEY '));
319
+
320
+ const catalog = await svc().listAccessibleCatalog('user@x.eu');
321
+
322
+ const reasonFor = (file: string) => catalog.invalid.find((i) => i.path === `Plugins/${file}`)?.reason ?? '';
323
+ for (const file of ['pasted_key.tool', 'pasted_base64.tool', 'beside_a_variable.tool', 'key_over_sse.tool']) {
324
+ expect(reasonFor(file), file).toContain('something other than a vault variable as its `credentials`');
325
+ expect(reasonFor(file), file).toContain('Secrets Vault');
326
+ }
327
+ // Nothing of what was pasted comes back in the answer, in either spelling.
328
+ const answer = JSON.stringify(catalog);
329
+ expect(answer).not.toMatch(/NOT-A-REAL-KEY|iam\.gserviceaccount/);
330
+ expect(answer).not.toContain(Buffer.from(key).toString('base64').slice(0, 40));
331
+ // Either spelling of one variable is what the field is for.
332
+ expect(reasonFor('braced.tool')).toBe('');
333
+ expect(reasonFor('bare.tool')).toBe('');
334
+ expect(catalog.tools.map((m) => m.name)).toEqual(expect.arrayContaining(['braced', 'bare']));
335
+ });
336
+
186
337
  test('manual names are alphanumeric (no underscores) for variable namespacing', async () => {
187
338
  root = await mkdtemp(join(tmpdir(), 'tools2-'));
188
339
  const tools = join(root, wsId, KB_DIR, 'Plugins');
@@ -16,6 +16,9 @@ import {
16
16
  DefaultVariableSubstitutor,
17
17
  type CallTemplate,
18
18
  } from '@utcp/sdk';
19
+ // Also a side effect: loading the package registers the 'google_service_account'
20
+ // auth type, so a `.tool` naming it validates here.
21
+ import { findUnservedGoogleServiceAccountAuth, holdsLiteralGoogleServiceAccountKey } from '@bevel-software/platform-mcp-core';
19
22
  import { descriptorsFromMcpJson } from './mcp-json-discovery.js';
20
23
  import type { PluginSource } from '../plugins/discovery/plugin-source.js';
21
24
  import type { WorkspaceService } from '../workspace/workspace.service.js';
@@ -1196,6 +1199,40 @@ export function normalizeToolManual(
1196
1199
  descriptor.remote = false;
1197
1200
  }
1198
1201
 
1202
+ // A service account's KEY IS NEVER WRITTEN IN A `.tool`. The file is
1203
+ // knowledge-base content: committed, and read by everyone and every agent
1204
+ // that can read the knowledge base. So `credentials` names the vault
1205
+ // variable and nothing else, and a file that holds anything else there is
1206
+ // refused before the block's placement is even looked at: a key in a block
1207
+ // nothing acts on is just as readable. The refusal does not quote the value.
1208
+ if (holdsLiteralGoogleServiceAccountKey(obj)) {
1209
+ throw new Error(
1210
+ '`auth_type: google_service_account` has something other than a vault variable as its `credentials`. ' +
1211
+ 'Write `credentials: ${GOOGLE_SA_KEY}` and store the key in the Secrets Vault under that name: a `.tool` is ' +
1212
+ 'read by everyone who can read the knowledge base. If a real key was saved in this file, treat it as ' +
1213
+ 'exposed and replace it at Google; removing it here does not take it out of the history.',
1214
+ );
1215
+ }
1216
+
1217
+ // A Google service-account `auth` block works on one thing: an inline tool's
1218
+ // `http` call template. Anywhere else it validates and is then sent as no
1219
+ // credentials at all, so the tool would save cleanly and call Google
1220
+ // unauthenticated, with Google's 401 the only hint. Refused here instead,
1221
+ // naming where the block was found. The templates below are the only ones
1222
+ // this file's tools are called through: a `tools` list on a file that is not
1223
+ // `inline` is never read, so nothing in it counts.
1224
+ const toolCallTemplates =
1225
+ type === 'inline' && Array.isArray(obj.tools)
1226
+ ? obj.tools.map((tool: unknown) => (tool && typeof tool === 'object' ? (tool as Record<string, unknown>).tool_call_template : undefined))
1227
+ : [];
1228
+ const unserved = findUnservedGoogleServiceAccountAuth(obj, toolCallTemplates);
1229
+ if (unserved) {
1230
+ throw new Error(
1231
+ `\`auth_type: google_service_account\` is on ${unserved}, where no token would be sent. ` +
1232
+ "It works only as the `auth` of an inline tool's `tool_call_template` with `call_template_type: http`.",
1233
+ );
1234
+ }
1235
+
1199
1236
  if (type === 'inline') {
1200
1237
  const tools = Array.isArray(obj.tools) ? obj.tools : undefined;
1201
1238
  if (!tools) throw new Error('inline `.tool` must have a `tools` array');
@@ -183,6 +183,25 @@ async function start(
183
183
  await check?.();
184
184
  return plainWriteFile(path, content, options as never);
185
185
  };
186
+ // `LockingFilesystem.rewriteFile`: read, compute and write with the path
187
+ // "locked". The other writer runs first — where the real filesystem would be
188
+ // acquiring the lock — so `rewrite` reads what that writer left.
189
+ (fs as unknown as Record<string, unknown>).rewriteFile = async (
190
+ path: string,
191
+ rewrite: (current: Buffer | null) => string | Promise<string>,
192
+ ) => {
193
+ await runRaceHook();
194
+ // Absent is null; any other read failure is a failure, as in the real one.
195
+ const current = await fs.readFile(path).then(
196
+ (c) => (Buffer.isBuffer(c) ? c : Buffer.from(String(c), 'utf8')),
197
+ (err: unknown) => {
198
+ const code = (err as NodeJS.ErrnoException).code;
199
+ if (code === 'ENOENT' || code === 'ENOTDIR') return null;
200
+ throw err;
201
+ },
202
+ );
203
+ return plainWriteFile(path, await rewrite(current));
204
+ };
186
205
  (fs as unknown as Record<string, unknown>).writeFiles = async (
187
206
  writes: { path: string; content: string }[],
188
207
  _summary: string,
@@ -363,6 +382,98 @@ describe('workspace file primitives', () => {
363
382
  expect((await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'nope', new_string: 'x' })).status).toBe(400);
364
383
  });
365
384
 
385
+ it('edit_file writes new_string exactly as sent, `$` patterns included', async () => {
386
+ const base = await start();
387
+ const res = await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/a.md`, old_string: 'world', new_string: "cost: $& $1 $' $$" });
388
+ expect(res.status).toBe(200);
389
+ expect(String(await fs.readFile(`${KB_DIR}/a.md`))).toBe("hello\ncost: $& $1 $' $$\n");
390
+ });
391
+
392
+ /**
393
+ * An edit is a promise about the text it replaces, so that text has to be
394
+ * found in the file as it is when the write lands — read with the path's
395
+ * lock held, not at a preflight anybody may invalidate. `raceHook` is the
396
+ * other writer, running exactly where the real filesystem acquires the lock.
397
+ */
398
+ describe('edit_file looks for old_string in the file the write actually lands on', () => {
399
+ const EMPTY_OWNER = '# Assignee\n\n# Log';
400
+ const claim = (base: string, who: string) =>
401
+ post(`${base}/api/agent/tools/edit_file`, {
402
+ path: `${KB_DIR}/ticket.md`,
403
+ old_string: EMPTY_OWNER,
404
+ new_string: `# Assignee\n${who}\n\n# Log`,
405
+ });
406
+
407
+ it('refuses when another writer replaced that text after the preflight, and keeps their write', async () => {
408
+ const base = await start();
409
+ await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
410
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\ncoder1\n\n# Log\n- filed\n'); };
411
+ const res = await claim(base, 'coder2');
412
+ expect(res.status).toBe(400);
413
+ expect(((await res.json()) as { error: string }).error).toContain('old_string not found');
414
+ expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe('# Assignee\ncoder1\n\n# Log\n- filed\n');
415
+ });
416
+
417
+ it('applies the edit to what the other writer left when the text is still there', async () => {
418
+ const base = await start();
419
+ await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
420
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n- a line added meanwhile\n'); };
421
+ const res = await claim(base, 'coder2');
422
+ expect(res.status).toBe(200);
423
+ // Their line is kept: the new content was computed from the file as it was under the lock.
424
+ expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe('# Assignee\ncoder2\n\n# Log\n- filed\n- a line added meanwhile\n');
425
+ });
426
+
427
+ it('refuses when the text became ambiguous after the preflight', async () => {
428
+ const base = await start();
429
+ await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
430
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, `${EMPTY_OWNER}\n${EMPTY_OWNER}\n`); };
431
+ const res = await claim(base, 'coder2');
432
+ expect(res.status).toBe(400);
433
+ expect(((await res.json()) as { error: string }).error).toContain('appears 2 times');
434
+ expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe(`${EMPTY_OWNER}\n${EMPTY_OWNER}\n`);
435
+ });
436
+
437
+ it('answers not found when the file was deleted after the preflight, and does not recreate it', async () => {
438
+ const base = await start();
439
+ await fs.writeFile(`${KB_DIR}/ticket.md`, '# Assignee\n\n# Log\n- filed\n');
440
+ raceHook = async () => { await fs.deleteFile(`${KB_DIR}/ticket.md`); };
441
+ const res = await claim(base, 'coder2');
442
+ expect(res.status).toBe(404);
443
+ await expect(fs.readFile(`${KB_DIR}/ticket.md`)).rejects.toThrow();
444
+ });
445
+
446
+ // Every question the tool asks of the file is asked of the bytes it is
447
+ // about to replace. An extensionless file may hold anything, and the app's
448
+ // own upload can swap it for a binary between the preflight and the lock.
449
+ // The binary still CONTAINS `old_string` here, so nothing but the
450
+ // "may these bytes be edited as text" question can refuse it.
451
+ it('refuses when the file became binary after the preflight, and leaves those bytes alone', async () => {
452
+ const base = await start();
453
+ await fs.writeFile(`${KB_DIR}/TICKET`, '# Assignee\n\n# Log\n- filed\n');
454
+ const binary = Buffer.concat([Buffer.from([0x00, 0xff, 0xfe, 0x00]), Buffer.from(`${EMPTY_OWNER}\n`, 'utf8')]);
455
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/TICKET`, binary); };
456
+ const res = await post(`${base}/api/agent/tools/edit_file`, {
457
+ path: `${KB_DIR}/TICKET`,
458
+ old_string: EMPTY_OWNER,
459
+ new_string: '# Assignee\ncoder2\n\n# Log',
460
+ });
461
+ expect(res.status).toBe(415);
462
+ expect(await res.json()).toMatchObject({ kind: 'binary_not_writable' });
463
+ const after = await fs.readFile(`${KB_DIR}/TICKET`);
464
+ expect(Buffer.from(after as Buffer).equals(binary)).toBe(true);
465
+ });
466
+
467
+ it('counts replace_all over the file as it is under the lock', async () => {
468
+ const base = await start();
469
+ await fs.writeFile(`${KB_DIR}/ticket.md`, 'x x\n');
470
+ raceHook = async () => { await fs.writeFile(`${KB_DIR}/ticket.md`, 'x x x\n'); };
471
+ const res = await post(`${base}/api/agent/tools/edit_file`, { path: `${KB_DIR}/ticket.md`, old_string: 'x', new_string: 'y', replace_all: true });
472
+ expect(await res.json()).toMatchObject({ replaced: 3 });
473
+ expect(String(await fs.readFile(`${KB_DIR}/ticket.md`))).toBe('y y y\n');
474
+ });
475
+ });
476
+
366
477
  it('list_files + file_stat', async () => {
367
478
  const base = await start();
368
479
  const root = (await (await post(`${base}/api/agent/tools/list_files`, {})).json()) as { entries: { name: string }[] };
@@ -3445,7 +3556,7 @@ describe('a write refused for permissions says whether and how to propose it', (
3445
3556
  throw err;
3446
3557
  };
3447
3558
  const target = fs as unknown as Record<string, unknown>;
3448
- for (const m of ['writeFile', 'deleteFile', 'moveFile', 'mkdir']) target[m] = refuse;
3559
+ for (const m of ['writeFile', 'rewriteFile', 'deleteFile', 'moveFile', 'mkdir']) target[m] = refuse;
3449
3560
  target.copyFile = async (_src: string, dest: string) => refuse(dest);
3450
3561
  // `delete_folder` lands through the same batch with NO writes and the
3451
3562
  // folder's files as `deletes` — in production the refusal comes from the
@@ -353,17 +353,17 @@ function assertNotDocumentEdit(readers: FileReaderRegistry, path: string): void
353
353
  *
354
354
  * Costs one read of the existing file, and only for readers that ask the
355
355
  * question. A path with nothing at it is a CREATE: there is nothing to destroy.
356
- * Returns the bytes it read (so a caller that needs the content next —
357
- * `edit_file` — does not read the file a second time), or undefined when it
358
- * had no reason to read or nothing existed.
356
+ * For the tools that REPLACE a file without needing what it held (`write_file`,
357
+ * `write_files`); `edit_file` holds the bytes already and asks
358
+ * `assertBytesTextEditable` of each reading it takes.
359
359
  */
360
360
  async function assertNotBinaryOverwrite(
361
361
  readers: FileReaderRegistry,
362
362
  path: string,
363
363
  fs: { readFile(p: string): Promise<string | Buffer> },
364
- ): Promise<Buffer | undefined> {
364
+ ): Promise<void> {
365
365
  const reader = readers.readerFor(path);
366
- if (reader.editRefusalForExisting === undefined) return undefined;
366
+ if (reader.editRefusalForExisting === undefined) return;
367
367
  let existing: Buffer;
368
368
  try {
369
369
  existing = asBytes(await fs.readFile(path));
@@ -372,12 +372,21 @@ async function assertNotBinaryOverwrite(
372
372
  // FileNotFoundError carry the disk's absence codes). Any other failure —
373
373
  // permissions, I/O — means the existing content could not be inspected:
374
374
  // propagate it rather than let the write destroy bytes the gate never saw.
375
- if (isAbsence(err)) return undefined; // nothing there yet
375
+ if (isAbsence(err)) return; // nothing there yet
376
376
  throw err;
377
377
  }
378
- const refusal = reader.editRefusalForExisting(existing, path);
378
+ assertBytesTextEditable(readers, path, existing);
379
+ }
380
+
381
+ /**
382
+ * The same refusal, over bytes the caller already holds. Split out so a tool
383
+ * that reads the file more than once — `edit_file`, before the lock and again
384
+ * under it — judges EVERY reading with the one rule, and the bytes it replaces
385
+ * are always bytes this gate has seen.
386
+ */
387
+ function assertBytesTextEditable(readers: FileReaderRegistry, path: string, existing: Buffer): void {
388
+ const refusal = readers.readerFor(path).editRefusalForExisting?.(existing, path) ?? null;
379
389
  if (refusal !== null) throw binaryNotWritable('binary', refusal);
380
- return existing;
381
390
  }
382
391
 
383
392
  /** What a write is ALLOWED to do at a path. `create` is the default everywhere. */
@@ -2367,20 +2376,49 @@ export function registerWorkspaceTools(
2367
2376
  const path = a.path as string;
2368
2377
  const oldStr = a.old_string as string;
2369
2378
  const newStr = a.new_string as string;
2370
- // The overwrite gate already read the file when its reader asked the
2371
- // binary question — reuse those bytes instead of reading twice.
2372
- const content = await orNotFound(path, async () => {
2373
- const existing = await assertNotBinaryOverwrite(readers, path, fs);
2374
- return asText(existing ?? (await fs.readFile(path)));
2375
- });
2376
- const count = oldStr ? content.split(oldStr).length - 1 : 0;
2377
- if (count === 0) throw new ToolError('old_string not found in the file.', 400);
2378
- if (count > 1 && a.replace_all !== true) {
2379
- throw new ToolError(`old_string appears ${count} times — add more context to make it unique, or set replace_all.`, 400);
2379
+ // Everything the tool decides about ONE reading of the file: may these
2380
+ // bytes be edited as text at all, is `old_string` there, is it unique,
2381
+ // and what the file becomes. One function, because the file is read
2382
+ // twice — before the lock and under it — and a reading that skipped any
2383
+ // of these questions would let bytes land that were never judged.
2384
+ // `split`/`join`, not `String.replace`, which reads `$&`, `$'`, `` $` ``
2385
+ // and `$$` in `new_string` as patterns and writes something the caller
2386
+ // never sent.
2387
+ const edit = (existing: Buffer): { updated: string; replaced: number } => {
2388
+ assertBytesTextEditable(readers, path, existing);
2389
+ const text = asText(existing);
2390
+ const pieces = oldStr ? text.split(oldStr) : [text];
2391
+ const count = pieces.length - 1;
2392
+ if (count === 0) throw new ToolError('old_string not found in the file.', 400);
2393
+ if (count > 1 && a.replace_all !== true) {
2394
+ throw new ToolError(`old_string appears ${count} times — add more context to make it unique, or set replace_all.`, 400);
2395
+ }
2396
+ return { updated: pieces.join(newStr), replaced: count };
2397
+ };
2398
+ // A first verdict before any lock is taken, so an ordinary refusal costs
2399
+ // no lock cycle. It is a verdict about a file anyone may still change.
2400
+ let result = edit(await orNotFound(path, async () => asBytes(await fs.readFile(path))));
2401
+ // The one the answer carries is taken again with the path's lock HELD,
2402
+ // over the bytes read there (`write: true` guarantees the locking
2403
+ // filesystem): read, verdict and write are one step nobody can get
2404
+ // between. Taken before the lock only, two callers replacing the same
2405
+ // text — two runners claiming a work item by filling its empty owner
2406
+ // field — were BOTH told their edit landed, and the second silently
2407
+ // overwrote the first. A filesystem without the method has no lock to
2408
+ // read under, so the first verdict stands.
2409
+ const locking = fs as unknown as {
2410
+ rewriteFile?(path: string, rewrite: (current: Buffer | null) => string): Promise<void>;
2411
+ };
2412
+ if (typeof locking.rewriteFile === 'function') {
2413
+ await locking.rewriteFile(path, (current) => {
2414
+ if (current === null) throw notFound(path);
2415
+ result = edit(current);
2416
+ return result.updated;
2417
+ });
2418
+ } else {
2419
+ await fs.writeFile(path, result.updated);
2380
2420
  }
2381
- const updated = a.replace_all === true ? content.split(oldStr).join(newStr) : content.replace(oldStr, newStr);
2382
- await fs.writeFile(path, updated);
2383
- return { path, replaced: a.replace_all === true ? count : 1, ...(await saveWarnings(ctx, path, updated)) };
2421
+ return { path, replaced: result.replaced, ...(await saveWarnings(ctx, path, result.updated)) };
2384
2422
  },
2385
2423
  });
2386
2424