@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,148 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { Store } from './store.js';
3
+ import { ServiceError } from './protocol.js';
4
+
5
+ /**
6
+ * Board reads (operator ruling 2026-10-28, option iii). While it works, the agent may read its own
7
+ * card, its card's runs, and search or open library items, the way an agent with board tools does.
8
+ * Salaam holds no SuperPipeline or SuperLibrary credential: it records the read, the hub sees it in
9
+ * work status, serves it with its own clients, and answers. A read names no card, board or
10
+ * credential; the hub decides scope from the run it is serving. Read-only: nothing here asks for
11
+ * approval, and nothing a read returns authorizes anything.
12
+ */
13
+ export type BoardReadAction = 'card' | 'attempts' | 'library_search' | 'library_get';
14
+ export interface BoardRead {
15
+ id: string;
16
+ action: BoardReadAction;
17
+ args: Record<string, string>;
18
+ state: 'pending' | 'answered';
19
+ ok?: boolean;
20
+ text?: string;
21
+ error?: string;
22
+ }
23
+ const ITEM = /^itm_[0-9a-f]{16}$/;
24
+ /** The most a hub answer may carry back to the agent. */
25
+ export const BOARD_READ_MAX_BYTES = 256 * 1024;
26
+
27
+ const bad = (message: string) => new ServiceError('invalid_board_read', message, 400);
28
+
29
+ /** Closed arguments per action; anything else is refused. */
30
+ export function boardReadArgs(input: Record<string, unknown>): {
31
+ action: BoardReadAction;
32
+ args: Record<string, string>;
33
+ } {
34
+ const { action, ...rest } = input;
35
+ const keys = Object.keys(rest).filter((k) => rest[k] !== undefined);
36
+ if (action === 'card' || action === 'attempts') {
37
+ if (keys.length) throw bad(`${action} takes no arguments: it is always this run's own card`);
38
+ return { action, args: {} };
39
+ }
40
+ if (action === 'library_search') {
41
+ const q = rest.query;
42
+ if (keys.join() !== 'query' || typeof q !== 'string' || !q.trim() || q.length > 500)
43
+ throw bad('library_search takes one query of 1 to 500 characters');
44
+ return { action, args: { query: q.trim() } };
45
+ }
46
+ if (action === 'library_get') {
47
+ const id = rest.item_id;
48
+ if (keys.join() !== 'item_id' || typeof id !== 'string' || !ITEM.test(id))
49
+ throw bad('library_get takes one item_id (itm_ and 16 hex characters)');
50
+ return { action, args: { item_id: id } };
51
+ }
52
+ throw bad('Board reads are card, attempts, library_search or library_get');
53
+ }
54
+
55
+ export class BoardReads {
56
+ constructor(private readonly store: Store) {
57
+ store.db.exec(`CREATE TABLE IF NOT EXISTS board_reads(
58
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, tool_call_id TEXT NOT NULL, action TEXT NOT NULL,
59
+ args TEXT NOT NULL, state TEXT NOT NULL, ok INTEGER, text TEXT, error TEXT,
60
+ created_at TEXT NOT NULL, answered_at TEXT, UNIQUE(session_id, tool_call_id))`);
61
+ }
62
+ private view(row: Record<string, unknown>): BoardRead {
63
+ return {
64
+ id: String(row.id),
65
+ action: String(row.action) as BoardReadAction,
66
+ args: JSON.parse(String(row.args)) as Record<string, string>,
67
+ state: String(row.state) as BoardRead['state'],
68
+ ...(row.state === 'answered'
69
+ ? {
70
+ ok: row.ok === 1,
71
+ ...(row.text !== null ? { text: String(row.text) } : {}),
72
+ ...(row.error !== null ? { error: String(row.error) } : {}),
73
+ }
74
+ : {}),
75
+ };
76
+ }
77
+ /** Record a read for this session, once per tool call. */
78
+ request(session: string, toolCallId: string, input: Record<string, unknown>): BoardRead {
79
+ const { action, args } = boardReadArgs(input);
80
+ const existing = this.store.db
81
+ .prepare('SELECT * FROM board_reads WHERE session_id=? AND tool_call_id=?')
82
+ .get(session, toolCallId);
83
+ if (existing) return this.view(existing);
84
+ const id = randomUUID();
85
+ this.store.db
86
+ .prepare(
87
+ "INSERT INTO board_reads(id,session_id,tool_call_id,action,args,state,created_at) VALUES (?,?,?,?,?,'pending',?)",
88
+ )
89
+ .run(id, session, toolCallId, action, JSON.stringify(args), new Date().toISOString());
90
+ return this.get(session, id);
91
+ }
92
+ get(session: string, id: string): BoardRead {
93
+ const row = this.store.db
94
+ .prepare('SELECT * FROM board_reads WHERE id=? AND session_id=?')
95
+ .get(id, session);
96
+ if (!row) throw new ServiceError('board_read_not_found', 'No such board read', 404);
97
+ return this.view(row);
98
+ }
99
+ /** What the hub should serve for this session, oldest first. */
100
+ pending(
101
+ session: string,
102
+ ): { id: string; action: BoardReadAction; args: Record<string, string> }[] {
103
+ return this.store.db
104
+ .prepare(
105
+ "SELECT * FROM board_reads WHERE session_id=? AND state='pending' ORDER BY rowid LIMIT 8",
106
+ )
107
+ .all(session)
108
+ .map((r) => {
109
+ const v = this.view(r);
110
+ return { id: v.id, action: v.action, args: v.args };
111
+ });
112
+ }
113
+ /** The hub's answer: once, for a read of this session, bounded. */
114
+ answer(session: string, value: unknown): BoardRead {
115
+ const a = (value ?? {}) as Record<string, unknown>;
116
+ const keys = Object.keys(a).sort().join();
117
+ const ok = a.ok === true;
118
+ if (
119
+ typeof a.read_id !== 'string' ||
120
+ (ok
121
+ ? keys !== 'ok,read_id,text' || typeof a.text !== 'string'
122
+ : keys !== 'error,ok,read_id' || a.ok !== false || typeof a.error !== 'string')
123
+ )
124
+ throw bad('Closed board read answer required');
125
+ const body = ok ? (a.text as string) : (a.error as string);
126
+ if (Buffer.byteLength(body) > (ok ? BOARD_READ_MAX_BYTES : 2048))
127
+ throw bad('Board read answer too large');
128
+ const done = this.store.db
129
+ .prepare(
130
+ "UPDATE board_reads SET state='answered',ok=?,text=?,error=?,answered_at=? WHERE id=? AND session_id=? AND state='pending'",
131
+ )
132
+ .run(
133
+ ok ? 1 : 0,
134
+ ok ? body : null,
135
+ ok ? null : body,
136
+ new Date().toISOString(),
137
+ a.read_id,
138
+ session,
139
+ );
140
+ if (done.changes !== 1)
141
+ throw new ServiceError(
142
+ 'board_read_settled',
143
+ 'That board read is not pending for this run',
144
+ 409,
145
+ );
146
+ return this.get(session, a.read_id);
147
+ }
148
+ }
package/src/children.ts CHANGED
@@ -7,6 +7,8 @@ import type { RepositorySnapshots } from './repository-snapshots.js';
7
7
  import type { WorkerWorkspaces } from './worker-workspaces.js';
