@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.
- package/CHANGELOG.md +45 -0
- package/README.md +12 -11
- package/dist/src/acp-outcome.js +177 -17
- package/dist/src/acp.js +41 -7
- package/dist/src/agent-profiles.js +45 -0
- package/dist/src/agentpod-authority.js +169 -11
- package/dist/src/agentpod-default-config.js +12 -0
- package/dist/src/agentpod-work-decision-protocol.js +40 -6
- package/dist/src/agentpod-work-decisions.js +108 -4
- package/dist/src/agentpod-work.js +1214 -127
- package/dist/src/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +27 -2
- package/dist/src/cli.js +87 -3
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +217 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/host-bind.js +111 -0
- package/dist/src/host-breaker.js +88 -0
- package/dist/src/host-start.js +41 -1
- package/dist/src/host.js +192 -6
- package/dist/src/house-extension.js +162 -57
- package/dist/src/local-coding.js +214 -18
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/operator-mcp-cli.js +107 -0
- package/dist/src/operator-mcp.js +342 -0
- package/dist/src/persona.js +31 -0
- package/dist/src/pi.js +22 -1
- package/dist/src/protocol.js +5 -2
- package/dist/src/rate-limit-retries.js +62 -0
- package/dist/src/recurring-work.js +1 -0
- package/dist/src/repository-snapshots.js +5 -2
- package/dist/src/run-grants.js +62 -0
- package/dist/src/service.js +141 -6
- package/dist/src/station-checkpoints.js +139 -0
- package/dist/src/station-config.js +8 -1
- package/dist/src/station-files.js +20 -1
- package/dist/src/station-task-verification.js +6 -3
- package/dist/src/station-work.js +315 -22
- package/dist/src/store.js +19 -0
- package/dist/src/supervisor.js +177 -5
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-critic.js +115 -0
- package/dist/src/task-driver.js +46 -2
- package/dist/src/task-policy.js +44 -12
- package/dist/src/task-verification.js +42 -9
- package/dist/src/task-work.js +47 -0
- package/dist/src/tool-identity.js +69 -0
- package/dist/src/usage.js +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-outcome.ts +169 -19
- package/src/acp.ts +53 -7
- package/src/agent-profiles.ts +48 -0
- package/src/agentpod-authority.ts +234 -15
- package/src/agentpod-default-config.ts +16 -0
- package/src/agentpod-work-decision-protocol.ts +69 -8
- package/src/agentpod-work-decisions.ts +123 -5
- package/src/agentpod-work.ts +1455 -152
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +27 -2
- package/src/cli.ts +109 -3
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +273 -0
- package/src/execution-evidence.ts +31 -1
- package/src/host-bind.ts +156 -0
- package/src/host-breaker.ts +89 -0
- package/src/host-start.ts +51 -1
- package/src/host.ts +244 -6
- package/src/house-extension.ts +174 -56
- package/src/local-coding.ts +270 -18
- package/src/local-edit-helper.ts +26 -0
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi.ts +23 -1
- package/src/protocol.ts +6 -1
- package/src/rate-limit-retries.ts +69 -0
- package/src/recurring-work.ts +1 -0
- package/src/repository-snapshots.ts +5 -2
- package/src/run-grants.ts +107 -0
- package/src/service.ts +222 -6
- package/src/station-checkpoints.ts +183 -0
- package/src/station-config.ts +10 -1
- package/src/station-files.ts +20 -1
- package/src/station-task-verification.ts +6 -3
- package/src/station-work.ts +369 -20
- package/src/store.ts +18 -0
- package/src/supervisor.ts +203 -4
- package/src/task-budget.ts +48 -3
- package/src/task-critic.ts +171 -0
- package/src/task-driver.ts +49 -2
- package/src/task-policy.ts +65 -13
- package/src/task-verification.ts +53 -9
- package/src/task-work.ts +75 -0
- package/src/tool-identity.ts +110 -0
- 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
|
+
}
|
package/dist/src/children.js
CHANGED
|
@@ -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))`)
|
package/dist/src/cli-help.js
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.
|
|
@@ -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();
|