@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
@@ -0,0 +1,106 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { ServiceError } from './protocol.js';
3
+ const ITEM = /^itm_[0-9a-f]{16}$/;
4
+ /** The most a hub answer may carry back to the agent. */
5
+ export const BOARD_READ_MAX_BYTES = 256 * 1024;
6
+ const bad = (message) => new ServiceError('invalid_board_read', message, 400);
7
+ /** Closed arguments per action; anything else is refused. */
8
+ export function boardReadArgs(input) {
9
+ const { action, ...rest } = input;
10
+ const keys = Object.keys(rest).filter((k) => rest[k] !== undefined);
11
+ if (action === 'card' || action === 'attempts') {
12
+ if (keys.length)
13
+ throw bad(`${action} takes no arguments: it is always this run's own card`);
14
+ return { action, args: {} };
15
+ }
16
+ if (action === 'library_search') {
17
+ const q = rest.query;
18
+ if (keys.join() !== 'query' || typeof q !== 'string' || !q.trim() || q.length > 500)
19
+ throw bad('library_search takes one query of 1 to 500 characters');
20
+ return { action, args: { query: q.trim() } };
21
+ }
22
+ if (action === 'library_get') {
23
+ const id = rest.item_id;
24
+ if (keys.join() !== 'item_id' || typeof id !== 'string' || !ITEM.test(id))
25
+ throw bad('library_get takes one item_id (itm_ and 16 hex characters)');
26
+ return { action, args: { item_id: id } };
27
+ }
28
+ throw bad('Board reads are card, attempts, library_search or library_get');
29
+ }
30
+ export class BoardReads {
31
+ store;
32
+ constructor(store) {
33
+ this.store = store;
34
+ store.db.exec(`CREATE TABLE IF NOT EXISTS board_reads(
35
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, tool_call_id TEXT NOT NULL, action TEXT NOT NULL,
36
+ args TEXT NOT NULL, state TEXT NOT NULL, ok INTEGER, text TEXT, error TEXT,
37
+ created_at TEXT NOT NULL, answered_at TEXT, UNIQUE(session_id, tool_call_id))`);
38
+ }
39
+ view(row) {
40
+ return {
41
+ id: String(row.id),
42
+ action: String(row.action),
43
+ args: JSON.parse(String(row.args)),
44
+ state: String(row.state),
45
+ ...(row.state === 'answered'
46
+ ? {
47
+ ok: row.ok === 1,
48
+ ...(row.text !== null ? { text: String(row.text) } : {}),
49
+ ...(row.error !== null ? { error: String(row.error) } : {}),
50
+ }
51
+ : {}),
52
+ };
53
+ }
54
+ /** Record a read for this session, once per tool call. */
55
+ request(session, toolCallId, input) {
56
+ const { action, args } = boardReadArgs(input);
57
+ const existing = this.store.db
58
+ .prepare('SELECT * FROM board_reads WHERE session_id=? AND tool_call_id=?')
59
+ .get(session, toolCallId);
60
+ if (existing)
61
+ return this.view(existing);
62
+ const id = randomUUID();
63
+ this.store.db
64
+ .prepare("INSERT INTO board_reads(id,session_id,tool_call_id,action,args,state,created_at) VALUES (?,?,?,?,?,'pending',?)")
65
+ .run(id, session, toolCallId, action, JSON.stringify(args), new Date().toISOString());
66
+ return this.get(session, id);
67
+ }
68
+ get(session, id) {
69
+ const row = this.store.db
70
+ .prepare('SELECT * FROM board_reads WHERE id=? AND session_id=?')
71
+ .get(id, session);
72
+ if (!row)
73
+ throw new ServiceError('board_read_not_found', 'No such board read', 404);
74
+ return this.view(row);
75
+ }
76
+ /** What the hub should serve for this session, oldest first. */
77
+ pending(session) {
78
+ return this.store.db
79
+ .prepare("SELECT * FROM board_reads WHERE session_id=? AND state='pending' ORDER BY rowid LIMIT 8")
80
+ .all(session)
81
+ .map((r) => {
82
+ const v = this.view(r);
83
+ return { id: v.id, action: v.action, args: v.args };
84
+ });
85
+ }
86
+ /** The hub's answer: once, for a read of this session, bounded. */
87
+ answer(session, value) {
88
+ const a = (value ?? {});
89
+ const keys = Object.keys(a).sort().join();
90
+ const ok = a.ok === true;
91
+ if (typeof a.read_id !== 'string' ||
92
+ (ok
93
+ ? keys !== 'ok,read_id,text' || typeof a.text !== 'string'
94
+ : keys !== 'error,ok,read_id' || a.ok !== false || typeof a.error !== 'string'))
95
+ throw bad('Closed board read answer required');
96
+ const body = ok ? a.text : a.error;
97
+ if (Buffer.byteLength(body) > (ok ? BOARD_READ_MAX_BYTES : 2048))
98
+ throw bad('Board read answer too large');
99
+ const done = this.store.db
100
+ .prepare("UPDATE board_reads SET state='answered',ok=?,text=?,error=?,answered_at=? WHERE id=? AND session_id=? AND state='pending'")
101
+ .run(ok ? 1 : 0, ok ? body : null, ok ? null : body, new Date().toISOString(), a.read_id, session);
102
+ if (done.changes !== 1)
103
+ throw new ServiceError('board_read_settled', 'That board read is not pending for this run', 409);
104
+ return this.get(session, a.read_id);
105
+ }
106
+ }
@@ -169,6 +169,89 @@ export class ChildWork {
169
169
  this.start(child);
170
170
  return child;
171
171
  }
