@superjackfruitlabs/salaam 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-outcome.js +177 -17
  4. package/dist/src/acp.js +41 -7
  5. package/dist/src/agent-profiles.js +45 -0
  6. package/dist/src/agentpod-authority.js +169 -11
  7. package/dist/src/agentpod-default-config.js +12 -0
  8. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  9. package/dist/src/agentpod-work-decisions.js +108 -4
  10. package/dist/src/agentpod-work.js +1214 -127
  11. package/dist/src/board-reads.js +106 -0
  12. package/dist/src/children.js +83 -0
  13. package/dist/src/cli-help.js +27 -2
  14. package/dist/src/cli.js +87 -3
  15. package/dist/src/command-network.js +200 -0
  16. package/dist/src/command-sandbox.js +217 -0
  17. package/dist/src/execution-evidence.js +33 -1
  18. package/dist/src/host-bind.js +111 -0
  19. package/dist/src/host-breaker.js +88 -0
  20. package/dist/src/host-start.js +41 -1
  21. package/dist/src/host.js +192 -6
  22. package/dist/src/house-extension.js +162 -57
  23. package/dist/src/local-coding.js +214 -18
  24. package/dist/src/local-edit-helper.js +25 -0
  25. package/dist/src/local-undo.js +25 -16
  26. package/dist/src/managed.js +1 -1
  27. package/dist/src/operator-mcp-cli.js +107 -0
  28. package/dist/src/operator-mcp.js +342 -0
  29. package/dist/src/persona.js +31 -0
  30. package/dist/src/pi.js +22 -1
  31. package/dist/src/protocol.js +5 -2
  32. package/dist/src/rate-limit-retries.js +62 -0
  33. package/dist/src/recurring-work.js +1 -0
  34. package/dist/src/repository-snapshots.js +5 -2
  35. package/dist/src/run-grants.js +62 -0
  36. package/dist/src/service.js +141 -6
  37. package/dist/src/station-checkpoints.js +139 -0
  38. package/dist/src/station-config.js +8 -1
  39. package/dist/src/station-files.js +20 -1
  40. package/dist/src/station-task-verification.js +6 -3
  41. package/dist/src/station-work.js +315 -22
  42. package/dist/src/store.js +19 -0
  43. package/dist/src/supervisor.js +177 -5
  44. package/dist/src/task-budget.js +39 -3
  45. package/dist/src/task-critic.js +115 -0
  46. package/dist/src/task-driver.js +46 -2
  47. package/dist/src/task-policy.js +44 -12
  48. package/dist/src/task-verification.js +42 -9
  49. package/dist/src/task-work.js +47 -0
  50. package/dist/src/tool-identity.js +69 -0
  51. package/dist/src/usage.js +1 -1
  52. package/npm-shrinkwrap.json +2 -2
  53. package/package.json +1 -1
  54. package/src/acp-outcome.ts +169 -19
  55. package/src/acp.ts +53 -7
  56. package/src/agent-profiles.ts +48 -0
  57. package/src/agentpod-authority.ts +234 -15
  58. package/src/agentpod-default-config.ts +16 -0
  59. package/src/agentpod-work-decision-protocol.ts +69 -8
  60. package/src/agentpod-work-decisions.ts +123 -5
  61. package/src/agentpod-work.ts +1455 -152
  62. package/src/board-reads.ts +148 -0
  63. package/src/children.ts +112 -0
  64. package/src/cli-help.ts +27 -2
  65. package/src/cli.ts +109 -3
  66. package/src/command-network.ts +215 -0
  67. package/src/command-sandbox.ts +273 -0
  68. package/src/execution-evidence.ts +31 -1
  69. package/src/host-bind.ts +156 -0
  70. package/src/host-breaker.ts +89 -0
  71. package/src/host-start.ts +51 -1
  72. package/src/host.ts +244 -6
  73. package/src/house-extension.ts +174 -56
  74. package/src/local-coding.ts +270 -18
  75. package/src/local-edit-helper.ts +26 -0
  76. package/src/local-undo.ts +29 -14
  77. package/src/managed.ts +2 -2
  78. package/src/operator-mcp-cli.ts +108 -0
  79. package/src/operator-mcp.ts +420 -0
  80. package/src/persona.ts +37 -0
  81. package/src/pi.ts +23 -1
  82. package/src/protocol.ts +6 -1
  83. package/src/rate-limit-retries.ts +69 -0
  84. package/src/recurring-work.ts +1 -0
  85. package/src/repository-snapshots.ts +5 -2
  86. package/src/run-grants.ts +107 -0
  87. package/src/service.ts +222 -6
  88. package/src/station-checkpoints.ts +183 -0
  89. package/src/station-config.ts +10 -1
  90. package/src/station-files.ts +20 -1
  91. package/src/station-task-verification.ts +6 -3
  92. package/src/station-work.ts +369 -20
  93. package/src/store.ts +18 -0
  94. package/src/supervisor.ts +203 -4
  95. package/src/task-budget.ts +48 -3
  96. package/src/task-critic.ts +171 -0
  97. package/src/task-driver.ts +49 -2
  98. package/src/task-policy.ts +65 -13
  99. package/src/task-verification.ts +53 -9
  100. package/src/task-work.ts +75 -0
  101. package/src/tool-identity.ts +110 -0
  102. package/src/usage.ts +1 -1
