@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,6 +1,18 @@
1
+ import { BrokerRelay, COMMAND_NETWORK_PORT } from './command-network.js';
2
+ import {
3
+ sandboxArgv,
4
+ seatbeltArgv,
5
+ UNSANDBOXED_REFUSAL,
6
+ type Bwrap,
7
+ type SandboxKind,
8
+ type SandboxLevel,
9
+ type Seatbelt,
10
+ } from './command-sandbox.js';
1
11
  import { spawn } from 'node:child_process';
2
12
  import { randomUUID } from 'node:crypto';
3
- import { realpathSync } from 'node:fs';
13
+ import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
4
16
  import type { Store } from './store.js';
5
17
  import type { ManagedWork } from './managed.js';
6
18
  import { digest, object, string, ServiceError, type Operation } from './protocol.js';
@@ -18,6 +30,13 @@ import {
18
30
  } from './autonomy.js';
19
31
  import { assertUndoSettled } from './local-undo.js';
20
32
  import { processIdentity } from './supervisor.js';
33
+ import {
34
+ applyEditsAsTools,
35
+ killToolGroup,
36
+ serviceToolIdentity,
37
+ toolArgv,
38
+ type ToolIdentity,
39
+ } from './tool-identity.js';
21
40
 
22
41
  interface LocalCall {
23
42
  id: string;
@@ -34,6 +53,35 @@ interface LocalCall {
34
53
  pid: number | null;
35
54
  identity: string | null;
36
55
  }
56
+ /**
57
+ * Item 10: how this host runs local commands. `bwrap` (Linux) or `seatbelt` (macOS): under the OS
58
+ * sandbox at `level` (full-access only by explicit operator setting). `none`: a host with no usable
59
+ * sandbox, which refuses local commands unless the operator allowed them unsandboxed
60
+ * (`allowUnsandboxed`); then it runs them and says so in every approval.
61
+ */
62
+ export interface LocalSandbox {
63
+ kind: SandboxKind;
64
+ level: SandboxLevel;
65
+ bwrap: Bwrap | null;
66
+ seatbelt?: Seatbelt | null;
67
+ /** The host config's `unsandboxed_commands: "allow"` (operator ruling 2026-10-30, option A). */
68
+ allowUnsandboxed?: boolean;
69
+ /** The host config's `command_network: "broker"`: a sandboxed command reaches the network only
70
+ * through this stk local-commands socket (operator rulings 2026-10-30). */
71
+ network?: { socket: string };
72
+ }
73
+ /** The proxy a broker-network command uses: only the relay on loopback, nothing direct. */
74
+ function proxyEnv(port: number): Record<string, string> {
75
+ const url = `http://127.0.0.1:${port}`;
76
+ return {
77
+ HTTP_PROXY: url,
78
+ HTTPS_PROXY: url,
79
+ http_proxy: url,
80
+ https_proxy: url,
81
+ NO_PROXY: 'localhost,127.0.0.1',
82
+ no_proxy: 'localhost,127.0.0.1',
83
+ };
84
+ }
37
85
  export class LocalCoding {
38
86
  private jobs = new Map<string, { stop: () => void; done: Promise<void> }>();
39
87
  private closed = false;
@@ -42,6 +90,9 @@ export class LocalCoding {
42
90
  readonly store: Store,
43
91
  readonly managed: ManagedWork,
44
92
  readonly active: (s: string, e: number, a: string) => void,
93
+ readonly tools: ToolIdentity = serviceToolIdentity(),
94
+ /** Chosen by the service from the host and the operator's setting. */
95
+ public sandbox: LocalSandbox = { kind: 'none', level: 'full-access', bwrap: null },
45
96
  ) {
46
97
  this.unregister = managed.registerMcpController('local-coding-v1', {
47
98
  owns: (op) => !!store.db.prepare('SELECT 1 FROM local_calls WHERE operation_id=?').get(op.id),
@@ -64,6 +115,8 @@ export class LocalCoding {
64
115
  /* outcome remains uncertain */
65
116
  }
66
117
  }
118
+ const pgid = c.result ? Number(JSON.parse(c.result).tool_pgid) : NaN;
119
+ if (Number.isSafeInteger(pgid)) killToolGroup(this.tools, pgid);
67
120
  store.transaction(() => {
68
121
  this.change(c, 'outcome_unknown', {
69
122
  error:
@@ -158,16 +211,43 @@ export class LocalCoding {
158
211
  const kind = string(input.action, 'action');
159
212
  let args:
160
213
  | { action: 'edit'; edits: LocalEdit[] }
161
- | { action: 'command'; command: string; timeout_ms: number };
214
+ | {
215
+ action: 'command';
216
+ command: string;
217
+ timeout_ms: number;
218
+ sandbox: { kind: string; level: string; bwrap: string | null; network?: 'broker' };
219
+ };
162
220
  if (kind === 'edit') args = { action: 'edit', edits: prepareLocalEdits(root, input.edits) };
163
221
  else if (kind === 'command') {
164
222
  const timeout = input.timeout_ms ?? 30000;
165
223
  if (!Number.isInteger(timeout) || Number(timeout) < 100 || Number(timeout) > 120000)
166
224
  throw new ServiceError('invalid_field', 'Command timeout must be 100–120000 ms');
225
+ if (
226
+ this.sandbox.kind === 'bwrap' &&
227
+ this.sandbox.level !== 'full-access' &&
228
+ !this.sandbox.bwrap
229
+ )
230
+ throw new ServiceError(
231
+ 'sandbox_unavailable',
232
+ 'This host has no usable bubblewrap, so it cannot run a sandboxed command; an operator can choose full access explicitly',
233
+ );
234
+ if (
235
+ this.sandbox.kind === 'none' &&
236
+ this.sandbox.level !== 'full-access' &&
237
+ !this.sandbox.allowUnsandboxed
238
+ )
239
+ throw new ServiceError('sandbox_unavailable', UNSANDBOXED_REFUSAL);
167
240
  args = {
168
241
  action: 'command',
169
242
  command: string(input.command, 'command', 8192),
170
243
  timeout_ms: Number(timeout),
244
+ // Bound into the approval and the operation digest: the sandbox is part of what is approved.
245
+ sandbox: {
246
+ kind: this.sandbox.kind,
247
+ level: this.sandbox.level,
248
+ bwrap: this.sandbox.bwrap?.version ?? null,
249
+ ...(this.brokerNetwork() ? { network: 'broker' as const } : {}),
250
+ },
171
251
  };
172
252
  } else throw new ServiceError('invalid_field', 'Choose edit or command');
173
253
  c.args = JSON.stringify(args);
@@ -177,7 +257,7 @@ export class LocalCoding {
177
257
  ? args.edits
178
258
  .map((e) => `--- ${e.path}\n${e.before ?? '(new file)'}\n+++ ${e.path}\n${e.after}`)
179
259
  .join('\n\n')
180
- : `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}`;
260
+ : `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}`;
181
261
  const permission = this.managed.prepareWorkspaceInTransaction(
182
262
  session,
183
263
  epoch,
@@ -287,16 +367,19 @@ export class LocalCoding {
287
367
  try {
288
368
  verifyLocalEdits(c.root, args.edits as LocalEdit[]);
289
369
  entered = true;
290
- applyLocalEdits(c.root, args.edits as LocalEdit[], (path) => {
370
+ const progress = (path: string) => {
291
371
  applied.push(path);
292
372
  this.store.transaction(() => this.change(c, 'running', { applied }));
293
- });
373
+ };
374
+ if (this.tools.mode === 'tools-user')
375
+ applyEditsAsTools(this.tools, c.root, args.edits as LocalEdit[], progress);
376
+ else applyLocalEdits(c.root, args.edits as LocalEdit[], progress);
294
377
  verifyLocalEdits(
295
378
  c.root,
296
379
  (args.edits as LocalEdit[]).map((edit) => ({ ...edit, before: edit.after })),
297
380
  );
298
381
  this.store.transaction(() => {
299
- const result = { applied, verified: true };
382
+ const result = { applied, verified: true, tool_uid: this.tools.uid };
300
383
  this.change(c, 'succeeded', result);
301
384
  this.managed.workspaceResultInTransaction(c.operation_id, result);
302
385
  });
@@ -316,17 +399,125 @@ export class LocalCoding {
316
399
  } else this.command(c, String(args.command), Number(args.timeout_ms));
317
400
  return this.view(id);
318
401
  }
402
+ private sandboxText(): string {
403
+ const { kind, level } = this.sandbox;
404
+ if (level === 'full-access')
405
+ return 'unsandboxed: the operator chose full access. May access files and network beyond this workspace.';
406
+ if (kind === 'none')
407
+ return 'unsandboxed: this host has no usable command sandbox, so the command may access files and network beyond this workspace.';
408
+ const name = kind === 'seatbelt' ? 'Seatbelt' : 'bubblewrap';
409
+ const net = this.brokerNetwork()
410
+ ? 'network: through the broker allowlist (hosts set by the stk policy), nothing direct.'
411
+ : 'has no network.';
412
+ return level === 'read-only'
413
+ ? `in a ${name} sandbox (read-only): it can read this workspace but write nothing, and ${net}`
414
+ : `in a ${name} sandbox (workspace-write): it can write only inside this workspace, and ${net}`;
415
+ }
416
+ /** Broker network applies only to a sandboxed command: unsandboxed ones are not confined anyway. */
417
+ private brokerNetwork(): { socket: string } | null {
418
+ return this.sandbox.network &&
419
+ this.sandbox.kind !== 'none' &&
420
+ this.sandbox.level !== 'full-access'
421
+ ? this.sandbox.network
422
+ : null;
423
+ }
424
+ /** The process a command runs as. Under the tools user, the host-side shell reports its own
425
+ * process group (the one a stop kills) before exec-ing bubblewrap, whose PID namespace would
426
+ * otherwise report a number that means nothing on the host. */
427
+ private commandArgv(
428
+ root: string,
429
+ script: string,
430
+ tmp?: string,
431
+ relay?: BrokerRelay | null,
432
+ ): string[] {
433
+ const sandboxed = this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access';
434
+ const inner = (s: string) =>
435
+ !sandboxed
436
+ ? ['/bin/sh', '-c', s]
437
+ : this.sandbox.kind === 'seatbelt'
438
+ ? seatbeltArgv(
439
+ this.sandbox.level,
440
+ root,
441
+ tmp ?? '',
442
+ ['/bin/sh', '-c', s],
443
+ this.sandbox.seatbelt ?? null,
444
+ { network: false, ...(relay?.port ? { brokerPort: relay.port } : {}) },
445
+ )
446
+ : sandboxArgv(this.sandbox.level, root, ['/bin/sh', '-c', s], this.sandbox.bwrap, {
447
+ network: false,
448
+ ...(relay?.dir ? { broker: { dir: relay.dir, node: process.execPath } } : {}),
449
+ });
450
+ if (this.tools.mode !== 'tools-user') return inner(script);
451
+ const marker = 'umask 0002\nprintf \'salaam-tool-pgid:%s\\n\' "$$"\n';
452
+ return sandboxed
453
+ ? toolArgv(this.tools, [
454
+ '/usr/bin/setsid',
455
+ '-w',
456
+ '/bin/sh',
457
+ '-c',
458
+ marker + 'exec "$@"',
459
+ 'sh',
460
+ ...inner(script),
461
+ ])
462
+ : toolArgv(this.tools, ['/usr/bin/setsid', '-w', '/bin/sh', '-c', marker + script]);
463
+ }
319
464
  private command(c: LocalCall, command: string, timeout: number): void {
465
+ const broker = this.brokerNetwork();
466
+ // The relay is Salaam's, outside the sandbox: it forwards to stk and counts what was reached.
467
+ const relayStarting = broker
468
+ ? BrokerRelay.start(broker.socket, this.sandbox.kind === 'seatbelt' ? 'tcp' : 'unix')
469
+ : Promise.resolve(null);
470
+ // A stop that arrives while the relay starts still cancels the command once it is spawned.
471
+ let early = false;
472
+ let stop = () => {
473
+ early = true;
474
+ };
475
+ // Seatbelt has no tmpfs: each command gets its own private tmp, removed when it ends.
476
+ const tmp =
477
+ this.sandbox.kind === 'seatbelt' && this.sandbox.level !== 'full-access'
478
+ ? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-cmd-')))
479
+ : undefined;
480
+ const set = (s: () => void) => {
481
+ stop = s;
482
+ if (early) s();
483
+ };
484
+ const done = relayStarting.then(
485
+ (relay) => this.runCommand(c, command, timeout, tmp, relay, set),
486
+ () => this.runCommand(c, command, timeout, tmp, null, set, !!broker),
487
+ );
488
+ this.jobs.set(c.id, { stop: () => stop(), done });
489
+ if (tmp) void done.finally(() => rmSync(tmp, { recursive: true, force: true }));
490
+ }
491
+ private runCommand(
492
+ c: LocalCall,
493
+ command: string,
494
+ timeout: number,
495
+ tmp: string | undefined,
496
+ relay: BrokerRelay | null,
497
+ setStop: (stop: () => void) => void,
498
+ relayFailed = false,
499
+ ): Promise<void> {
320
500
  let stop = () => {};
321
- const done = new Promise<void>((resolve) => {
501
+ return new Promise<void>((resolve) => {
322
502
  let output = '',
323
503
  bytes = 0,
324
504
  reason: string | null = null,
325
505
  finished = false;
326
506
  // Deliberately do not inherit the daemon's provider credentials or service tokens.
327
507
  // Hold the shell before any user instruction until its identity is durably owned.
508
+ const tools = this.tools.mode === 'tools-user';
328
509
  const script = 'IFS= read -r salaam_start_gate || exit 125\nexec 0</dev/null\n' + command;
329
- const child = spawn('/bin/sh', ['-c', script], {
510
+ // As the tools user, setsid makes the shell its own group leader so it can be killed by group.
511
+ const argv = this.commandArgv(c.root, script, tmp, relay);
512
+ let toolGroup: number | null = null,
513
+ marker = tools ? '' : null,
514
+ spawned = false;
515
+ // Release the gate only once the process is owned and, as the tools user, its group is
516
+ // known: otherwise a kill before the marker could miss a command already running.
517
+ const release = () => {
518
+ if (spawned && (!tools || toolGroup) && !reason && !finished) child.stdin.end('start\n');
519
+ };
520
+ const child = spawn(argv[0]!, argv.slice(1), {
330
521
  cwd: c.root,
331
522
  detached: true,
332
523
  env: {
@@ -334,11 +525,14 @@ export class LocalCoding {
334
525
  HOME: process.env.HOME ?? c.root,
335
526
  LANG: 'en_US.UTF-8',
336
527
  TMPDIR: '/tmp',
528
+ ...(relay ? proxyEnv(relay.port ?? COMMAND_NETWORK_PORT) : {}),
337
529
  },
338
530
  stdio: ['pipe', 'pipe', 'pipe'],
339
531
  });
340
532
  let reap: NodeJS.Timeout | undefined;
341
533
  const kill = () => {
534
+ if (toolGroup) killToolGroup(this.tools, toolGroup);
535
+ if (tools) child.stdin.destroy();
342
536
  if (child.pid)
343
537
  try {
344
538
  process.kill(-child.pid, 'SIGKILL');
@@ -347,17 +541,24 @@ export class LocalCoding {
347
541
  }
348
542
  if (!finished && !reap)
349
543
  reap = setTimeout(() => {
350
- reason = 'outcome_unknown';
351
- child.stdout.destroy();
352
- child.stderr.destroy();
353
- child.unref();
354
- finish(null);
544
+ if (toolGroup) killToolGroup(this.tools, toolGroup);
545
+ // A busy event loop runs timers before pending I/O: let an already delivered close
546
+ // settle the outcome before declaring it unknown.
547
+ setImmediate(() => {
548
+ if (finished) return;
549
+ reason = 'outcome_unknown';
550
+ child.stdout.destroy();
551
+ child.stderr.destroy();
552
+ child.unref();
553
+ finish(null);
554
+ });
355
555
  }, 1500);
356
556
  };
357
557
  stop = () => {
358
558
  reason ??= 'cancelled';
359
559
  kill();
360
560
  };
561
+ setStop(stop);
361
562
  const deadline = setTimeout(() => {
362
563
  reason = 'timed_out';
363
564
  kill();
@@ -382,7 +583,35 @@ export class LocalCoding {
382
583
  reason ??= 'outcome_unknown';
383
584
  kill();
384
585
  });
385
- child.stdout.on('data', collect);
586
+ child.stdout.on('data', (chunk: Buffer) => {
587
+ if (marker === null) return collect(chunk);
588
+ marker += chunk.toString('latin1');
589
+ const end = marker.indexOf('\n');
590
+ if (end < 0) {
591
+ if (marker.length > 64) {
592
+ reason = 'outcome_unknown';
593
+ kill();
594
+ }
595
+ return;
596
+ }
597
+ const found = /^salaam-tool-pgid:(\d+)$/.exec(marker.slice(0, end));
598
+ const rest = Buffer.from(marker.slice(end + 1), 'latin1');
599
+ marker = null;
600
+ if (!found) {
601
+ reason = 'outcome_unknown';
602
+ kill();
603
+ return;
604
+ }
605
+ toolGroup = Number(found[1]);
606
+ try {
607
+ this.store.transaction(() => this.change(c, 'running', { tool_pgid: toolGroup }));
608
+ } catch {
609
+ reason = 'outcome_unknown';
610
+ kill();
611
+ }
612
+ if (rest.length) collect(rest);
613
+ release();
614
+ });
386
615
  child.stderr.on('data', collect);
387
616
  child.once('spawn', () => {
388
617
  try {
@@ -399,7 +628,8 @@ export class LocalCoding {
399
628
  stop();
400
629
  return;
401
630
  }
402
- child.stdin.end('start\n');
631
+ spawned = true;
632
+ release();
403
633
  } catch {
404
634
  reason = 'outcome_unknown';
405
635
  kill();
@@ -414,12 +644,35 @@ export class LocalCoding {
414
644
  kill();
415
645
  this.jobs.delete(c.id);
416
646
  const state = reason ?? (error ? 'failed' : code === 0 ? 'succeeded' : 'failed');
647
+ const network = relay
648
+ ? relay.summary()
649
+ : relayFailed
650
+ ? { broker: 'unavailable' as const, connections: [] }
651
+ : null;
652
+ void relay?.close();
653
+ // What the broker refused, in its own words, so the reason reaches the card.
654
+ const refusals = (network?.connections ?? [])
655
+ .filter((n) => n.refused && n.reason)
656
+ .map((n) => `[network] refused ${n.target}: ${n.reason}`)
657
+ .join('\n');
417
658
  const result = {
418
659
  exit_code: code,
419
- output,
660
+ output: refusals
661
+ ? output + (output.endsWith('\n') || !output ? '' : '\n') + refusals + '\n'
662
+ : output,
420
663
  state,
421
664
  error: error ? 'Command could not start' : null,
422
- isolation: 'local-user-no-os-sandbox',
665
+ isolation:
666
+ (tools ? 'tools-user-' : 'local-user-') +
667
+ (this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access'
668
+ ? this.sandbox.kind + '-' + this.sandbox.level
669
+ : 'no-os-sandbox'),
670
+ sandbox:
671
+ this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access'
672
+ ? this.sandbox.kind
673
+ : 'none',
674
+ tool_uid: this.tools.uid,
675
+ ...(network ? { network } : {}),
423
676
  };
424
677
  this.store.transaction(() => {
425
678
  this.change(c, state, result);
@@ -437,7 +690,6 @@ export class LocalCoding {
437
690
  child.once('error', (e) => finish(null, e));
438
691
  child.once('close', (code) => finish(code));
439
692
  });
440
- this.jobs.set(c.id, { stop: () => stop(), done });
441
693
  }
442
694
  async close(): Promise<void> {
443
695
  this.closed = true;
@@ -0,0 +1,26 @@
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
+ import type { ToolEdit } from './tool-identity.js';
6
+
7
+ const root = process.argv[2];
8
+ if (!root) throw new Error('usage: local-edit-helper <root>');
9
+ const edits = JSON.parse(readFileSync(0, 'utf8')) as ToolEdit[];
10
+ for (const { remove, ...edit } of edits) {
11
+ if (remove) {
12
+ verifyLocalEdits(root, [edit]);
13
+ const path = localPath(root, edit.path);
14
+ rmSync(path);
15
+ const directory = openSync(dirname(path), constants.O_RDONLY);
16
+ try {
17
+ fsyncSync(directory);
18
+ } finally {
19
+ closeSync(directory);
20
+ }
21
+ process.stdout.write('applied ' + JSON.stringify(edit.path) + '\n');
22
+ } else
23
+ applyLocalEdits(root, [edit], (path) =>
24
+ process.stdout.write('applied ' + JSON.stringify(path) + '\n'),
25
+ );
26
+ }
package/src/local-undo.ts CHANGED
@@ -3,6 +3,7 @@ import { rmSync, openSync, closeSync, fsyncSync, constants } from 'node:fs';
3
3
  import { assertPermissionBinding } from './autonomy.js';
4
4
  import type { Store } from './store.js';
5
5
  import { digest, ServiceError, type Controller } from './protocol.js';
6
+ import { applyEditsAsTools, type ToolIdentity } from './tool-identity.js';
6
7
  import { applyLocalEdits, localPath, verifyLocalEdits, type LocalEdit } from './local-edits.js';
7
8
 
8
9
  export interface UndoReview {
@@ -53,6 +54,7 @@ export function applyUndo(
53
54
  reviewed: string,
54
55
  controller: Controller,
55
56
  idle: () => void,
57
+ tools?: ToolIdentity,
56
58
  ) {
57
59
  const input = { kind: 'local-undo', id, reviewed };
58
60
  const saved = store.cachedRequest<{ state: string }>('local-operator', request, input);
@@ -79,20 +81,33 @@ export function applyUndo(
79
81
  return { state: 'outcome_unknown', ...review };
80
82
  });
81
83
  // Intent is durable before touching files. A lost process never repeats this mutation.
82
- for (const edit of work.edits) {
83
- const inverse = { ...edit, before: edit.after, after: edit.before ?? '' };
84
- if (edit.before === null) {
85
- verifyLocalEdits(root, [inverse]);
86
- const path = localPath(root, edit.path);
87
- rmSync(path);
88
- const directory = openSync(dirname(path), constants.O_RDONLY);
89
- try {
90
- fsyncSync(directory);
91
- } finally {
92
- closeSync(directory);
93
- }
94
- } else applyLocalEdits(root, [inverse], () => {});
95
- }
84
+ if (tools?.mode === 'tools-user')
85
+ applyEditsAsTools(
86
+ tools,
87
+ root,
88
+ work.edits.map((edit: LocalEdit) => ({
89
+ ...edit,
90
+ before: edit.after,
91
+ after: edit.before ?? '',
92
+ ...(edit.before === null ? { remove: true } : {}),
93
+ })),
94
+ () => {},
95
+ );
96
+ else
97
+ for (const edit of work.edits) {
98
+ const inverse = { ...edit, before: edit.after, after: edit.before ?? '' };
99
+ if (edit.before === null) {
100
+ verifyLocalEdits(root, [inverse]);
101
+ const path = localPath(root, edit.path);
102
+ rmSync(path);
103
+ const directory = openSync(dirname(path), constants.O_RDONLY);
104
+ try {
105
+ fsyncSync(directory);
106
+ } finally {
107
+ closeSync(directory);
108
+ }
109
+ } else applyLocalEdits(root, [inverse], () => {});
110
+ }
96
111
  verifyLocalEdits(root, work.edits);
97
112
  return store.transaction(() => {
98
113
  const result = { state: 'completed', call_id: work.call_id };
package/src/managed.ts CHANGED
@@ -503,7 +503,7 @@ export class ManagedWork {
503
503
  revision: number,
504
504
  binding: string,
505
505
  decision: 'approve' | 'deny',
506
- scoped?: { receipt: Record<string, unknown>; authorize: () => void },
506
+ scoped?: { receipt: Record<string, unknown>; authorize: () => void; decidedBy?: string },
507
507
  ): Approval {
508
508
  const requestWork = this.store.db.isTransaction
509
509
  ? this.store.requestInTransaction.bind(this.store)
@@ -553,7 +553,7 @@ export class ManagedWork {
553
553
  .prepare(
554
554
  "UPDATE approvals SET state='decided',decision=?,decided_by=?,revision=revision+1 WHERE id=?",
555
555
  )
556
- .run(decision, scoped ? 'scoped-policy' : 'local-operator', id);
556
+ .run(decision, scoped ? (scoped.decidedBy ?? 'scoped-policy') : 'local-operator', id);
557
557
  if (decision === 'deny')
558
558
  this.transition(op, 'cancelled_before_dispatch', 'Denied by operator');
559
559
  this.store.event(
@@ -0,0 +1,108 @@
1
+ import {
2
+ declarationDigest,
3
+ operatorMcpPath,
4
+ readOperatorMcpFile,
5
+ validateDeclaration,
6
+ writeOperatorMcpFile,
7
+ type McpDeclaration,
8
+ } from './operator-mcp.js';
9
+
10
+ export const MCP_HELP = `salaam mcp: operator-declared MCP servers
11
+
12
+ salaam mcp add <name> [--env K='\${VAR}']... -- <command> [args...]
13
+ salaam mcp add <name> --url <https-url> [--header K='Bearer \${VAR}']...
14
+ salaam mcp list
15
+ salaam mcp rm <name>
16
+
17
+ Options: --config <path> (default $XDG_CONFIG_HOME/salaam/mcp.json)
18
+ Values in --env and --header must reference environment variables as \${VAR};
19
+ inline secrets are refused. Changes apply at the next salaam service start.`;
20
+
21
+ function pairs(values: string[], flag: string): Record<string, string> | undefined {
22
+ if (!values.length) return undefined;
23
+ const out: Record<string, string> = {};
24
+ for (const value of values) {
25
+ const i = value.indexOf('=');
26
+ if (i < 1) throw new Error(`${flag} expects K=V`);
27
+ const key = value.slice(0, i);
28
+ if (Object.hasOwn(out, key)) throw new Error(`${flag} ${key} given twice`);
29
+ out[key] = value.slice(i + 1);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /** `salaam mcp ...`; argv excludes the leading "mcp". Returns text for stdout. */
35
+ export function mcpCli(argv: string[], env: NodeJS.ProcessEnv = process.env): string {
36
+ const dash = argv.indexOf('--');
37
+ const head = dash < 0 ? [...argv] : argv.slice(0, dash);
38
+ const command = dash < 0 ? [] : argv.slice(dash + 1);
39
+ const take = (flag: string, many = false): string[] => {
40
+ const found: string[] = [];
41
+ for (let i = head.indexOf(flag); i >= 0; i = head.indexOf(flag)) {
42
+ const value = head[i + 1];
43
+ if (value === undefined) throw new Error(`Missing value for ${flag}`);
44
+ found.push(value);
45
+ head.splice(i, 2);
46
+ if (!many) break;
47
+ }
48
+ return found;
49
+ };
50
+ const path = take('--config')[0] ?? operatorMcpPath(env);
51
+ const url = take('--url')[0];
52
+ const headers = pairs(take('--header', true), '--header');
53
+ const envs = pairs(take('--env', true), '--env');
54
+ const [verb, name, ...rest] = head;
55
+ if (rest.length || head.some((x) => x.startsWith('--')))
56
+ throw new Error('Unexpected arguments; run salaam mcp --help');
57
+ const file = readOperatorMcpFile(path);
58
+ if (verb === 'list') {
59
+ if (dash >= 0 || name) throw new Error('salaam mcp list takes no arguments');
60
+ return JSON.stringify(
61
+ {
62
+ config: path,
63
+ servers: file.servers.map((s) => ({ ...s, declaration_digest: declarationDigest(s) })),
64
+ },
65
+ null,
66
+ 2,
67
+ );
68
+ }
69
+ if (!name) throw new Error('A server name is required');
70
+ if (verb === 'rm') {
71
+ if (dash >= 0 || url || headers || envs) throw new Error('salaam mcp rm takes only a name');
72
+ const servers = file.servers.filter((s) => s.name !== name);
73
+ if (servers.length === file.servers.length) throw new Error(`No declared server "${name}"`);
74
+ writeOperatorMcpFile(path, { version: 1, servers });
75
+ return JSON.stringify({ removed: name, config: path });
76
+ }
77
+ if (verb === 'add') {
78
+ if (file.servers.some((s) => s.name === name))
79
+ throw new Error(`Server "${name}" is already declared; rm it first`);
80
+ let declaration: McpDeclaration;
81
+ if (url) {
82
+ if (command.length || envs) throw new Error('Use either --url or -- <command>, not both');
83
+ declaration = validateDeclaration({
84
+ name,
85
+ transport: 'http',
86
+ url,
87
+ ...(headers ? { headers } : {}),
88
+ });
89
+ } else {
90
+ if (!command.length) throw new Error('Supply -- <command> [args...] or --url <url>');
91
+ if (headers) throw new Error('--header applies to --url servers');
92
+ declaration = validateDeclaration({
93
+ name,
94
+ transport: 'stdio',
95
+ command: command[0],
96
+ args: command.slice(1),
97
+ ...(envs ? { env: envs } : {}),
98
+ });
99
+ }
100
+ writeOperatorMcpFile(path, { version: 1, servers: [...file.servers, declaration] });
101
+ return JSON.stringify({
102
+ added: name,
103
+ config: path,
104
+ declaration_digest: declarationDigest(declaration),
105
+ });
106
+ }
107
+ throw new Error('Use salaam mcp add, list or rm');
108
+ }