172
+ /**
173
+ * The task critic (item 8): a reviewer child over an immutable snapshot of the verified
174
+ * deliverables. No parent attempt is active at completion, so it is linked to the task's last
175
+ * step attempt and charged to its own bounded budget, never to an attempt's reservation. The
176
+ * grant must be the built-in reviewer; the child can read the snapshot and report findings only.
177
+ */
178
+ spawnCritic(parent, parentAttempt, step, prompt, budget, grant, snapshot) {
179
+ if (!Number.isSafeInteger(budget) || budget < 1 || budget > 8)
180
+ throw new ServiceError('invalid_budget', 'Critic request budget must be 1–8');
181
+ if (grant.profile.id !== 'salaam/reviewer' || grant.owner_session !== parent)
182
+ throw new ServiceError('authority_denied', 'The critic must be the built-in reviewer');
183
+ if (!Object.keys(snapshot).length)
184
+ throw new ServiceError('invalid_child', 'A critic needs a snapshot of the verified deliverables');
185
+ if (!prompt.trim() || prompt.length > 32768)
186
+ throw new ServiceError('invalid_child', 'Invalid critic prompt');
187
+ const authority = [...grant.profile.tools].sort();
188
+ const input = digest({
189
+ prompt,
190
+ budget,
191
+ critic: grant.profile_digest,
192
+ snapshot: digest(snapshot),
193
+ });
194
+ const prepare = () => {
195
+ const old = this.store.db
196
+ .prepare('SELECT * FROM child_tasks WHERE parent_session=? AND step_key=?')
197
+ .get(parent, step);
198
+ if (old) {
199
+ if (old.input_digest !== input)
200
+ throw new ServiceError('spawn_conflict', 'Critic step already binds a different review');
201
+ return { child: old, fresh: false };
202
+ }
203
+ if (this.paused)
204
+ throw new ServiceError('stopping', 'Child dispatch is stopping');
205
+ this.store.execution.assert(parent);
206
+ if (this.activeCount() >= 8)
207
+ throw new ServiceError('child_limit', 'Daemon worker admission limit reached');
208
+ const effectivePrompt = `${grant.profile.prompt}\n\nGoal:\n${prompt}`;
209
+ const id = randomUUID(), session = randomUUID(), submission = randomUUID(), childAttempt = randomUUID();
210
+ this.store.db
211
+ .prepare('INSERT INTO sessions(id,cwd,pi_session_id) VALUES (?,?,?)')
212
+ .run(session, this.store.session(parent).cwd, session);
213
+ this.store.db
214
+ .prepare('INSERT INTO submissions(id,session_id,text,attempt_id) VALUES (?,?,?,?)')
215
+ .run(submission, session, effectivePrompt, childAttempt);
216
+ this.store.db
217
+ .prepare("INSERT INTO attempts(id,session_id,submission_id,state) VALUES (?,?,?,'accepted')")
218
+ .run(childAttempt, session, submission);
219
+ this.store.db
220
+ .prepare("INSERT INTO child_tasks(id,parent_session,parent_attempt,step_key,input_digest,child_session,child_attempt,authority,budget,state) VALUES (?,?,?,?,?,?,?,?,?,'accepted')")
221
+ .run(id, parent, parentAttempt, step, input, session, childAttempt, JSON.stringify(authority), budget);
222
+ this.store.taskChildReserve?.(parent, childAttempt, budget);
223
+ this.store.usage.budget(childAttempt, budget);
224
+ const artifact = this.artifacts.prepare(session, effectivePrompt);
225
+ artifact.commit();
226
+ // The reviewed snapshot is the verified bytes; its base is empty, so its diff is all of it.
227
+ const current = this.artifacts.prepare(session, JSON.stringify(snapshot)), base = this.artifacts.prepare(session, JSON.stringify({}));
228
+ current.commit();
229
+ base.commit();
230
+ this.store.workerBoundary.freeze(this.owned(session), artifact.id, false, grant, {
231
+ artifact_id: current.id,
232
+ base_artifact: base.id,
233
+ digest: digest(snapshot),
234
+ });
235
+ this.store.event(session, 'session.created', { parent_session: parent, child_id: id });
236
+ this.store.event(session, 'submission.accepted', {
237
+ attempt_id: childAttempt,
238
+ submission_id: submission,
239
+ });
240
+ this.store.event(parent, 'child.spawned', {
241
+ child_id: id,
242
+ child_session: session,
243
+ step_key: step,
244
+ budget,
245
+ authority,
246
+ critic: true,
247
+ });
248
+ return { child: this.inspect(parent, id), fresh: true };
249
+ };
250
+ const { child, fresh } = this.store.transaction(prepare);
251
+ if (fresh)
252
+ this.start(child);
253
+ return child;
254
+ }
172
255
  activeCount(parent) {
173
256
  return Number(this.store.db
174
257
  .prepare(`SELECT count(*) AS n FROM child_tasks c WHERE (? IS NULL OR c.parent_session=?) AND (c.state='accepted' OR EXISTS(SELECT 1 FROM workers w WHERE w.session_id=c.child_session AND w.state!='exited') OR EXISTS(SELECT 1 FROM external_runs e WHERE e.child_id=c.id AND e.stopped=0))`)
@@ -103,6 +103,10 @@ salaam host inspect --manifest host-manifest.json
103
103
  salaam host unit --manifest host-manifest.json --platform systemd|launchd
104
104
  salaam host run --manifest host-manifest.json
105
105
  salaam host start --manifest host-manifest.json
106
+ salaam host unit-start --unit salaam-host@STATION.service --state DIR
107
+ salaam host bind --organization ORG --node NODE --station-key KEY --workspace DIR \
108
+ --tools-user USER --files HELPER --runtime-root DIR --runtime-image-digest sha256:… \
109
+ [--operator-group GROUP] [--provider P --model M] [--mcp-config FILE] [--persona FILE] [--managed-mcp-config FILE] [--dir DIR] [--state DIR]
106
110
 
107
111
  Config requires absolute state, coding_root and coding_socket paths; optional
108
112
  provider, model, max_output_tokens and workspace_id. Planning probes the existing
@@ -116,7 +120,18 @@ For a bound AgentPod host, use mode="agentpod", state and agentpod_config instea
116
120
  of coding_root/coding_socket. This profile enables no station tools. The private
117
121
  manifest pins Node, CLI, source/build, Pi, provider and authority pins. Invoke the
118
122
  canonical Node executable explicitly. host start waits for matching live health
119
- and safely coalesces concurrent callers; it never installs or upgrades packages.`,
123
+ and safely coalesces concurrent callers; it never installs or upgrades packages.
124
+
125
+ Launcher profile (Linux): root plans a strict work host with operator_group set.
126
+ A root-owned unit runs host run from that root-held manifest. The daemon lets that
127
+ one group traverse its state and use operator.sock. An unprivileged member then
128
+ runs host unit-start, which asks systemd to start the named salaam-host@ unit and
129
+ prints the receipt the running daemon reports. It cannot choose what root runs.
130
+
131
+ host bind (root, Linux) writes everything a launcher-profile station needs into
132
+ --dir (default /etc/salaam/stations/<key>): agentpod-pins.json, station-config.json
133
+ (with tools_user), host-config.json, and host.json from host plan. It is
134
+ idempotent: the same inputs leave the files and the manifest digest unchanged.`,
120
135
  schedules: `salaam schedules create TASK_ID --name NAME --revision N --max-steps N [--schedule FILE] [--compact]
121
136
  salaam schedules list [--offset N]
122
137
  salaam schedules inspect JOB_ID
@@ -208,10 +223,18 @@ Status prints metadata only. Existing Anthropic keys remain compatible.`,
208
223
  [--coding-root PRIVATE_DIR] [--coding-socket DOCKER_SOCKET]
209
224
  [--harness-config OWNER_ONLY_FILE] [--a2a-config OWNER_ONLY_FILE]
210
225
  [--a2a-listen-config OWNER_ONLY_FILE]
211
- [--agentpod-config OWNER_ONLY_FILE]
226
+ [--agentpod-config OWNER_ONLY_FILE] [--mcp-config FILE]
227
+ [--tools-user USER]
212
228
  salaam service status
213
229
  salaam service stop
214
230
 
231
+ --tools-user USER (or SALAAM_TOOLS_USER) runs local agent commands and edits as
232
+ USER through sudo -n -u USER on Linux. Without it, tools share the provider
233
+ credential's OS user and start prints a warning.
234
+
235
+ Without --agentpod-config, start binds to ~/.config/salaam/agentpod-pins.json
236
+ ($XDG_CONFIG_HOME/salaam) when that file exists.
237
+
215
238
  The default provider is offline. Anthropic defaults to claude-sonnet-5-5.
216
239
  Other providers require --model; use salaam providers list and salaam models list PROVIDER.
217
240
  Start with --foreground for process-supervisor use; otherwise the service detaches.
@@ -287,6 +310,7 @@ Commands:
287
310
  auth Configure provider credentials or OAuth
288
311
  service Start, inspect or stop the session service
289
312
  session Create, inspect, submit, resume or cancel work
313
+ mcp Declare operator MCP servers (add, list, rm)
290
314
  attach Open an interactive terminal on a session
291
315
  acp Connect an editor over ACP v1 stdio
292
316
  approvals Inspect, approve or deny managed operations
@@ -357,6 +381,7 @@ const valueOptions = new Set([
357
381
  '--a2a-config',
358
382
  '--agentpod-config',
359
383
  '--a2a-listen-config',
384
+ '--tools-user',
360
385
  '--assignment',
361
386
  '--peer',
362
387
  '--peer-client',
package/dist/src/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { SANDBOX_LEVELS } from './command-sandbox.js';
2
3
  import { acp } from './acp.js';
3
4
  import { homedir } from 'node:os';
4
5
  import { join, resolve, dirname } from 'node:path';
@@ -6,9 +7,12 @@ import { spawn } from 'node:child_process';
6
7
  import { randomUUID, randomBytes } from 'node:crypto';
7
8
  import { digest, object } from './protocol.js';
8
9
  import { fileURLToPath } from 'node:url';
9
- import { mkdirSync, openSync, closeSync, readFileSync, realpathSync, writeFileSync, fsyncSync, } from 'node:fs';
10
+ import { mkdirSync, openSync, closeSync, readFileSync, realpathSync, writeFileSync, fsyncSync, existsSync, } from 'node:fs';
10
11
  import { VERSION } from './config.js';
11
12
  import { cliHelp, hasHelpFlag } from './cli-help.js';
13
+ import { mcpCli, MCP_HELP } from './operator-mcp-cli.js';
14
+ import { operatorMcpPath, readOperatorMcpFile } from './operator-mcp.js';
15
+ import { defaultAgentPodConfig } from './agentpod-default-config.js';
12
16
  import { SalaamService } from './service.js';
13
17
  import { call } from './transport.js';
14
18
  import { attach, acquireControl, Outbox } from './attach.js';
@@ -29,6 +33,17 @@ async function main() {
29
33
  console.log('salaam ' + VERSION);
30
34
  return;
31
35
  }
36
+ if (args[0] === 'mcp') {
37
+ // Parsed before global help: a declared command's own flags follow "--".
38
+ const rest = args.slice(1);
39
+ const dash = rest.indexOf('--');
40
+ const flags = dash < 0 ? rest : rest.slice(0, dash);
41
+ if (!rest.length || flags.includes('--help') || flags.includes('-h'))
42
+ console.log(MCP_HELP);
43
+ else
44
+ console.log(mcpCli(rest));
45
+ return;
46
+ }
32
47
  if ((!args.length && !(process.stdin.isTTY && process.stdout.isTTY)) ||
33
48
  args[0] === 'help' ||
34
49
  hasHelpFlag(args)) {
@@ -388,7 +403,7 @@ async function main() {
388
403
  return;
389
404
  }
390
405
  if (group === 'host') {
391
- const { planHost, readHost, hostUnit, runHost } = await import('./host.js');
406
+ const { planHost, readHost, hostUnit, runHost, hostReceipt, hostToolsUser, hostApprovalTtlMs, hostAllowsUnsandboxed, hostCommandNetwork, } = await import('./host.js');
392
407
  if (verb?.startsWith('config-')) {
393
408
  const file = option('--manifest');
394
409
  if (!file)
@@ -413,6 +428,42 @@ async function main() {
413
428
  console.log(JSON.stringify(await planHost(JSON.parse(readFileSync(resolve(file), 'utf8'))), null, 2));
414
429
  return;
415
430
  }
431
+ if (verb === 'bind') {
432
+ const { bindHost } = await import('./host-bind.js');
433
+ const required = (name) => {
434
+ const v = option(name);
435
+ if (!v)
436
+ throw new Error('Supply ' + name);
437
+ return v;
438
+ };
439
+ console.log(JSON.stringify(await bindHost({
440
+ organization_id: required('--organization'),
441
+ node_id: required('--node'),
442
+ station_key: required('--station-key'),
443
+ workspace: required('--workspace'),
444
+ tools_user: required('--tools-user'),
445
+ files: required('--files'),
446
+ runtime_root: required('--runtime-root'),
447
+ runtime_image_digest: required('--runtime-image-digest'),
448
+ operator_group: option('--operator-group'),
449
+ dir: option('--dir'),
450
+ state: explicitState ? state : undefined,
451
+ provider: option('--provider'),
452
+ model: option('--model'),
453
+ mcp_config: option('--mcp-config'),
454
+ persona: option('--persona'),
455
+ managed_mcp_config: option('--managed-mcp-config'),
456
+ })));
457
+ return;
458
+ }
459
+ if (verb === 'unit-start') {
460
+ const { startHostUnit } = await import('./host-start.js');
461
+ const unit = option('--unit');
462
+ if (!unit || !explicitState)
463
+ throw new Error('Supply --unit UNIT and --state DIR');
464
+ console.log(JSON.stringify(await startHostUnit(unit, state)));
465
+ return;
466
+ }
416
467
  const file = option('--manifest');
417
468
  if (!file)
418
469
  throw new Error('Supply --manifest FILE');
@@ -462,9 +513,21 @@ async function main() {
462
513
  : {}),
463
514
  ...(m.schema === 5 || m.schema === 6 ? { stationConfig: m.station_execution } : {}),
464
515
  ...(m.schema === 6 ? { agentpodWorkProgression: true } : {}),
516
+ ...(m.schema === 6 && m.operator_access
517
+ ? { operatorAccess: m.operator_access }
518
+ : {}),
519
+ ...(m.schema === 6 && m.operator_mcp
520
+ ? { operatorMcpConfig: m.operator_mcp.config }
521
+ : {}),
522
+ ...(m.schema === 6 && m.persona ? { personaConfig: m.persona } : {}),
465
523
  }),
466
524
  hostManifestDigest: digest(m),
525
+ hostReceipt: hostReceipt(m),
526
+ toolsUser: hostToolsUser(m),
467
527
  providerRoute: m.provider,
528
+ approvalTtlMs: hostApprovalTtlMs(m),
529
+ ...(hostAllowsUnsandboxed(m) ? { unsandboxedCommands: 'allow' } : {}),
530
+ ...(hostCommandNetwork(m) ? { commandNetwork: hostCommandNetwork(m) } : {}),
468
531
  });
469
532
  if (process.connected)
470
533
  process.once('disconnect', () => void service.close());
@@ -1245,17 +1308,25 @@ async function main() {
1245
1308
  }
1246
1309
  const effectSocket = option('--effect-socket');
1247
1310
  const opaque = args.includes('--effect-opaque');
1248
- const codingRoot = option('--coding-root'), codingSocket = option('--coding-socket'), harnessConfig = option('--harness-config'), a2aConfig = option('--a2a-config'), agentpodConfig = option('--agentpod-config'), a2aListenConfig = option('--a2a-listen-config');
1311
+ const codingRoot = option('--coding-root'), codingSocket = option('--coding-socket'), harnessConfig = option('--harness-config'), a2aConfig = option('--a2a-config'), agentpodConfig = option('--agentpod-config') ?? defaultAgentPodConfig(process.env, existsSync), a2aListenConfig = option('--a2a-listen-config'), mcpConfig = resolve(option('--mcp-config') ?? operatorMcpPath(process.env)), toolsUser = option('--tools-user') ?? (process.env.SALAAM_TOOLS_USER || undefined), localSandbox = option('--local-sandbox') ?? (process.env.SALAAM_LOCAL_SANDBOX || undefined), unsandboxedCommands = option('--unsandboxed-commands') ?? (process.env.SALAAM_UNSANDBOXED_COMMANDS || undefined);
1312
+ if (localSandbox !== undefined && !SANDBOX_LEVELS.includes(localSandbox))
1313
+ throw Error('--local-sandbox is read-only, workspace-write or full-access');
1314
+ if (unsandboxedCommands !== undefined && unsandboxedCommands !== 'allow')
1315
+ throw Error('--unsandboxed-commands is allow or absent');
1249
1316
  if (foreground) {
1250
1317
  const service = await SalaamService.open({
1251
1318
  stateDir: state,
1252
1319
  providerRoute: route,
1253
1320
  agentpodConfig: agentpodConfig ? resolve(agentpodConfig) : undefined,
1321
+ operatorMcpConfig: mcpConfig,
1254
1322
  codingRoot: codingRoot ? resolve(codingRoot) : undefined,
1255
1323
  codingSocket: codingSocket ? resolve(codingSocket) : undefined,
1256
1324
  harnessConfig: harnessConfig ? resolve(harnessConfig) : undefined,
1257
1325
  a2aConfig: a2aConfig ? resolve(a2aConfig) : undefined,
1258
1326
  a2aListenConfig: a2aListenConfig ? resolve(a2aListenConfig) : undefined,
1327
+ toolsUser,
1328
+ ...(localSandbox ? { localSandbox: localSandbox } : {}),
1329
+ ...(unsandboxedCommands ? { unsandboxedCommands: 'allow' } : {}),
1259
1330
  effectConnector: effectSocket
1260
1331
  ? new SocketEffectConnector(resolve(effectSocket), { opaque })
1261
1332
  : undefined,
@@ -1263,6 +1334,9 @@ async function main() {
1263
1334
  const warning = service.supervisor.providerCapabilities().warning;
1264
1335
  if (warning)
1265
1336
  console.error(warning);
1337
+ // Only a live provider has a credential to share; the offline proof route stays quiet.
1338
+ if (service.toolIdentity.warning && route.provider !== 'salaam-proof')
1339
+ console.error('warning: ' + service.toolIdentity.warning);
1266
1340
  process.stdout.write(JSON.stringify({ socket, service: 'started' }) + '\n');
1267
1341
  for (const signal of ['SIGINT', 'SIGTERM'])
1268
1342
  process.once(signal, () => {
@@ -1290,6 +1364,11 @@ async function main() {
1290
1364
  if ((health.coding_root ?? null) !== (codingRoot ? resolve(codingRoot) : null) ||
1291
1365
  (health.coding_socket ?? null) !== (codingSocket ? resolve(codingSocket) : null))
1292
1366
  throw new Error('Service is already running with another coding configuration; stop it first');
1367
+ if ((health.operator_mcp?.config ?? null) !== mcpConfig)
1368
+ throw new Error('Service is already running with another MCP config; stop it first');
1369
+ if (health.operator_mcp &&
1370
+ health.operator_mcp.digest !== digest(readOperatorMcpFile(mcpConfig)))
1371
+ console.error('Declared MCP servers changed since the service started; run salaam service stop, then start, to apply them');
1293
1372
  console.log('Salaam service already running');
1294
1373
  return;
1295
1374
  }
@@ -1315,6 +1394,11 @@ async function main() {
1315
1394
  ...(a2aConfig ? ['--a2a-config', resolve(a2aConfig)] : []),
1316
1395
  ...(agentpodConfig ? ['--agentpod-config', resolve(agentpodConfig)] : []),
1317
1396
  ...(a2aListenConfig ? ['--a2a-listen-config', resolve(a2aListenConfig)] : []),
1397
+ '--mcp-config',
1398
+ mcpConfig,
1399
+ ...(toolsUser ? ['--tools-user', toolsUser] : []),
1400
+ ...(localSandbox ? ['--local-sandbox', localSandbox] : []),
1401
+ ...(unsandboxedCommands ? ['--unsandboxed-commands', 'allow'] : []),
1318
1402
  ], { detached: true, stdio: ['ignore', fd, fd] });
1319
1403
  closeSync(fd);
1320
1404
  child.unref();
@@ -0,0 +1,200 @@
1
+ import { createServer, connect } from 'node:net';
2
+ import { chmodSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ /**
6
+ * Network for local commands through the broker allowlist (operator rulings 2026-10-30). A command
7
+ * has no network of its own; its only route out is stk's `local-commands` socket, where every
8
+ * connection is the `local-commands` identity and only `servers.local-commands.egress.hosts`
9
+ * decides. Salaam puts a relay of its own in between, outside the sandbox, so what a command
10
+ * reached is counted where the command cannot touch it.
11
+ *
12
+ * Linux: the relay listens on a unix socket in a private directory bound into the bubblewrap
13
+ * sandbox; inside, `inSandboxRelaySource` bridges 127.0.0.1:COMMAND_NETWORK_PORT to it and then
14
+ * starts the command. macOS: the relay listens on 127.0.0.1:<ephemeral>, the only address the
15
+ * Seatbelt profile lets the command connect to.
16
+ */
17
+ export const COMMAND_NETWORK_PORT = 3128;
18
+ export const DEFAULT_COMMAND_NETWORK_SOCKET = '/run/stk/local-commands.sock';
19
+ const MAX_TARGETS = 32;
20
+ const MAX_REASON = 300;
21
+ export class BrokerRelay {
22
+ server;
23
+ broker;
24
+ port;
25
+ path;
26
+ dir;
27
+ counts = new Map();
28
+ truncated = false;
29
+ unavailable = false;
30
+ sockets = new Set();
31
+ constructor(server, broker, port, path, dir) {
32
+ this.server = server;
33
+ this.broker = broker;
34
+ this.port = port;
35
+ this.path = path;
36
+ this.dir = dir;
37
+ }
38
+ /** `tcp`: 127.0.0.1:<ephemeral> (macOS). `unix`: a socket in a new private directory (Linux),
39
+ * readable by the command's user so a tools-user command can reach it. */
40
+ static async start(broker, mode) {
41
+ let relay;
42
+ const server = createServer((client) => relay.handle(client));
43
+ if (mode === 'tcp') {
44
+ await new Promise((resolve, reject) => {
45
+ server.once('error', reject);
46
+ server.listen(0, '127.0.0.1', () => resolve());
47
+ });
48
+ relay = new BrokerRelay(server, broker, server.address().port, null, null);
49
+ }
50
+ else {
51
+ const dir = mkdtempSync(join(tmpdir(), 'salaam-net-'));
52
+ chmodSync(dir, 0o755);
53
+ const path = join(dir, 'relay.sock');
54
+ await new Promise((resolve, reject) => {
55
+ server.once('error', reject);
56
+ server.listen(path, () => resolve());
57
+ });
58
+ chmodSync(path, 0o666);
59
+ relay = new BrokerRelay(server, broker, null, path, dir);
60
+ }
61
+ return relay;
62
+ }
63
+ /** A client connection to this relay, for tests. */
64
+ connect() {
65
+ return this.path ? connect(this.path) : connect(this.port, '127.0.0.1');
66
+ }
67
+ record(target, allowed, reason) {
68
+ let entry = this.counts.get(target);
69
+ if (!entry) {
70
+ if (this.counts.size >= MAX_TARGETS) {
71
+ this.truncated = true;
72
+ return;
73
+ }
74
+ entry = { allowed: 0, refused: 0 };
75
+ this.counts.set(target, entry);
76
+ }
77
+ if (allowed)
78
+ entry.allowed++;
79
+ else {
80
+ entry.refused++;
81
+ if (reason && !entry.reason)
82
+ entry.reason = reason.slice(0, MAX_REASON);
83
+ }
84
+ }
85
+ handle(client) {
86
+ this.sockets.add(client);
87
+ client.on('close', () => this.sockets.delete(client));
88
+ client.on('error', () => { });
89
+ let head = Buffer.alloc(0);
90
+ const onHead = (chunk) => {
91
+ head = Buffer.concat([head, chunk]);
92
+ const end = head.indexOf('\r\n');
93
+ if (end < 0) {
94
+ if (head.length > 8192)
95
+ client.destroy();
96
+ return;
97
+ }
98
+ client.off('data', onHead);
99
+ client.pause();
100
+ const line = head.subarray(0, end).toString('latin1');
101
+ const connectLine = /^CONNECT (\S+) HTTP\/1\.[01]$/.exec(line);
102
+ const absolute = /^[A-Z]+ http:\/\/([^/\s]+)/.exec(line);
103
+ const target = connectLine
104
+ ? connectLine[1].toLowerCase()
105
+ : absolute
106
+ ? (absolute[1].includes(':') ? absolute[1] : absolute[1] + ':80').toLowerCase()
107
+ : 'invalid-request';
108
+ const up = connect(this.broker);
109
+ this.sockets.add(up);
110
+ up.on('close', () => this.sockets.delete(up));
111
+ up.once('error', () => {
112
+ this.unavailable = true;
113
+ this.record(target, false, 'the broker socket is unavailable');
114
+ const body = 'salaam: the command network broker is unavailable\n';
115
+ client.end(`HTTP/1.1 502 Bad Gateway\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`);
116
+ });
117
+ up.once('connect', () => {
118
+ let reply = Buffer.alloc(0), decided = false;
119
+ const onReply = (chunk) => {
120
+ client.write(chunk);
121
+ if (decided)
122
+ return;
123
+ reply = Buffer.concat([reply, chunk]);
124
+ const status = /^HTTP\/1\.[01] (\d{3})/.exec(reply.toString('latin1'));
125
+ if (!status) {
126
+ if (reply.length > 8192)
127
+ decided = true;
128
+ return;
129
+ }
130
+ const code = Number(status[1]);
131
+ if (code >= 200 && code < 300) {
132
+ decided = true;
133
+ this.record(target, true);
134
+ return;
135
+ }
136
+ // A refusal: wait for the body so the reason names the fix, as stk writes it.
137
+ const text = reply.toString('utf8');
138
+ const bodyAt = text.indexOf('\r\n\r\n');
139
+ if (bodyAt >= 0 && text.length > bodyAt + 4) {
140
+ decided = true;
141
+ this.record(target, false, text
142
+ .slice(bodyAt + 4)
143
+ .trim()
144
+ .split('\n')[0]);
145
+ }
146
+ };
147
+ up.on('data', onReply);
148
+ up.on('end', () => {
149
+ if (!decided) {
150
+ decided = true;
151
+ const status = /^HTTP\/1\.[01] (\d{3})[^\r\n]*/.exec(reply.toString('latin1'));
152
+ const code = status ? Number(status[1]) : 0;
153
+ this.record(target, code >= 200 && code < 300, status ? status[0] : 'no reply');
154
+ }
155
+ client.end();
156
+ });
157
+ up.write(head);
158
+ client.on('data', (d) => up.write(d));
159
+ client.on('end', () => up.end());
160
+ client.resume();
161
+ });
162
+ };
163
+ client.on('data', onHead);
164
+ }
165
+ summary() {
166
+ return {
167
+ broker: this.unavailable ? 'unavailable' : 'available',
168
+ connections: [...this.counts].map(([target, c]) => ({ target, ...c })),
169
+ ...(this.truncated ? { truncated: true } : {}),
170
+ };
171
+ }
172
+ async close() {
173
+ for (const s of this.sockets)
174
+ s.destroy();
175
+ await new Promise((resolve) => this.server.close(() => resolve()));
176
+ if (this.dir)
177
+ rmSync(this.dir, { recursive: true, force: true });
178
+ }
179
+ }
180
+ /**
181
+ * Runs inside the bubblewrap sandbox as `node -e <this> <unix socket> <port> <command...>`: listens
182
+ * on 127.0.0.1:<port> in the sandbox's own network namespace, pipes each connection to the bound
183
+ * relay socket, then starts the command with its stdio and exits with its status. It decides
184
+ * nothing; the host-side relay and stk do.
185
+ */
186
+ export const inSandboxRelaySource = `
187
+ const net = require('node:net'), { spawn } = require('node:child_process');
188
+ const [sock, port, cmd, ...args] = process.argv.slice(1);
189
+ const server = net.createServer((c) => {
190
+ const u = net.connect(sock);
191
+ c.on('error', () => u.destroy()); u.on('error', () => c.destroy());
192
+ c.pipe(u).pipe(c);
193
+ });
194
+ server.listen(Number(port), '127.0.0.1', () => {
195
+ const child = spawn(cmd, args, { stdio: 'inherit' });
196
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 128 : 1)));
197
+ child.on('error', () => process.exit(127));
198
+ });
199
+ server.on('error', () => process.exit(126));
200
+ `.trim();