@@ -1,22 +1,45 @@
1
+ import { BrokerRelay, COMMAND_NETWORK_PORT } from './command-network.js';
2
+ import { sandboxArgv, seatbeltArgv, UNSANDBOXED_REFUSAL, } from './command-sandbox.js';
1
3
  import { spawn } from 'node:child_process';
2
4
  import { randomUUID } from 'node:crypto';
3
- import { realpathSync } from 'node:fs';
5
+ import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
4
8
  import { digest, object, string, ServiceError } from './protocol.js';
5
9
  import { prepareLocalEdits, verifyLocalEdits, applyLocalEdits, } from './local-edits.js';
6
10
  import { permissionPolicy, permissionDigest, matchesEditScope, assertPermissionBinding, } from './autonomy.js';
7
11
  import { assertUndoSettled } from './local-undo.js';
8
12
  import { processIdentity } from './supervisor.js';
13
+ import { applyEditsAsTools, killToolGroup, serviceToolIdentity, toolArgv, } from './tool-identity.js';
14
+ /** The proxy a broker-network command uses: only the relay on loopback, nothing direct. */
15
+ function proxyEnv(port) {
16
+ const url = `http://127.0.0.1:${port}`;
17
+ return {
18
+ HTTP_PROXY: url,
19
+ HTTPS_PROXY: url,
20
+ http_proxy: url,
21
+ https_proxy: url,
22
+ NO_PROXY: 'localhost,127.0.0.1',
23
+ no_proxy: 'localhost,127.0.0.1',
24
+ };
25
+ }
9
26
  export class LocalCoding {
10
27
  store;
11
28
  managed;
12
29
  active;
30
+ tools;
31
+ sandbox;
13
32
  jobs = new Map();
14
33
  closed = false;
15
34
  unregister;
16
- constructor(store, managed, active) {
35
+ constructor(store, managed, active, tools = serviceToolIdentity(),
36
+ /** Chosen by the service from the host and the operator's setting. */
37
+ sandbox = { kind: 'none', level: 'full-access', bwrap: null }) {
17
38
  this.store = store;
18
39
  this.managed = managed;
19
40
  this.active = active;
41
+ this.tools = tools;
42
+ this.sandbox = sandbox;
20
43
  this.unregister = managed.registerMcpController('local-coding-v1', {
21
44
  owns: (op) => !!store.db.prepare('SELECT 1 FROM local_calls WHERE operation_id=?').get(op.id),
22
45
  assert: (op) => this.guard(this.byOperation(op)),
@@ -39,6 +62,9 @@ export class LocalCoding {
39
62
  /* outcome remains uncertain */
40
63
  }
41
64
  }
65
+ const pgid = c.result ? Number(JSON.parse(c.result).tool_pgid) : NaN;
66
+ if (Number.isSafeInteger(pgid))
67
+ killToolGroup(this.tools, pgid);
42
68
  store.transaction(() => {
43
69
  this.change(c, 'outcome_unknown', {
44
70
  error: 'Controller restarted during execution. Inspect the recorded intent and workspace; this operation will not replay.',
@@ -122,10 +148,25 @@ export class LocalCoding {
122
148
  const timeout = input.timeout_ms ?? 30000;
123
149
  if (!Number.isInteger(timeout) || Number(timeout) < 100 || Number(timeout) > 120000)
124
150
  throw new ServiceError('invalid_field', 'Command timeout must be 100–120000 ms');
151
+ if (this.sandbox.kind === 'bwrap' &&
152
+ this.sandbox.level !== 'full-access' &&
153
+ !this.sandbox.bwrap)
154
+ throw new ServiceError('sandbox_unavailable', 'This host has no usable bubblewrap, so it cannot run a sandboxed command; an operator can choose full access explicitly');
155
+ if (this.sandbox.kind === 'none' &&
156
+ this.sandbox.level !== 'full-access' &&
157
+ !this.sandbox.allowUnsandboxed)
158
+ throw new ServiceError('sandbox_unavailable', UNSANDBOXED_REFUSAL);
125
159
  args = {
126
160
  action: 'command',
127
161
  command: string(input.command, 'command', 8192),
128
162
  timeout_ms: Number(timeout),
163
+ // Bound into the approval and the operation digest: the sandbox is part of what is approved.
164
+ sandbox: {
165
+ kind: this.sandbox.kind,
166
+ level: this.sandbox.level,
167
+ bwrap: this.sandbox.bwrap?.version ?? null,
168
+ ...(this.brokerNetwork() ? { network: 'broker' } : {}),
169
+ },
129
170
  };
130
171
  }
131
172
  else
@@ -136,7 +177,7 @@ export class LocalCoding {
136
177
  ? args.edits
137
178
  .map((e) => `--- ${e.path}\n${e.before ?? '(new file)'}\n+++ ${e.path}\n${e.after}`)
138
179
  .join('\n\n')
139
- : `Runs as your local user, outside an OS sandbox. May access files and network beyond this workspace.\nDirectory: ${root}\nTimeout: ${args.timeout_ms} ms\nCommand:\n${args.command}`;
180
+ : `Runs as ${this.tools.mode === 'tools-user' ? `tools user ${this.tools.user} (uid ${this.tools.uid})` : 'your local user'}, ${this.sandboxText()}\nDirectory: ${root}\nTimeout: ${args.timeout_ms} ms\nCommand:\n${args.command}`;
140
181
  const permission = this.managed.prepareWorkspaceInTransaction(session, epoch, attempt, toolCall, `Local ${kind}: ${root}`, reviewed, digest({ root, args }));
141
182
  c.operation_id = permission.operation.id;
142
183
  this.store.db
@@ -207,13 +248,17 @@ export class LocalCoding {
207
248
  try {
208
249
  verifyLocalEdits(c.root, args.edits);
209
250
  entered = true;
210
- applyLocalEdits(c.root, args.edits, (path) => {
251
+ const progress = (path) => {
211
252
  applied.push(path);
212
253
  this.store.transaction(() => this.change(c, 'running', { applied }));
213
- });
254
+ };
255
+ if (this.tools.mode === 'tools-user')
256
+ applyEditsAsTools(this.tools, c.root, args.edits, progress);
257
+ else
258
+ applyLocalEdits(c.root, args.edits, progress);
214
259
  verifyLocalEdits(c.root, args.edits.map((edit) => ({ ...edit, before: edit.after })));
215
260
  this.store.transaction(() => {
216
- const result = { applied, verified: true };
261
+ const result = { applied, verified: true, tool_uid: this.tools.uid };
217
262
  this.change(c, 'succeeded', result);
218
263
  this.managed.workspaceResultInTransaction(c.operation_id, result);
219
264
  });
@@ -237,14 +282,99 @@ export class LocalCoding {
237
282
  this.command(c, String(args.command), Number(args.timeout_ms));
238
283
  return this.view(id);
239
284
  }
285
+ sandboxText() {
286
+ const { kind, level } = this.sandbox;
287
+ if (level === 'full-access')
288
+ return 'unsandboxed: the operator chose full access. May access files and network beyond this workspace.';
289
+ if (kind === 'none')
290
+ return 'unsandboxed: this host has no usable command sandbox, so the command may access files and network beyond this workspace.';
291
+ const name = kind === 'seatbelt' ? 'Seatbelt' : 'bubblewrap';
292
+ const net = this.brokerNetwork()
293
+ ? 'network: through the broker allowlist (hosts set by the stk policy), nothing direct.'
294
+ : 'has no network.';
295
+ return level === 'read-only'
296
+ ? `in a ${name} sandbox (read-only): it can read this workspace but write nothing, and ${net}`
297
+ : `in a ${name} sandbox (workspace-write): it can write only inside this workspace, and ${net}`;
298
+ }
299
+ /** Broker network applies only to a sandboxed command: unsandboxed ones are not confined anyway. */
300
+ brokerNetwork() {
301
+ return this.sandbox.network &&
302
+ this.sandbox.kind !== 'none' &&
303
+ this.sandbox.level !== 'full-access'
304
+ ? this.sandbox.network
305
+ : null;
306
+ }
307
+ /** The process a command runs as. Under the tools user, the host-side shell reports its own
308
+ * process group (the one a stop kills) before exec-ing bubblewrap, whose PID namespace would
309
+ * otherwise report a number that means nothing on the host. */
310
+ commandArgv(root, script, tmp, relay) {
311
+ const sandboxed = this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access';
312
+ const inner = (s) => !sandboxed
313
+ ? ['/bin/sh', '-c', s]
314
+ : this.sandbox.kind === 'seatbelt'
315
+ ? seatbeltArgv(this.sandbox.level, root, tmp ?? '', ['/bin/sh', '-c', s], this.sandbox.seatbelt ?? null, { network: false, ...(relay?.port ? { brokerPort: relay.port } : {}) })
316
+ : sandboxArgv(this.sandbox.level, root, ['/bin/sh', '-c', s], this.sandbox.bwrap, {
317
+ network: false,
318
+ ...(relay?.dir ? { broker: { dir: relay.dir, node: process.execPath } } : {}),
319
+ });
320
+ if (this.tools.mode !== 'tools-user')
321
+ return inner(script);
322
+ const marker = 'umask 0002\nprintf \'salaam-tool-pgid:%s\\n\' "$$"\n';
323
+ return sandboxed
324
+ ? toolArgv(this.tools, [
325
+ '/usr/bin/setsid',
326
+ '-w',
327
+ '/bin/sh',
328
+ '-c',
329
+ marker + 'exec "$@"',
330
+ 'sh',
331
+ ...inner(script),
332
+ ])
333
+ : toolArgv(this.tools, ['/usr/bin/setsid', '-w', '/bin/sh', '-c', marker + script]);
334
+ }
240
335
  command(c, command, timeout) {
336
+ const broker = this.brokerNetwork();
337
+ // The relay is Salaam's, outside the sandbox: it forwards to stk and counts what was reached.
338
+ const relayStarting = broker
339
+ ? BrokerRelay.start(broker.socket, this.sandbox.kind === 'seatbelt' ? 'tcp' : 'unix')
340
+ : Promise.resolve(null);
341
+ // A stop that arrives while the relay starts still cancels the command once it is spawned.
342
+ let early = false;
343
+ let stop = () => {
344
+ early = true;
345
+ };
346
+ // Seatbelt has no tmpfs: each command gets its own private tmp, removed when it ends.
347
+ const tmp = this.sandbox.kind === 'seatbelt' && this.sandbox.level !== 'full-access'
348
+ ? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-cmd-')))
349
+ : undefined;
350
+ const set = (s) => {
351
+ stop = s;
352
+ if (early)
353
+ s();
354
+ };
355
+ const done = relayStarting.then((relay) => this.runCommand(c, command, timeout, tmp, relay, set), () => this.runCommand(c, command, timeout, tmp, null, set, !!broker));
356
+ this.jobs.set(c.id, { stop: () => stop(), done });
357
+ if (tmp)
358
+ void done.finally(() => rmSync(tmp, { recursive: true, force: true }));
359
+ }
360
+ runCommand(c, command, timeout, tmp, relay, setStop, relayFailed = false) {
241
361
  let stop = () => { };
242
- const done = new Promise((resolve) => {
362
+ return new Promise((resolve) => {
243
363
  let output = '', bytes = 0, reason = null, finished = false;
244
364
  // Deliberately do not inherit the daemon's provider credentials or service tokens.
245
365
  // Hold the shell before any user instruction until its identity is durably owned.
366
+ const tools = this.tools.mode === 'tools-user';
246
367
  const script = 'IFS= read -r salaam_start_gate || exit 125\nexec 0</dev/null\n' + command;
247
- const child = spawn('/bin/sh', ['-c', script], {
368
+ // As the tools user, setsid makes the shell its own group leader so it can be killed by group.
369
+ const argv = this.commandArgv(c.root, script, tmp, relay);
370
+ let toolGroup = null, marker = tools ? '' : null, spawned = false;
371
+ // Release the gate only once the process is owned and, as the tools user, its group is
372
+ // known: otherwise a kill before the marker could miss a command already running.
373
+ const release = () => {
374
+ if (spawned && (!tools || toolGroup) && !reason && !finished)
375
+ child.stdin.end('start\n');
376
+ };
377
+ const child = spawn(argv[0], argv.slice(1), {
248
378
  cwd: c.root,
249
379
  detached: true,
250
380
  env: {
@@ -252,11 +382,16 @@ export class LocalCoding {
252
382
  HOME: process.env.HOME ?? c.root,
253
383
  LANG: 'en_US.UTF-8',
254
384
  TMPDIR: '/tmp',
385
+ ...(relay ? proxyEnv(relay.port ?? COMMAND_NETWORK_PORT) : {}),
255
386
  },
256
387
  stdio: ['pipe', 'pipe', 'pipe'],
257
388
  });
258
389
  let reap;
259
390
  const kill = () => {
391
+ if (toolGroup)
392
+ killToolGroup(this.tools, toolGroup);
393
+ if (tools)
394
+ child.stdin.destroy();
260
395
  if (child.pid)
261
396
  try {
262
397
  process.kill(-child.pid, 'SIGKILL');
@@ -266,17 +401,26 @@ export class LocalCoding {
266
401
  }
267
402
  if (!finished && !reap)
268
403
  reap = setTimeout(() => {
269
- reason = 'outcome_unknown';
270
- child.stdout.destroy();
271
- child.stderr.destroy();
272
- child.unref();
273
- finish(null);
404
+ if (toolGroup)
405
+ killToolGroup(this.tools, toolGroup);
406
+ // A busy event loop runs timers before pending I/O: let an already delivered close
407
+ // settle the outcome before declaring it unknown.
408
+ setImmediate(() => {
409
+ if (finished)
410
+ return;
411
+ reason = 'outcome_unknown';
412
+ child.stdout.destroy();
413
+ child.stderr.destroy();
414
+ child.unref();
415
+ finish(null);
416
+ });
274
417
  }, 1500);
275
418
  };
276
419
  stop = () => {
277
420
  reason ??= 'cancelled';
278
421
  kill();
279
422
  };
423
+ setStop(stop);
280
424
  const deadline = setTimeout(() => {
281
425
  reason = 'timed_out';
282
426
  kill();
@@ -302,7 +446,38 @@ export class LocalCoding {
302
446
  reason ??= 'outcome_unknown';
303
447
  kill();
304
448
  });
305
- child.stdout.on('data', collect);
449
+ child.stdout.on('data', (chunk) => {
450
+ if (marker === null)
451
+ return collect(chunk);
452
+ marker += chunk.toString('latin1');
453
+ const end = marker.indexOf('\n');
454
+ if (end < 0) {
455
+ if (marker.length > 64) {
456
+ reason = 'outcome_unknown';
457
+ kill();
458
+ }
459
+ return;
460
+ }
461
+ const found = /^salaam-tool-pgid:(\d+)$/.exec(marker.slice(0, end));
462
+ const rest = Buffer.from(marker.slice(end + 1), 'latin1');
463
+ marker = null;
464
+ if (!found) {
465
+ reason = 'outcome_unknown';
466
+ kill();
467
+ return;
468
+ }
469
+ toolGroup = Number(found[1]);
470
+ try {
471
+ this.store.transaction(() => this.change(c, 'running', { tool_pgid: toolGroup }));
472
+ }
473
+ catch {
474
+ reason = 'outcome_unknown';
475
+ kill();
476
+ }
477
+ if (rest.length)
478
+ collect(rest);
479
+ release();
480
+ });
306
481
  child.stderr.on('data', collect);
307
482
  child.once('spawn', () => {
308
483
  try {
@@ -319,7 +494,8 @@ export class LocalCoding {
319
494
  stop();
320
495
  return;
321
496
  }
322
- child.stdin.end('start\n');
497
+ spawned = true;
498
+ release();
323
499
  }
324
500
  catch {
325
501
  reason = 'outcome_unknown';
@@ -337,12 +513,33 @@ export class LocalCoding {
337
513
  kill();
338
514
  this.jobs.delete(c.id);
339
515
  const state = reason ?? (error ? 'failed' : code === 0 ? 'succeeded' : 'failed');
516
+ const network = relay
517
+ ? relay.summary()
518
+ : relayFailed
519
+ ? { broker: 'unavailable', connections: [] }
520
+ : null;
521
+ void relay?.close();
522
+ // What the broker refused, in its own words, so the reason reaches the card.
523
+ const refusals = (network?.connections ?? [])
524
+ .filter((n) => n.refused && n.reason)
525
+ .map((n) => `[network] refused ${n.target}: ${n.reason}`)
526
+ .join('\n');
340
527
  const result = {
341
528
  exit_code: code,
342
- output,
529
+ output: refusals
530
+ ? output + (output.endsWith('\n') || !output ? '' : '\n') + refusals + '\n'
531
+ : output,
343
532
  state,
344
533
  error: error ? 'Command could not start' : null,
345
- isolation: 'local-user-no-os-sandbox',
534
+ isolation: (tools ? 'tools-user-' : 'local-user-') +
535
+ (this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access'
536
+ ? this.sandbox.kind + '-' + this.sandbox.level
537
+ : 'no-os-sandbox'),
538
+ sandbox: this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access'
539
+ ? this.sandbox.kind
540
+ : 'none',
541
+ tool_uid: this.tools.uid,
542
+ ...(network ? { network } : {}),
346
543
  };
347
544
  this.store.transaction(() => {
348
545
  this.change(c, state, result);
@@ -356,7 +553,6 @@ export class LocalCoding {
356
553
  child.once('error', (e) => finish(null, e));
357
554
  child.once('close', (code) => finish(code));
358
555
  });
359
- this.jobs.set(c.id, { stop: () => stop(), done });
360
556
  }
361
557
  async close() {
362
558
  this.closed = true;
@@ -0,0 +1,25 @@
1
+ // Runs as the tools user: applies reviewed local edits that the service has recorded and verified.
2
+ import { closeSync, constants, fsyncSync, openSync, readFileSync, rmSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { applyLocalEdits, localPath, verifyLocalEdits } from './local-edits.js';
5
+ const root = process.argv[2];
6
+ if (!root)
7
+ throw new Error('usage: local-edit-helper <root>');
8
+ const edits = JSON.parse(readFileSync(0, 'utf8'));
9
+ for (const { remove, ...edit } of edits) {
10
+ if (remove) {
11
+ verifyLocalEdits(root, [edit]);
12
+ const path = localPath(root, edit.path);
13
+ rmSync(path);
14
+ const directory = openSync(dirname(path), constants.O_RDONLY);
15
+ try {
16
+ fsyncSync(directory);
17
+ }
18
+ finally {
19
+ closeSync(directory);
20
+ }
21
+ process.stdout.write('applied ' + JSON.stringify(edit.path) + '\n');
22
+ }
23
+ else
24
+ applyLocalEdits(root, [edit], (path) => process.stdout.write('applied ' + JSON.stringify(path) + '\n'));
25
+ }
@@ -2,6 +2,7 @@ import { dirname } from 'node:path';
2
2
  import { rmSync, openSync, closeSync, fsyncSync, constants } from 'node:fs';
3
3
  import { assertPermissionBinding } from './autonomy.js';
4
4
  import { digest, ServiceError } from './protocol.js';
5
+ import { applyEditsAsTools } from './tool-identity.js';
5
6
  import { applyLocalEdits, localPath, verifyLocalEdits } from './local-edits.js';
6
7
  /** Unfinished inverse edits fence every local session sharing the root, including after a crash. */
7
8
  export function assertUndoSettled(store, root) {
@@ -24,7 +25,7 @@ export function reviewUndo(store, id) {
24
25
  verifyLocalEdits(session.cwd, edits.map((e) => ({ ...e, before: e.after })));
25
26
  return { call_id: row.id, digest: digest({ root: session.cwd, call: row.id, edits }), edits };
26
27
  }
27
- export function applyUndo(store, id, request, reviewed, controller, idle) {
28
+ export function applyUndo(store, id, request, reviewed, controller, idle, tools) {
28
29
  const input = { kind: 'local-undo', id, reviewed };
29
30
  const saved = store.cachedRequest('local-operator', request, input);
30
31
  if (saved)
@@ -47,23 +48,31 @@ export function applyUndo(store, id, request, reviewed, controller, idle) {
47
48
  return { state: 'outcome_unknown', ...review };
48
49
  });
49
50
  // Intent is durable before touching files. A lost process never repeats this mutation.
50
- for (const edit of work.edits) {
51
- const inverse = { ...edit, before: edit.after, after: edit.before ?? '' };
52
- if (edit.before === null) {
53
- verifyLocalEdits(root, [inverse]);
54
- const path = localPath(root, edit.path);
55
- rmSync(path);
56
- const directory = openSync(dirname(path), constants.O_RDONLY);
57
- try {
58
- fsyncSync(directory);
59
- }
60
- finally {
61
- closeSync(directory);
51
+ if (tools?.mode === 'tools-user')
52
+ applyEditsAsTools(tools, root, work.edits.map((edit) => ({
53
+ ...edit,
54
+ before: edit.after,
55
+ after: edit.before ?? '',
56
+ ...(edit.before === null ? { remove: true } : {}),
57
+ })), () => { });
58
+ else
59
+ for (const edit of work.edits) {
60
+ const inverse = { ...edit, before: edit.after, after: edit.before ?? '' };
61
+ if (edit.before === null) {
62
+ verifyLocalEdits(root, [inverse]);
63
+ const path = localPath(root, edit.path);
64
+ rmSync(path);
65
+ const directory = openSync(dirname(path), constants.O_RDONLY);
66
+ try {
67
+ fsyncSync(directory);
68
+ }
69
+ finally {
70
+ closeSync(directory);
71
+ }
62
72
  }
73
+ else
74
+ applyLocalEdits(root, [inverse], () => { });
63
75
  }
64
- else
65
- applyLocalEdits(root, [inverse], () => { });
66
- }
67
76
  verifyLocalEdits(root, work.edits);
68
77
  return store.transaction(() => {
69
78
  const result = { state: 'completed', call_id: work.call_id };
@@ -383,7 +383,7 @@ export class ManagedWork {
383
383
  this.fresh(op);
384
384
  this.store.db
385
385
  .prepare("UPDATE approvals SET state='decided',decision=?,decided_by=?,revision=revision+1 WHERE id=?")
386
- .run(decision, scoped ? 'scoped-policy' : 'local-operator', id);
386
+ .run(decision, scoped ? (scoped.decidedBy ?? 'scoped-policy') : 'local-operator', id);
387
387
  if (decision === 'deny')
388
388
  this.transition(op, 'cancelled_before_dispatch', 'Denied by operator');
389
389
  this.store.event(approval.session_id, 'approval.decided', { approval_id: id, operation_id: op.id, decision }, request);
@@ -0,0 +1,107 @@
1
+ import { declarationDigest, operatorMcpPath, readOperatorMcpFile, validateDeclaration, writeOperatorMcpFile, } from './operator-mcp.js';
2
+ export const MCP_HELP = `salaam mcp: operator-declared MCP servers
3
+
4
+ salaam mcp add <name> [--env K='\${VAR}']... -- <command> [args...]
5
+ salaam mcp add <name> --url <https-url> [--header K='Bearer \${VAR}']...
6
+ salaam mcp list
7
+ salaam mcp rm <name>
8
+
9
+ Options: --config <path> (default $XDG_CONFIG_HOME/salaam/mcp.json)
10
+ Values in --env and --header must reference environment variables as \${VAR};
11
+ inline secrets are refused. Changes apply at the next salaam service start.`;
12
+ function pairs(values, flag) {
13
+ if (!values.length)
14
+ return undefined;
15
+ const out = {};
16
+ for (const value of values) {
17
+ const i = value.indexOf('=');
18
+ if (i < 1)
19
+ throw new Error(`${flag} expects K=V`);
20
+ const key = value.slice(0, i);
21
+ if (Object.hasOwn(out, key))
22
+ throw new Error(`${flag} ${key} given twice`);
23
+ out[key] = value.slice(i + 1);
24
+ }
25
+ return out;
26
+ }
27
+ /** `salaam mcp ...`; argv excludes the leading "mcp". Returns text for stdout. */
28
+ export function mcpCli(argv, env = process.env) {
29
+ const dash = argv.indexOf('--');
30
+ const head = dash < 0 ? [...argv] : argv.slice(0, dash);
31
+ const command = dash < 0 ? [] : argv.slice(dash + 1);
32
+ const take = (flag, many = false) => {
33
+ const found = [];
34
+ for (let i = head.indexOf(flag); i >= 0; i = head.indexOf(flag)) {
35
+ const value = head[i + 1];
36
+ if (value === undefined)
37
+ throw new Error(`Missing value for ${flag}`);
38
+ found.push(value);
39
+ head.splice(i, 2);
40
+ if (!many)
41
+ break;
42
+ }
43
+ return found;
44
+ };
45
+ const path = take('--config')[0] ?? operatorMcpPath(env);
46
+ const url = take('--url')[0];
47
+ const headers = pairs(take('--header', true), '--header');
48
+ const envs = pairs(take('--env', true), '--env');
49
+ const [verb, name, ...rest] = head;
50
+ if (rest.length || head.some((x) => x.startsWith('--')))
51
+ throw new Error('Unexpected arguments; run salaam mcp --help');
52
+ const file = readOperatorMcpFile(path);
53
+ if (verb === 'list') {
54
+ if (dash >= 0 || name)
55
+ throw new Error('salaam mcp list takes no arguments');
56
+ return JSON.stringify({
57
+ config: path,
58
+ servers: file.servers.map((s) => ({ ...s, declaration_digest: declarationDigest(s) })),
59
+ }, null, 2);
60
+ }
61
+ if (!name)
62
+ throw new Error('A server name is required');
63
+ if (verb === 'rm') {
64
+ if (dash >= 0 || url || headers || envs)
65
+ throw new Error('salaam mcp rm takes only a name');
66
+ const servers = file.servers.filter((s) => s.name !== name);
67
+ if (servers.length === file.servers.length)
68
+ throw new Error(`No declared server "${name}"`);
69
+ writeOperatorMcpFile(path, { version: 1, servers });
70
+ return JSON.stringify({ removed: name, config: path });
71
+ }
72
+ if (verb === 'add') {
73
+ if (file.servers.some((s) => s.name === name))
74
+ throw new Error(`Server "${name}" is already declared; rm it first`);
75
+ let declaration;
76
+ if (url) {
77
+ if (command.length || envs)
78
+ throw new Error('Use either --url or -- <command>, not both');
79
+ declaration = validateDeclaration({
80
+ name,
81
+ transport: 'http',
82
+ url,
83
+ ...(headers ? { headers } : {}),
84
+ });
85
+ }
86
+ else {
87
+ if (!command.length)
88
+ throw new Error('Supply -- <command> [args...] or --url <url>');
89
+ if (headers)
90
+ throw new Error('--header applies to --url servers');
91
+ declaration = validateDeclaration({
92
+ name,
93
+ transport: 'stdio',
94
+ command: command[0],
95
+ args: command.slice(1),
96
+ ...(envs ? { env: envs } : {}),
97
+ });
98
+ }
99
+ writeOperatorMcpFile(path, { version: 1, servers: [...file.servers, declaration] });
100
+ return JSON.stringify({
101
+ added: name,
102
+ config: path,
103
+ declaration_digest: declarationDigest(declaration),
104
+ });
105
+ }
106
+ throw new Error('Use salaam mcp add, list or rm');
107
+ }