@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
package/dist/src/local-coding.js
CHANGED
|
@@ -1,22 +1,45 @@
|
|
|
1
|
+
import { BrokerRelay, COMMAND_NETWORK_PORT } from './command-network.js';
|
|
2
|
+
import { sandboxArgv, seatbeltArgv, UNSANDBOXED_REFUSAL, } from './command-sandbox.js';
|
|
1
3
|
import { spawn } from 'node:child_process';
|
|
2
4
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { realpathSync } from 'node:fs';
|
|
5
|
+
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
4
8
|
import { digest, object, string, ServiceError } from './protocol.js';
|
|
5
9
|
import { prepareLocalEdits, verifyLocalEdits, applyLocalEdits, } from './local-edits.js';
|
|
6
10
|
import { permissionPolicy, permissionDigest, matchesEditScope, assertPermissionBinding, } from './autonomy.js';
|
|
7
11
|
import { assertUndoSettled } from './local-undo.js';
|
|
8
12
|
import { processIdentity } from './supervisor.js';
|
|
13
|
+
import { applyEditsAsTools, killToolGroup, serviceToolIdentity, toolArgv, } from './tool-identity.js';
|
|
14
|
+
/** The proxy a broker-network command uses: only the relay on loopback, nothing direct. */
|
|
15
|
+
function proxyEnv(port) {
|
|
16
|
+
const url = `http://127.0.0.1:${port}`;
|
|
17
|
+
return {
|
|
18
|
+
HTTP_PROXY: url,
|
|
19
|
+
HTTPS_PROXY: url,
|
|
20
|
+
http_proxy: url,
|
|
21
|
+
https_proxy: url,
|
|
22
|
+
NO_PROXY: 'localhost,127.0.0.1',
|
|
23
|
+
no_proxy: 'localhost,127.0.0.1',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
9
26
|
export class LocalCoding {
|
|
10
27
|
store;
|
|
11
28
|
managed;
|
|
12
29
|
active;
|
|
30
|
+
tools;
|
|
31
|
+
sandbox;
|
|
13
32
|
jobs = new Map();
|
|
14
33
|
closed = false;
|
|
15
34
|
unregister;
|
|
16
|
-
constructor(store, managed, active)
|
|
35
|
+
constructor(store, managed, active, tools = serviceToolIdentity(),
|
|
36
|
+
/** Chosen by the service from the host and the operator's setting. */
|
|
37
|
+
sandbox = { kind: 'none', level: 'full-access', bwrap: null }) {
|
|
17
38
|
this.store = store;
|
|
18
39
|
this.managed = managed;
|
|
19
40
|
this.active = active;
|
|
41
|
+
this.tools = tools;
|
|
42
|
+
this.sandbox = sandbox;
|
|
20
43
|
this.unregister = managed.registerMcpController('local-coding-v1', {
|
|
21
44
|
owns: (op) => !!store.db.prepare('SELECT 1 FROM local_calls WHERE operation_id=?').get(op.id),
|
|
22
45
|
assert: (op) => this.guard(this.byOperation(op)),
|
|
@@ -39,6 +62,9 @@ export class LocalCoding {
|
|
|
39
62
|
/* outcome remains uncertain */
|
|
40
63
|
}
|
|
41
64
|
}
|
|
65
|
+
const pgid = c.result ? Number(JSON.parse(c.result).tool_pgid) : NaN;
|
|
66
|
+
if (Number.isSafeInteger(pgid))
|
|
67
|
+
killToolGroup(this.tools, pgid);
|
|
42
68
|
store.transaction(() => {
|
|
43
69
|
this.change(c, 'outcome_unknown', {
|
|
44
70
|
error: 'Controller restarted during execution. Inspect the recorded intent and workspace; this operation will not replay.',
|
|
@@ -122,10 +148,25 @@ export class LocalCoding {
|
|
|
122
148
|
const timeout = input.timeout_ms ?? 30000;
|
|
123
149
|
if (!Number.isInteger(timeout) || Number(timeout) < 100 || Number(timeout) > 120000)
|
|
124
150
|
throw new ServiceError('invalid_field', 'Command timeout must be 100–120000 ms');
|
|
151
|
+
if (this.sandbox.kind === 'bwrap' &&
|
|
152
|
+
this.sandbox.level !== 'full-access' &&
|
|
153
|
+
!this.sandbox.bwrap)
|
|
154
|
+
throw new ServiceError('sandbox_unavailable', 'This host has no usable bubblewrap, so it cannot run a sandboxed command; an operator can choose full access explicitly');
|
|
155
|
+
if (this.sandbox.kind === 'none' &&
|
|
156
|
+
this.sandbox.level !== 'full-access' &&
|
|
157
|
+
!this.sandbox.allowUnsandboxed)
|
|
158
|
+
throw new ServiceError('sandbox_unavailable', UNSANDBOXED_REFUSAL);
|
|
125
159
|
args = {
|
|
126
160
|
action: 'command',
|
|
127
161
|
command: string(input.command, 'command', 8192),
|
|
128
162
|
timeout_ms: Number(timeout),
|
|
163
|
+
// Bound into the approval and the operation digest: the sandbox is part of what is approved.
|
|
164
|
+
sandbox: {
|
|
165
|
+
kind: this.sandbox.kind,
|
|
166
|
+
level: this.sandbox.level,
|
|
167
|
+
bwrap: this.sandbox.bwrap?.version ?? null,
|
|
168
|
+
...(this.brokerNetwork() ? { network: 'broker' } : {}),
|
|
169
|
+
},
|
|
129
170
|
};
|
|
130
171
|
}
|
|
131
172
|
else
|
|
@@ -136,7 +177,7 @@ export class LocalCoding {
|
|
|
136
177
|
? args.edits
|
|
137
178
|
.map((e) => `--- ${e.path}\n${e.before ?? '(new file)'}\n+++ ${e.path}\n${e.after}`)
|
|
138
179
|
.join('\n\n')
|
|
139
|
-
: `Runs as
|
|
180
|
+
: `Runs as ${this.tools.mode === 'tools-user' ? `tools user ${this.tools.user} (uid ${this.tools.uid})` : 'your local user'}, ${this.sandboxText()}\nDirectory: ${root}\nTimeout: ${args.timeout_ms} ms\nCommand:\n${args.command}`;
|
|
140
181
|
const permission = this.managed.prepareWorkspaceInTransaction(session, epoch, attempt, toolCall, `Local ${kind}: ${root}`, reviewed, digest({ root, args }));
|
|
141
182
|
c.operation_id = permission.operation.id;
|
|
142
183
|
this.store.db
|
|
@@ -207,13 +248,17 @@ export class LocalCoding {
|
|
|
207
248
|
try {
|
|
208
249
|
verifyLocalEdits(c.root, args.edits);
|
|
209
250
|
entered = true;
|
|
210
|
-
|
|
251
|
+
const progress = (path) => {
|
|
211
252
|
applied.push(path);
|
|
212
253
|
this.store.transaction(() => this.change(c, 'running', { applied }));
|
|
213
|
-
}
|
|
254
|
+
};
|
|
255
|
+
if (this.tools.mode === 'tools-user')
|
|
256
|
+
applyEditsAsTools(this.tools, c.root, args.edits, progress);
|
|
257
|
+
else
|
|
258
|
+
applyLocalEdits(c.root, args.edits, progress);
|
|
214
259
|
verifyLocalEdits(c.root, args.edits.map((edit) => ({ ...edit, before: edit.after })));
|
|
215
260
|
this.store.transaction(() => {
|
|
216
|
-
const result = { applied, verified: true };
|
|
261
|
+
const result = { applied, verified: true, tool_uid: this.tools.uid };
|
|
217
262
|
this.change(c, 'succeeded', result);
|
|
218
263
|
this.managed.workspaceResultInTransaction(c.operation_id, result);
|
|
219
264
|
});
|
|
@@ -237,14 +282,99 @@ export class LocalCoding {
|
|
|
237
282
|
this.command(c, String(args.command), Number(args.timeout_ms));
|
|
238
283
|
return this.view(id);
|
|
239
284
|
}
|
|
285
|
+
sandboxText() {
|
|
286
|
+
const { kind, level } = this.sandbox;
|
|
287
|
+
if (level === 'full-access')
|
|
288
|
+
return 'unsandboxed: the operator chose full access. May access files and network beyond this workspace.';
|
|
289
|
+
if (kind === 'none')
|
|
290
|
+
return 'unsandboxed: this host has no usable command sandbox, so the command may access files and network beyond this workspace.';
|
|
291
|
+
const name = kind === 'seatbelt' ? 'Seatbelt' : 'bubblewrap';
|
|
292
|
+
const net = this.brokerNetwork()
|
|
293
|
+
? 'network: through the broker allowlist (hosts set by the stk policy), nothing direct.'
|
|
294
|
+
: 'has no network.';
|
|
295
|
+
return level === 'read-only'
|
|
296
|
+
? `in a ${name} sandbox (read-only): it can read this workspace but write nothing, and ${net}`
|
|
297
|
+
: `in a ${name} sandbox (workspace-write): it can write only inside this workspace, and ${net}`;
|
|
298
|
+
}
|
|
299
|
+
/** Broker network applies only to a sandboxed command: unsandboxed ones are not confined anyway. */
|
|
300
|
+
brokerNetwork() {
|
|
301
|
+
return this.sandbox.network &&
|
|
302
|
+
this.sandbox.kind !== 'none' &&
|
|
303
|
+
this.sandbox.level !== 'full-access'
|
|
304
|
+
? this.sandbox.network
|
|
305
|
+
: null;
|
|
306
|
+
}
|
|
307
|
+
/** The process a command runs as. Under the tools user, the host-side shell reports its own
|
|
308
|
+
* process group (the one a stop kills) before exec-ing bubblewrap, whose PID namespace would
|
|
309
|
+
* otherwise report a number that means nothing on the host. */
|
|
310
|
+
commandArgv(root, script, tmp, relay) {
|
|
311
|
+
const sandboxed = this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access';
|
|
312
|
+
const inner = (s) => !sandboxed
|
|
313
|
+
? ['/bin/sh', '-c', s]
|
|
314
|
+
: this.sandbox.kind === 'seatbelt'
|
|
315
|
+
? seatbeltArgv(this.sandbox.level, root, tmp ?? '', ['/bin/sh', '-c', s], this.sandbox.seatbelt ?? null, { network: false, ...(relay?.port ? { brokerPort: relay.port } : {}) })
|
|
316
|
+
: sandboxArgv(this.sandbox.level, root, ['/bin/sh', '-c', s], this.sandbox.bwrap, {
|
|
317
|
+
network: false,
|
|
318
|
+
...(relay?.dir ? { broker: { dir: relay.dir, node: process.execPath } } : {}),
|
|
319
|
+
});
|
|
320
|
+
if (this.tools.mode !== 'tools-user')
|
|
321
|
+
return inner(script);
|
|
322
|
+
const marker = 'umask 0002\nprintf \'salaam-tool-pgid:%s\\n\' "$$"\n';
|
|
323
|
+
return sandboxed
|
|
324
|
+
? toolArgv(this.tools, [
|
|
325
|
+
'/usr/bin/setsid',
|
|
326
|
+
'-w',
|
|
327
|
+
'/bin/sh',
|
|
328
|
+
'-c',
|
|
329
|
+
marker + 'exec "$@"',
|
|
330
|
+
'sh',
|
|
331
|
+
...inner(script),
|
|
332
|
+
])
|
|
333
|
+
: toolArgv(this.tools, ['/usr/bin/setsid', '-w', '/bin/sh', '-c', marker + script]);
|
|
334
|
+
}
|
|
240
335
|
command(c, command, timeout) {
|
|
336
|
+
const broker = this.brokerNetwork();
|
|
337
|
+
// The relay is Salaam's, outside the sandbox: it forwards to stk and counts what was reached.
|
|
338
|
+
const relayStarting = broker
|
|
339
|
+
? BrokerRelay.start(broker.socket, this.sandbox.kind === 'seatbelt' ? 'tcp' : 'unix')
|
|
340
|
+
: Promise.resolve(null);
|
|
341
|
+
// A stop that arrives while the relay starts still cancels the command once it is spawned.
|
|
342
|
+
let early = false;
|
|
343
|
+
let stop = () => {
|
|
344
|
+
early = true;
|
|
345
|
+
};
|
|
346
|
+
// Seatbelt has no tmpfs: each command gets its own private tmp, removed when it ends.
|
|
347
|
+
const tmp = this.sandbox.kind === 'seatbelt' && this.sandbox.level !== 'full-access'
|
|
348
|
+
? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-cmd-')))
|
|
349
|
+
: undefined;
|
|
350
|
+
const set = (s) => {
|
|
351
|
+
stop = s;
|
|
352
|
+
if (early)
|
|
353
|
+
s();
|
|
354
|
+
};
|
|
355
|
+
const done = relayStarting.then((relay) => this.runCommand(c, command, timeout, tmp, relay, set), () => this.runCommand(c, command, timeout, tmp, null, set, !!broker));
|
|
356
|
+
this.jobs.set(c.id, { stop: () => stop(), done });
|
|
357
|
+
if (tmp)
|
|
358
|
+
void done.finally(() => rmSync(tmp, { recursive: true, force: true }));
|
|
359
|
+
}
|
|
360
|
+
runCommand(c, command, timeout, tmp, relay, setStop, relayFailed = false) {
|
|
241
361
|
let stop = () => { };
|
|
242
|
-
|
|
362
|
+
return new Promise((resolve) => {
|
|
243
363
|
let output = '', bytes = 0, reason = null, finished = false;
|
|
244
364
|
// Deliberately do not inherit the daemon's provider credentials or service tokens.
|
|
245
365
|
// Hold the shell before any user instruction until its identity is durably owned.
|
|
366
|
+
const tools = this.tools.mode === 'tools-user';
|
|
246
367
|
const script = 'IFS= read -r salaam_start_gate || exit 125\nexec 0</dev/null\n' + command;
|
|
247
|
-
|
|
368
|
+
// As the tools user, setsid makes the shell its own group leader so it can be killed by group.
|
|
369
|
+
const argv = this.commandArgv(c.root, script, tmp, relay);
|
|
370
|
+
let toolGroup = null, marker = tools ? '' : null, spawned = false;
|
|
371
|
+
// Release the gate only once the process is owned and, as the tools user, its group is
|
|
372
|
+
// known: otherwise a kill before the marker could miss a command already running.
|
|
373
|
+
const release = () => {
|
|
374
|
+
if (spawned && (!tools || toolGroup) && !reason && !finished)
|
|
375
|
+
child.stdin.end('start\n');
|
|
376
|
+
};
|
|
377
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
248
378
|
cwd: c.root,
|
|
249
379
|
detached: true,
|
|
250
380
|
env: {
|
|
@@ -252,11 +382,16 @@ export class LocalCoding {
|
|
|
252
382
|
HOME: process.env.HOME ?? c.root,
|
|
253
383
|
LANG: 'en_US.UTF-8',
|
|
254
384
|
TMPDIR: '/tmp',
|
|
385
|
+
...(relay ? proxyEnv(relay.port ?? COMMAND_NETWORK_PORT) : {}),
|
|
255
386
|
},
|
|
256
387
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
257
388
|
});
|
|
258
389
|
let reap;
|
|
259
390
|
const kill = () => {
|
|
391
|
+
if (toolGroup)
|
|
392
|
+
killToolGroup(this.tools, toolGroup);
|
|
393
|
+
if (tools)
|
|
394
|
+
child.stdin.destroy();
|
|
260
395
|
if (child.pid)
|
|
261
396
|
try {
|
|
262
397
|
process.kill(-child.pid, 'SIGKILL');
|
|
@@ -266,17 +401,26 @@ export class LocalCoding {
|
|
|
266
401
|
}
|
|
267
402
|
if (!finished && !reap)
|
|
268
403
|
reap = setTimeout(() => {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
404
|
+
if (toolGroup)
|
|
405
|
+
killToolGroup(this.tools, toolGroup);
|
|
406
|
+
// A busy event loop runs timers before pending I/O: let an already delivered close
|
|
407
|
+
// settle the outcome before declaring it unknown.
|
|
408
|
+
setImmediate(() => {
|
|
409
|
+
if (finished)
|
|
410
|
+
return;
|
|
411
|
+
reason = 'outcome_unknown';
|
|
412
|
+
child.stdout.destroy();
|
|
413
|
+
child.stderr.destroy();
|
|
414
|
+
child.unref();
|
|
415
|
+
finish(null);
|
|
416
|
+
});
|
|
274
417
|
}, 1500);
|
|
275
418
|
};
|
|
276
419
|
stop = () => {
|
|
277
420
|
reason ??= 'cancelled';
|
|
278
421
|
kill();
|
|
279
422
|
};
|
|
423
|
+
setStop(stop);
|
|
280
424
|
const deadline = setTimeout(() => {
|
|
281
425
|
reason = 'timed_out';
|
|
282
426
|
kill();
|
|
@@ -302,7 +446,38 @@ export class LocalCoding {
|
|
|
302
446
|
reason ??= 'outcome_unknown';
|
|
303
447
|
kill();
|
|
304
448
|
});
|
|
305
|
-
child.stdout.on('data',
|
|
449
|
+
child.stdout.on('data', (chunk) => {
|
|
450
|
+
if (marker === null)
|
|
451
|
+
return collect(chunk);
|
|
452
|
+
marker += chunk.toString('latin1');
|
|
453
|
+
const end = marker.indexOf('\n');
|
|
454
|
+
if (end < 0) {
|
|
455
|
+
if (marker.length > 64) {
|
|
456
|
+
reason = 'outcome_unknown';
|
|
457
|
+
kill();
|
|
458
|
+
}
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const found = /^salaam-tool-pgid:(\d+)$/.exec(marker.slice(0, end));
|
|
462
|
+
const rest = Buffer.from(marker.slice(end + 1), 'latin1');
|
|
463
|
+
marker = null;
|
|
464
|
+
if (!found) {
|
|
465
|
+
reason = 'outcome_unknown';
|
|
466
|
+
kill();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
toolGroup = Number(found[1]);
|
|
470
|
+
try {
|
|
471
|
+
this.store.transaction(() => this.change(c, 'running', { tool_pgid: toolGroup }));
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
reason = 'outcome_unknown';
|
|
475
|
+
kill();
|
|
476
|
+
}
|
|
477
|
+
if (rest.length)
|
|
478
|
+
collect(rest);
|
|
479
|
+
release();
|
|
480
|
+
});
|
|
306
481
|
child.stderr.on('data', collect);
|
|
307
482
|
child.once('spawn', () => {
|
|
308
483
|
try {
|
|
@@ -319,7 +494,8 @@ export class LocalCoding {
|
|
|
319
494
|
stop();
|
|
320
495
|
return;
|
|
321
496
|
}
|
|
322
|
-
|
|
497
|
+
spawned = true;
|
|
498
|
+
release();
|
|
323
499
|
}
|
|
324
500
|
catch {
|
|
325
501
|
reason = 'outcome_unknown';
|
|
@@ -337,12 +513,33 @@ export class LocalCoding {
|
|
|
337
513
|
kill();
|
|
338
514
|
this.jobs.delete(c.id);
|
|
339
515
|
const state = reason ?? (error ? 'failed' : code === 0 ? 'succeeded' : 'failed');
|
|
516
|
+
const network = relay
|
|
517
|
+
? relay.summary()
|
|
518
|
+
: relayFailed
|
|
519
|
+
? { broker: 'unavailable', connections: [] }
|
|
520
|
+
: null;
|
|
521
|
+
void relay?.close();
|
|
522
|
+
// What the broker refused, in its own words, so the reason reaches the card.
|
|
523
|
+
const refusals = (network?.connections ?? [])
|
|
524
|
+
.filter((n) => n.refused && n.reason)
|
|
525
|
+
.map((n) => `[network] refused ${n.target}: ${n.reason}`)
|
|
526
|
+
.join('\n');
|
|
340
527
|
const result = {
|
|
341
528
|
exit_code: code,
|
|
342
|
-
output
|
|
529
|
+
output: refusals
|
|
530
|
+
? output + (output.endsWith('\n') || !output ? '' : '\n') + refusals + '\n'
|
|
531
|
+
: output,
|
|
343
532
|
state,
|
|
344
533
|
error: error ? 'Command could not start' : null,
|
|
345
|
-
isolation: '
|
|
534
|
+
isolation: (tools ? 'tools-user-' : 'local-user-') +
|
|
535
|
+
(this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access'
|
|
536
|
+
? this.sandbox.kind + '-' + this.sandbox.level
|
|
537
|
+
: 'no-os-sandbox'),
|
|
538
|
+
sandbox: this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access'
|
|
539
|
+
? this.sandbox.kind
|
|
540
|
+
: 'none',
|
|
541
|
+
tool_uid: this.tools.uid,
|
|
542
|
+
...(network ? { network } : {}),
|
|
346
543
|
};
|
|
347
544
|
this.store.transaction(() => {
|
|
348
545
|
this.change(c, state, result);
|
|
@@ -356,7 +553,6 @@ export class LocalCoding {
|
|
|
356
553
|
child.once('error', (e) => finish(null, e));
|
|
357
554
|
child.once('close', (code) => finish(code));
|
|
358
555
|
});
|
|
359
|
-
this.jobs.set(c.id, { stop: () => stop(), done });
|
|
360
556
|
}
|
|
361
557
|
async close() {
|
|
362
558
|
this.closed = true;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Runs as the tools user: applies reviewed local edits that the service has recorded and verified.
|
|
2
|
+
import { closeSync, constants, fsyncSync, openSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { applyLocalEdits, localPath, verifyLocalEdits } from './local-edits.js';
|
|
5
|
+
const root = process.argv[2];
|
|
6
|
+
if (!root)
|
|
7
|
+
throw new Error('usage: local-edit-helper <root>');
|
|
8
|
+
const edits = JSON.parse(readFileSync(0, 'utf8'));
|
|
9
|
+
for (const { remove, ...edit } of edits) {
|
|
10
|
+
if (remove) {
|
|
11
|
+
verifyLocalEdits(root, [edit]);
|
|
12
|
+
const path = localPath(root, edit.path);
|
|
13
|
+
rmSync(path);
|
|
14
|
+
const directory = openSync(dirname(path), constants.O_RDONLY);
|
|
15
|
+
try {
|
|
16
|
+
fsyncSync(directory);
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
closeSync(directory);
|
|
20
|
+
}
|
|
21
|
+
process.stdout.write('applied ' + JSON.stringify(edit.path) + '\n');
|
|
22
|
+
}
|
|
23
|
+
else
|
|
24
|
+
applyLocalEdits(root, [edit], (path) => process.stdout.write('applied ' + JSON.stringify(path) + '\n'));
|
|
25
|
+
}
|
package/dist/src/local-undo.js
CHANGED
|
@@ -2,6 +2,7 @@ import { dirname } from 'node:path';
|
|
|
2
2
|
import { rmSync, openSync, closeSync, fsyncSync, constants } from 'node:fs';
|
|
3
3
|
import { assertPermissionBinding } from './autonomy.js';
|
|
4
4
|
import { digest, ServiceError } from './protocol.js';
|
|
5
|
+
import { applyEditsAsTools } from './tool-identity.js';
|
|
5
6
|
import { applyLocalEdits, localPath, verifyLocalEdits } from './local-edits.js';
|
|
6
7
|
/** Unfinished inverse edits fence every local session sharing the root, including after a crash. */
|
|
7
8
|
export function assertUndoSettled(store, root) {
|
|
@@ -24,7 +25,7 @@ export function reviewUndo(store, id) {
|
|
|
24
25
|
verifyLocalEdits(session.cwd, edits.map((e) => ({ ...e, before: e.after })));
|
|
25
26
|
return { call_id: row.id, digest: digest({ root: session.cwd, call: row.id, edits }), edits };
|
|
26
27
|
}
|
|
27
|
-
export function applyUndo(store, id, request, reviewed, controller, idle) {
|
|
28
|
+
export function applyUndo(store, id, request, reviewed, controller, idle, tools) {
|
|
28
29
|
const input = { kind: 'local-undo', id, reviewed };
|
|
29
30
|
const saved = store.cachedRequest('local-operator', request, input);
|
|
30
31
|
if (saved)
|
|
@@ -47,23 +48,31 @@ export function applyUndo(store, id, request, reviewed, controller, idle) {
|
|
|
47
48
|
return { state: 'outcome_unknown', ...review };
|
|
48
49
|
});
|
|
49
50
|
// Intent is durable before touching files. A lost process never repeats this mutation.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
51
|
+
if (tools?.mode === 'tools-user')
|
|
52
|
+
applyEditsAsTools(tools, root, work.edits.map((edit) => ({
|
|
53
|
+
...edit,
|
|
54
|
+
before: edit.after,
|
|
55
|
+
after: edit.before ?? '',
|
|
56
|
+
...(edit.before === null ? { remove: true } : {}),
|
|
57
|
+
})), () => { });
|
|
58
|
+
else
|
|
59
|
+
for (const edit of work.edits) {
|
|
60
|
+
const inverse = { ...edit, before: edit.after, after: edit.before ?? '' };
|
|
61
|
+
if (edit.before === null) {
|
|
62
|
+
verifyLocalEdits(root, [inverse]);
|
|
63
|
+
const path = localPath(root, edit.path);
|
|
64
|
+
rmSync(path);
|
|
65
|
+
const directory = openSync(dirname(path), constants.O_RDONLY);
|
|
66
|
+
try {
|
|
67
|
+
fsyncSync(directory);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
closeSync(directory);
|
|
71
|
+
}
|
|
62
72
|
}
|
|
73
|
+
else
|
|
74
|
+
applyLocalEdits(root, [inverse], () => { });
|
|
63
75
|
}
|
|
64
|
-
else
|
|
65
|
-
applyLocalEdits(root, [inverse], () => { });
|
|
66
|
-
}
|
|
67
76
|
verifyLocalEdits(root, work.edits);
|
|
68
77
|
return store.transaction(() => {
|
|
69
78
|
const result = { state: 'completed', call_id: work.call_id };
|
package/dist/src/managed.js
CHANGED
|
@@ -383,7 +383,7 @@ export class ManagedWork {
|
|
|
383
383
|
this.fresh(op);
|
|
384
384
|
this.store.db
|
|
385
385
|
.prepare("UPDATE approvals SET state='decided',decision=?,decided_by=?,revision=revision+1 WHERE id=?")
|
|
386
|
-
.run(decision, scoped ? 'scoped-policy' : 'local-operator', id);
|
|
386
|
+
.run(decision, scoped ? (scoped.decidedBy ?? 'scoped-policy') : 'local-operator', id);
|
|
387
387
|
if (decision === 'deny')
|
|
388
388
|
this.transition(op, 'cancelled_before_dispatch', 'Denied by operator');
|
|
389
389
|
this.store.event(approval.session_id, 'approval.decided', { approval_id: id, operation_id: op.id, decision }, request);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { declarationDigest, operatorMcpPath, readOperatorMcpFile, validateDeclaration, writeOperatorMcpFile, } from './operator-mcp.js';
|
|
2
|
+
export const MCP_HELP = `salaam mcp: operator-declared MCP servers
|
|
3
|
+
|
|
4
|
+
salaam mcp add <name> [--env K='\${VAR}']... -- <command> [args...]
|
|
5
|
+
salaam mcp add <name> --url <https-url> [--header K='Bearer \${VAR}']...
|
|
6
|
+
salaam mcp list
|
|
7
|
+
salaam mcp rm <name>
|
|
8
|
+
|
|
9
|
+
Options: --config <path> (default $XDG_CONFIG_HOME/salaam/mcp.json)
|
|
10
|
+
Values in --env and --header must reference environment variables as \${VAR};
|
|
11
|
+
inline secrets are refused. Changes apply at the next salaam service start.`;
|
|
12
|
+
function pairs(values, flag) {
|
|
13
|
+
if (!values.length)
|
|
14
|
+
return undefined;
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const value of values) {
|
|
17
|
+
const i = value.indexOf('=');
|
|
18
|
+
if (i < 1)
|
|
19
|
+
throw new Error(`${flag} expects K=V`);
|
|
20
|
+
const key = value.slice(0, i);
|
|
21
|
+
if (Object.hasOwn(out, key))
|
|
22
|
+
throw new Error(`${flag} ${key} given twice`);
|
|
23
|
+
out[key] = value.slice(i + 1);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
/** `salaam mcp ...`; argv excludes the leading "mcp". Returns text for stdout. */
|
|
28
|
+
export function mcpCli(argv, env = process.env) {
|
|
29
|
+
const dash = argv.indexOf('--');
|
|
30
|
+
const head = dash < 0 ? [...argv] : argv.slice(0, dash);
|
|
31
|
+
const command = dash < 0 ? [] : argv.slice(dash + 1);
|
|
32
|
+
const take = (flag, many = false) => {
|
|
33
|
+
const found = [];
|
|
34
|
+
for (let i = head.indexOf(flag); i >= 0; i = head.indexOf(flag)) {
|
|
35
|
+
const value = head[i + 1];
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
throw new Error(`Missing value for ${flag}`);
|
|
38
|
+
found.push(value);
|
|
39
|
+
head.splice(i, 2);
|
|
40
|
+
if (!many)
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
return found;
|
|
44
|
+
};
|
|
45
|
+
const path = take('--config')[0] ?? operatorMcpPath(env);
|
|
46
|
+
const url = take('--url')[0];
|
|
47
|
+
const headers = pairs(take('--header', true), '--header');
|
|
48
|
+
const envs = pairs(take('--env', true), '--env');
|
|
49
|
+
const [verb, name, ...rest] = head;
|
|
50
|
+
if (rest.length || head.some((x) => x.startsWith('--')))
|
|
51
|
+
throw new Error('Unexpected arguments; run salaam mcp --help');
|
|
52
|
+
const file = readOperatorMcpFile(path);
|
|
53
|
+
if (verb === 'list') {
|
|
54
|
+
if (dash >= 0 || name)
|
|
55
|
+
throw new Error('salaam mcp list takes no arguments');
|
|
56
|
+
return JSON.stringify({
|
|
57
|
+
config: path,
|
|
58
|
+
servers: file.servers.map((s) => ({ ...s, declaration_digest: declarationDigest(s) })),
|
|
59
|
+
}, null, 2);
|
|
60
|
+
}
|
|
61
|
+
if (!name)
|
|
62
|
+
throw new Error('A server name is required');
|
|
63
|
+
if (verb === 'rm') {
|
|
64
|
+
if (dash >= 0 || url || headers || envs)
|
|
65
|
+
throw new Error('salaam mcp rm takes only a name');
|
|
66
|
+
const servers = file.servers.filter((s) => s.name !== name);
|
|
67
|
+
if (servers.length === file.servers.length)
|
|
68
|
+
throw new Error(`No declared server "${name}"`);
|
|
69
|
+
writeOperatorMcpFile(path, { version: 1, servers });
|
|
70
|
+
return JSON.stringify({ removed: name, config: path });
|
|
71
|
+
}
|
|
72
|
+
if (verb === 'add') {
|
|
73
|
+
if (file.servers.some((s) => s.name === name))
|
|
74
|
+
throw new Error(`Server "${name}" is already declared; rm it first`);
|
|
75
|
+
let declaration;
|
|
76
|
+
if (url) {
|
|
77
|
+
if (command.length || envs)
|
|
78
|
+
throw new Error('Use either --url or -- <command>, not both');
|
|
79
|
+
declaration = validateDeclaration({
|
|
80
|
+
name,
|
|
81
|
+
transport: 'http',
|
|
82
|
+
url,
|
|
83
|
+
...(headers ? { headers } : {}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
if (!command.length)
|
|
88
|
+
throw new Error('Supply -- <command> [args...] or --url <url>');
|
|
89
|
+
if (headers)
|
|
90
|
+
throw new Error('--header applies to --url servers');
|
|
91
|
+
declaration = validateDeclaration({
|
|
92
|
+
name,
|
|
93
|
+
transport: 'stdio',
|
|
94
|
+
command: command[0],
|
|
95
|
+
args: command.slice(1),
|
|
96
|
+
...(envs ? { env: envs } : {}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
writeOperatorMcpFile(path, { version: 1, servers: [...file.servers, declaration] });
|
|
100
|
+
return JSON.stringify({
|
|
101
|
+
added: name,
|
|
102
|
+
config: path,
|
|
103
|
+
declaration_digest: declarationDigest(declaration),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
throw new Error('Use salaam mcp add, list or rm');
|
|
107
|
+
}
|