@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,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 {
|
|
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 =
|
|
1557
|
-
|
|
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
|
);
|