8
8
  import type { ExternalRuntime } from './external-runtime.js';
9
9
  import type { ExternalWork } from './external-work.js';
10
+ import type { AgentGrant } from './agent-profiles.js';
11
+ import type { WorkspaceTree } from './workspaces.js';
10
12
 
11
13
  export class ChildWork {
12
14
  external?: ExternalWork;
@@ -222,6 +224,116 @@ export class ChildWork {
222
224
  this.start(child);
223
225
  return child;
224
226
  }
227
+ /**
228
+ * The task critic (item 8): a reviewer child over an immutable snapshot of the verified
229
+ * deliverables. No parent attempt is active at completion, so it is linked to the task's last
230
+ * step attempt and charged to its own bounded budget, never to an attempt's reservation. The
231
+ * grant must be the built-in reviewer; the child can read the snapshot and report findings only.
232
+ */
233
+ spawnCritic(
234
+ parent: string,
235
+ parentAttempt: string,
236
+ step: string,
237
+ prompt: string,
238
+ budget: number,
239
+ grant: AgentGrant,
240
+ snapshot: WorkspaceTree,
241
+ ): ChildTask {
242
+ if (!Number.isSafeInteger(budget) || budget < 1 || budget > 8)
243
+ throw new ServiceError('invalid_budget', 'Critic request budget must be 1–8');
244
+ if (grant.profile.id !== 'salaam/reviewer' || grant.owner_session !== parent)
245
+ throw new ServiceError('authority_denied', 'The critic must be the built-in reviewer');
246
+ if (!Object.keys(snapshot).length)
247
+ throw new ServiceError(
248
+ 'invalid_child',
249
+ 'A critic needs a snapshot of the verified deliverables',
250
+ );
251
+ if (!prompt.trim() || prompt.length > 32768)
252
+ throw new ServiceError('invalid_child', 'Invalid critic prompt');
253
+ const authority = [...grant.profile.tools].sort();
254
+ const input = digest({
255
+ prompt,
256
+ budget,
257
+ critic: grant.profile_digest,
258
+ snapshot: digest(snapshot),
259
+ });
260
+ const prepare = () => {
261
+ const old = this.store.db
262
+ .prepare('SELECT * FROM child_tasks WHERE parent_session=? AND step_key=?')
263
+ .get(parent, step) as unknown as ChildTask | undefined;
264
+ if (old) {
265
+ if (old.input_digest !== input)
266
+ throw new ServiceError('spawn_conflict', 'Critic step already binds a different review');
267
+ return { child: old, fresh: false };
268
+ }
269
+ if (this.paused) throw new ServiceError('stopping', 'Child dispatch is stopping');
270
+ this.store.execution.assert(parent);
271
+ if (this.activeCount() >= 8)
272
+ throw new ServiceError('child_limit', 'Daemon worker admission limit reached');
273
+ const effectivePrompt = `${grant.profile.prompt}\n\nGoal:\n${prompt}`;
274
+ const id = randomUUID(),
275
+ session = randomUUID(),
276
+ submission = randomUUID(),
277
+ childAttempt = randomUUID();
278
+ this.store.db
279
+ .prepare('INSERT INTO sessions(id,cwd,pi_session_id) VALUES (?,?,?)')
280
+ .run(session, this.store.session(parent).cwd, session);
281
+ this.store.db
282
+ .prepare('INSERT INTO submissions(id,session_id,text,attempt_id) VALUES (?,?,?,?)')
283
+ .run(submission, session, effectivePrompt, childAttempt);
284
+ this.store.db
285
+ .prepare(
286
+ "INSERT INTO attempts(id,session_id,submission_id,state) VALUES (?,?,?,'accepted')",
287
+ )
288
+ .run(childAttempt, session, submission);
289
+ this.store.db
290
+ .prepare(
291
+ "INSERT INTO child_tasks(id,parent_session,parent_attempt,step_key,input_digest,child_session,child_attempt,authority,budget,state) VALUES (?,?,?,?,?,?,?,?,?,'accepted')",
292
+ )
293
+ .run(
294
+ id,
295
+ parent,
296
+ parentAttempt,
297
+ step,
298
+ input,
299
+ session,
300
+ childAttempt,
301
+ JSON.stringify(authority),
302
+ budget,
303
+ );
304
+ this.store.taskChildReserve?.(parent, childAttempt, budget);
305
+ this.store.usage.budget(childAttempt, budget);
306
+ const artifact = this.artifacts.prepare(session, effectivePrompt);
307
+ artifact.commit();
308
+ // The reviewed snapshot is the verified bytes; its base is empty, so its diff is all of it.
309
+ const current = this.artifacts.prepare(session, JSON.stringify(snapshot)),
310
+ base = this.artifacts.prepare(session, JSON.stringify({}));
311
+ current.commit();
312
+ base.commit();
313
+ this.store.workerBoundary.freeze(this.owned(session)!, artifact.id, false, grant, {
314
+ artifact_id: current.id,
315
+ base_artifact: base.id,
316
+ digest: digest(snapshot),
317
+ });
318
+ this.store.event(session, 'session.created', { parent_session: parent, child_id: id });
319
+ this.store.event(session, 'submission.accepted', {
320
+ attempt_id: childAttempt,
321
+ submission_id: submission,
322
+ });
323
+ this.store.event(parent, 'child.spawned', {
324
+ child_id: id,
325
+ child_session: session,
326
+ step_key: step,
327
+ budget,
328
+ authority,
329
+ critic: true,
330
+ });
331
+ return { child: this.inspect(parent, id), fresh: true };
332
+ };
333
+ const { child, fresh } = this.store.transaction(prepare);
334
+ if (fresh) this.start(child);
335
+ return child;
336
+ }
225
337
  activeCount(parent?: string): number {
226
338
  return Number(
227
339
  this.store.db
package/src/cli-help.ts CHANGED
@@ -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.
@@ -286,6 +309,7 @@ Commands:
286
309
  auth Configure provider credentials or OAuth
287
310
  service Start, inspect or stop the session service
288
311
  session Create, inspect, submit, resume or cancel work
312
+ mcp Declare operator MCP servers (add, list, rm)
289
313
  attach Open an interactive terminal on a session
290
314
  acp Connect an editor over ACP v1 stdio
291
315
  approvals Inspect, approve or deny managed operations
@@ -356,6 +380,7 @@ const valueOptions = new Set([
356
380
  '--a2a-config',
357
381
  '--agentpod-config',
358
382
  '--a2a-listen-config',
383
+ '--tools-user',
359
384
  '--assignment',
360
385
  '--peer',
361
386
  '--peer-client',
package/src/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { SANDBOX_LEVELS, type SandboxLevel } 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';
@@ -14,9 +15,13 @@ import {
14
15
  realpathSync,
15
16
  writeFileSync,
16
17
  fsyncSync,
18
+ existsSync,
17
19
  } from 'node:fs';
18
20
  import { ROOT, VERSION } from './config.js';
19
21
  import { cliHelp, hasHelpFlag } from './cli-help.js';
22
+ import { mcpCli, MCP_HELP } from './operator-mcp-cli.js';
23
+ import { operatorMcpPath, readOperatorMcpFile } from './operator-mcp.js';
24
+ import { defaultAgentPodConfig } from './agentpod-default-config.js';
20
25
  import { SalaamService } from './service.js';
21
26
  import { call } from './transport.js';
22
27
  import { attach, acquireControl, Outbox } from './attach.js';
@@ -62,6 +67,15 @@ async function main(): Promise<void> {
62
67
  console.log('salaam ' + VERSION);
63
68
  return;
64
69
  }
70
+ if (args[0] === 'mcp') {
71
+ // Parsed before global help: a declared command's own flags follow "--".
72
+ const rest = args.slice(1);
73
+ const dash = rest.indexOf('--');
74
+ const flags = dash < 0 ? rest : rest.slice(0, dash);
75
+ if (!rest.length || flags.includes('--help') || flags.includes('-h')) console.log(MCP_HELP);
76
+ else console.log(mcpCli(rest));
77
+ return;
78
+ }
65
79
  if (
66
80
  (!args.length && !(process.stdin.isTTY && process.stdout.isTTY)) ||
67
81
  args[0] === 'help' ||
@@ -494,7 +508,17 @@ async function main(): Promise<void> {
494
508
  return;
495
509
  }
496
510
  if (group === 'host') {
497
- const { planHost, readHost, hostUnit, runHost } = await import('./host.js');
511
+ const {
512
+ planHost,
513
+ readHost,
514
+ hostUnit,
515
+ runHost,
516
+ hostReceipt,
517
+ hostToolsUser,
518
+ hostApprovalTtlMs,
519
+ hostAllowsUnsandboxed,
520
+ hostCommandNetwork,
521
+ } = await import('./host.js');
498
522
  if (verb?.startsWith('config-')) {
499
523
  const file = option('--manifest');
500
524
  if (!file) throw Error('Supply --manifest FILE');
@@ -526,6 +550,44 @@ async function main(): Promise<void> {
526
550
  );
527
551
  return;
528
552
  }
553
+ if (verb === 'bind') {
554
+ const { bindHost } = await import('./host-bind.js');
555
+ const required = (name: string) => {
556
+ const v = option(name);
557
+ if (!v) throw new Error('Supply ' + name);
558
+ return v;
559
+ };
560
+ console.log(
561
+ JSON.stringify(
562
+ await bindHost({
563
+ organization_id: required('--organization'),
564
+ node_id: required('--node'),
565
+ station_key: required('--station-key'),
566
+ workspace: required('--workspace'),
567
+ tools_user: required('--tools-user'),
568
+ files: required('--files'),
569
+ runtime_root: required('--runtime-root'),
570
+ runtime_image_digest: required('--runtime-image-digest'),
571
+ operator_group: option('--operator-group'),
572
+ dir: option('--dir'),
573
+ state: explicitState ? state : undefined,
574
+ provider: option('--provider'),
575
+ model: option('--model'),
576
+ mcp_config: option('--mcp-config'),
577
+ persona: option('--persona'),
578
+ managed_mcp_config: option('--managed-mcp-config'),
579
+ }),
580
+ ),
581
+ );
582
+ return;
583
+ }
584
+ if (verb === 'unit-start') {
585
+ const { startHostUnit } = await import('./host-start.js');
586
+ const unit = option('--unit');
587
+ if (!unit || !explicitState) throw new Error('Supply --unit UNIT and --state DIR');
588
+ console.log(JSON.stringify(await startHostUnit(unit, state)));
589
+ return;
590
+ }
529
591
  const file = option('--manifest');
530
592
  if (!file) throw new Error('Supply --manifest FILE');
531
593
  if (verb === 'inspect') {
@@ -585,9 +647,21 @@ async function main(): Promise<void> {
585
647
  : {}),
586
648
  ...(m.schema === 5 || m.schema === 6 ? { stationConfig: m.station_execution } : {}),
587
649
  ...(m.schema === 6 ? { agentpodWorkProgression: true } : {}),
650
+ ...(m.schema === 6 && m.operator_access
651
+ ? { operatorAccess: m.operator_access }
652
+ : {}),
653
+ ...(m.schema === 6 && m.operator_mcp
654
+ ? { operatorMcpConfig: m.operator_mcp.config }
655
+ : {}),
656
+ ...(m.schema === 6 && m.persona ? { personaConfig: m.persona } : {}),
588
657
  }),
589
658
  hostManifestDigest: digest(m),
659
+ hostReceipt: hostReceipt(m),
660
+ toolsUser: hostToolsUser(m),
590
661
  providerRoute: m.provider,
662
+ approvalTtlMs: hostApprovalTtlMs(m),
663
+ ...(hostAllowsUnsandboxed(m) ? { unsandboxedCommands: 'allow' as const } : {}),
664
+ ...(hostCommandNetwork(m) ? { commandNetwork: hostCommandNetwork(m)! } : {}),
591
665
  });
592
666
  if (process.connected) process.once('disconnect', () => void service.close());
593
667
  for (const signal of ['SIGTERM', 'SIGINT'] as const)
@@ -1553,24 +1627,41 @@ async function main(): Promise<void> {
1553
1627
  codingSocket = option('--coding-socket'),
1554
1628
  harnessConfig = option('--harness-config'),
1555
1629
  a2aConfig = option('--a2a-config'),
1556
- agentpodConfig = option('--agentpod-config'),
1557
- a2aListenConfig = option('--a2a-listen-config');
1630
+ agentpodConfig =
1631
+ option('--agentpod-config') ?? defaultAgentPodConfig(process.env, existsSync),
1632
+ a2aListenConfig = option('--a2a-listen-config'),
1633
+ mcpConfig = resolve(option('--mcp-config') ?? operatorMcpPath(process.env)),
1634
+ toolsUser = option('--tools-user') ?? (process.env.SALAAM_TOOLS_USER || undefined),
1635
+ localSandbox = option('--local-sandbox') ?? (process.env.SALAAM_LOCAL_SANDBOX || undefined),
1636
+ unsandboxedCommands =
1637
+ option('--unsandboxed-commands') ?? (process.env.SALAAM_UNSANDBOXED_COMMANDS || undefined);
1638
+ if (localSandbox !== undefined && !(SANDBOX_LEVELS as readonly string[]).includes(localSandbox))
1639
+ throw Error('--local-sandbox is read-only, workspace-write or full-access');
1640
+ if (unsandboxedCommands !== undefined && unsandboxedCommands !== 'allow')
1641
+ throw Error('--unsandboxed-commands is allow or absent');
1558
1642
  if (foreground) {
1559
1643
  const service = await SalaamService.open({
1560
1644
  stateDir: state,
1561
1645
  providerRoute: route,
1562
1646
  agentpodConfig: agentpodConfig ? resolve(agentpodConfig) : undefined,
1647
+ operatorMcpConfig: mcpConfig,
1563
1648
  codingRoot: codingRoot ? resolve(codingRoot) : undefined,
1564
1649
  codingSocket: codingSocket ? resolve(codingSocket) : undefined,
1565
1650
  harnessConfig: harnessConfig ? resolve(harnessConfig) : undefined,
1566
1651
  a2aConfig: a2aConfig ? resolve(a2aConfig) : undefined,
1567
1652
  a2aListenConfig: a2aListenConfig ? resolve(a2aListenConfig) : undefined,
1653
+ toolsUser,
1654
+ ...(localSandbox ? { localSandbox: localSandbox as SandboxLevel } : {}),
1655
+ ...(unsandboxedCommands ? { unsandboxedCommands: 'allow' as const } : {}),
1568
1656
  effectConnector: effectSocket
1569
1657
  ? new SocketEffectConnector(resolve(effectSocket), { opaque })
1570
1658
  : undefined,
1571
1659
  });
1572
1660
  const warning = service.supervisor.providerCapabilities().warning;
1573
1661
  if (warning) console.error(warning);
1662
+ // Only a live provider has a credential to share; the offline proof route stays quiet.
1663
+ if (service.toolIdentity.warning && route.provider !== 'salaam-proof')
1664
+ console.error('warning: ' + service.toolIdentity.warning);
1574
1665
  process.stdout.write(JSON.stringify({ socket, service: 'started' }) + '\n');
1575
1666
  for (const signal of ['SIGINT', 'SIGTERM'] as const)
1576
1667
  process.once(signal, () => {
@@ -1589,6 +1680,7 @@ async function main(): Promise<void> {
1589
1680
  coding_socket?: string | null;
1590
1681
  agentpod_config?: string | null;
1591
1682
  agentpod_pins_digest?: string | null;
1683
+ operator_mcp?: { config: string; digest: string } | null;
1592
1684
  }
1593
1685
  | undefined;
1594
1686
  try {
@@ -1613,6 +1705,15 @@ async function main(): Promise<void> {
1613
1705
  throw new Error(
1614
1706
  'Service is already running with another coding configuration; stop it first',
1615
1707
  );
1708
+ if ((health.operator_mcp?.config ?? null) !== mcpConfig)
1709
+ throw new Error('Service is already running with another MCP config; stop it first');
1710
+ if (
1711
+ health.operator_mcp &&
1712
+ health.operator_mcp.digest !== digest(readOperatorMcpFile(mcpConfig))
1713
+ )
1714
+ console.error(
1715
+ 'Declared MCP servers changed since the service started; run salaam service stop, then start, to apply them',
1716
+ );
1616
1717
  console.log('Salaam service already running');
1617
1718
  return;
1618
1719
  }
@@ -1640,6 +1741,11 @@ async function main(): Promise<void> {
1640
1741
  ...(a2aConfig ? ['--a2a-config', resolve(a2aConfig)] : []),
1641
1742
  ...(agentpodConfig ? ['--agentpod-config', resolve(agentpodConfig)] : []),
1642
1743
  ...(a2aListenConfig ? ['--a2a-listen-config', resolve(a2aListenConfig)] : []),
1744
+ '--mcp-config',
1745
+ mcpConfig,
1746
+ ...(toolsUser ? ['--tools-user', toolsUser] : []),
1747
+ ...(localSandbox ? ['--local-sandbox', localSandbox] : []),
1748
+ ...(unsandboxedCommands ? ['--unsandboxed-commands', 'allow'] : []),
1643
1749
  ],
1644
1750
  { detached: true, stdio: ['ignore', fd, fd] },
1645
1751
  );