@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/src/local-coding.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
|
+
import { BrokerRelay, COMMAND_NETWORK_PORT } from './command-network.js';
|
|
2
|
+
import {
|
|
3
|
+
sandboxArgv,
|
|
4
|
+
seatbeltArgv,
|
|
5
|
+
UNSANDBOXED_REFUSAL,
|
|
6
|
+
type Bwrap,
|
|
7
|
+
type SandboxKind,
|
|
8
|
+
type SandboxLevel,
|
|
9
|
+
type Seatbelt,
|
|
10
|
+
} from './command-sandbox.js';
|
|
1
11
|
import { spawn } from 'node:child_process';
|
|
2
12
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { realpathSync } from 'node:fs';
|
|
13
|
+
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
4
16
|
import type { Store } from './store.js';
|
|
5
17
|
import type { ManagedWork } from './managed.js';
|
|
6
18
|
import { digest, object, string, ServiceError, type Operation } from './protocol.js';
|
|
@@ -18,6 +30,13 @@ import {
|
|
|
18
30
|
} from './autonomy.js';
|
|
19
31
|
import { assertUndoSettled } from './local-undo.js';
|
|
20
32
|
import { processIdentity } from './supervisor.js';
|
|
33
|
+
import {
|
|
34
|
+
applyEditsAsTools,
|
|
35
|
+
killToolGroup,
|
|
36
|
+
serviceToolIdentity,
|
|
37
|
+
toolArgv,
|
|
38
|
+
type ToolIdentity,
|
|
39
|
+
} from './tool-identity.js';
|
|
21
40
|
|
|
22
41
|
interface LocalCall {
|
|
23
42
|
id: string;
|
|
@@ -34,6 +53,35 @@ interface LocalCall {
|
|
|
34
53
|
pid: number | null;
|
|
35
54
|
identity: string | null;
|
|
36
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Item 10: how this host runs local commands. `bwrap` (Linux) or `seatbelt` (macOS): under the OS
|
|
58
|
+
* sandbox at `level` (full-access only by explicit operator setting). `none`: a host with no usable
|
|
59
|
+
* sandbox, which refuses local commands unless the operator allowed them unsandboxed
|
|
60
|
+
* (`allowUnsandboxed`); then it runs them and says so in every approval.
|
|
61
|
+
*/
|
|
62
|
+
export interface LocalSandbox {
|
|
63
|
+
kind: SandboxKind;
|
|
64
|
+
level: SandboxLevel;
|
|
65
|
+
bwrap: Bwrap | null;
|
|
66
|
+
seatbelt?: Seatbelt | null;
|
|
67
|
+
/** The host config's `unsandboxed_commands: "allow"` (operator ruling 2026-10-30, option A). */
|
|
68
|
+
allowUnsandboxed?: boolean;
|
|
69
|
+
/** The host config's `command_network: "broker"`: a sandboxed command reaches the network only
|
|
70
|
+
* through this stk local-commands socket (operator rulings 2026-10-30). */
|
|
71
|
+
network?: { socket: string };
|
|
72
|
+
}
|
|
73
|
+
/** The proxy a broker-network command uses: only the relay on loopback, nothing direct. */
|
|
74
|
+
function proxyEnv(port: number): Record<string, string> {
|
|
75
|
+
const url = `http://127.0.0.1:${port}`;
|
|
76
|
+
return {
|
|
77
|
+
HTTP_PROXY: url,
|
|
78
|
+
HTTPS_PROXY: url,
|
|
79
|
+
http_proxy: url,
|
|
80
|
+
https_proxy: url,
|
|
81
|
+
NO_PROXY: 'localhost,127.0.0.1',
|
|
82
|
+
no_proxy: 'localhost,127.0.0.1',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
37
85
|
export class LocalCoding {
|
|
38
86
|
private jobs = new Map<string, { stop: () => void; done: Promise<void> }>();
|
|
39
87
|
private closed = false;
|
|
@@ -42,6 +90,9 @@ export class LocalCoding {
|
|
|
42
90
|
readonly store: Store,
|
|
43
91
|
readonly managed: ManagedWork,
|
|
44
92
|
readonly active: (s: string, e: number, a: string) => void,
|
|
93
|
+
readonly tools: ToolIdentity = serviceToolIdentity(),
|
|
94
|
+
/** Chosen by the service from the host and the operator's setting. */
|
|
95
|
+
public sandbox: LocalSandbox = { kind: 'none', level: 'full-access', bwrap: null },
|
|
45
96
|
) {
|
|
46
97
|
this.unregister = managed.registerMcpController('local-coding-v1', {
|
|
47
98
|
owns: (op) => !!store.db.prepare('SELECT 1 FROM local_calls WHERE operation_id=?').get(op.id),
|
|
@@ -64,6 +115,8 @@ export class LocalCoding {
|
|
|
64
115
|
/* outcome remains uncertain */
|
|
65
116
|
}
|
|
66
117
|
}
|
|
118
|
+
const pgid = c.result ? Number(JSON.parse(c.result).tool_pgid) : NaN;
|
|
119
|
+
if (Number.isSafeInteger(pgid)) killToolGroup(this.tools, pgid);
|
|
67
120
|
store.transaction(() => {
|
|
68
121
|
this.change(c, 'outcome_unknown', {
|
|
69
122
|
error:
|
|
@@ -158,16 +211,43 @@ export class LocalCoding {
|
|
|
158
211
|
const kind = string(input.action, 'action');
|
|
159
212
|
let args:
|
|
160
213
|
| { action: 'edit'; edits: LocalEdit[] }
|
|
161
|
-
| {
|
|
214
|
+
| {
|
|
215
|
+
action: 'command';
|
|
216
|
+
command: string;
|
|
217
|
+
timeout_ms: number;
|
|
218
|
+
sandbox: { kind: string; level: string; bwrap: string | null; network?: 'broker' };
|
|
219
|
+
};
|
|
162
220
|
if (kind === 'edit') args = { action: 'edit', edits: prepareLocalEdits(root, input.edits) };
|
|
163
221
|
else if (kind === 'command') {
|
|
164
222
|
const timeout = input.timeout_ms ?? 30000;
|
|
165
223
|
if (!Number.isInteger(timeout) || Number(timeout) < 100 || Number(timeout) > 120000)
|
|
166
224
|
throw new ServiceError('invalid_field', 'Command timeout must be 100–120000 ms');
|
|
225
|
+
if (
|
|
226
|
+
this.sandbox.kind === 'bwrap' &&
|
|
227
|
+
this.sandbox.level !== 'full-access' &&
|
|
228
|
+
!this.sandbox.bwrap
|
|
229
|
+
)
|
|
230
|
+
throw new ServiceError(
|
|
231
|
+
'sandbox_unavailable',
|
|
232
|
+
'This host has no usable bubblewrap, so it cannot run a sandboxed command; an operator can choose full access explicitly',
|
|
233
|
+
);
|
|
234
|
+
if (
|
|
235
|
+
this.sandbox.kind === 'none' &&
|
|
236
|
+
this.sandbox.level !== 'full-access' &&
|
|
237
|
+
!this.sandbox.allowUnsandboxed
|
|
238
|
+
)
|
|
239
|
+
throw new ServiceError('sandbox_unavailable', UNSANDBOXED_REFUSAL);
|
|
167
240
|
args = {
|
|
168
241
|
action: 'command',
|
|
169
242
|
command: string(input.command, 'command', 8192),
|
|
170
243
|
timeout_ms: Number(timeout),
|
|
244
|
+
// Bound into the approval and the operation digest: the sandbox is part of what is approved.
|
|
245
|
+
sandbox: {
|
|
246
|
+
kind: this.sandbox.kind,
|
|
247
|
+
level: this.sandbox.level,
|
|
248
|
+
bwrap: this.sandbox.bwrap?.version ?? null,
|
|
249
|
+
...(this.brokerNetwork() ? { network: 'broker' as const } : {}),
|
|
250
|
+
},
|
|
171
251
|
};
|
|
172
252
|
} else throw new ServiceError('invalid_field', 'Choose edit or command');
|
|
173
253
|
c.args = JSON.stringify(args);
|
|
@@ -177,7 +257,7 @@ export class LocalCoding {
|
|
|
177
257
|
? args.edits
|
|
178
258
|
.map((e) => `--- ${e.path}\n${e.before ?? '(new file)'}\n+++ ${e.path}\n${e.after}`)
|
|
179
259
|
.join('\n\n')
|
|
180
|
-
: `Runs as
|
|
260
|
+
: `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}`;
|
|
181
261
|
const permission = this.managed.prepareWorkspaceInTransaction(
|
|
182
262
|
session,
|
|
183
263
|
epoch,
|
|
@@ -287,16 +367,19 @@ export class LocalCoding {
|
|
|
287
367
|
try {
|
|
288
368
|
verifyLocalEdits(c.root, args.edits as LocalEdit[]);
|
|
289
369
|
entered = true;
|
|
290
|
-
|
|
370
|
+
const progress = (path: string) => {
|
|
291
371
|
applied.push(path);
|
|
292
372
|
this.store.transaction(() => this.change(c, 'running', { applied }));
|
|
293
|
-
}
|
|
373
|
+
};
|
|
374
|
+
if (this.tools.mode === 'tools-user')
|
|
375
|
+
applyEditsAsTools(this.tools, c.root, args.edits as LocalEdit[], progress);
|
|
376
|
+
else applyLocalEdits(c.root, args.edits as LocalEdit[], progress);
|
|
294
377
|
verifyLocalEdits(
|
|
295
378
|
c.root,
|
|
296
379
|
(args.edits as LocalEdit[]).map((edit) => ({ ...edit, before: edit.after })),
|
|
297
380
|
);
|
|
298
381
|
this.store.transaction(() => {
|
|
299
|
-
const result = { applied, verified: true };
|
|
382
|
+
const result = { applied, verified: true, tool_uid: this.tools.uid };
|
|
300
383
|
this.change(c, 'succeeded', result);
|
|
301
384
|
this.managed.workspaceResultInTransaction(c.operation_id, result);
|
|
302
385
|
});
|
|
@@ -316,17 +399,125 @@ export class LocalCoding {
|
|
|
316
399
|
} else this.command(c, String(args.command), Number(args.timeout_ms));
|
|
317
400
|
return this.view(id);
|
|
318
401
|
}
|
|
402
|
+
private sandboxText(): string {
|
|
403
|
+
const { kind, level } = this.sandbox;
|
|
404
|
+
if (level === 'full-access')
|
|
405
|
+
return 'unsandboxed: the operator chose full access. May access files and network beyond this workspace.';
|
|
406
|
+
if (kind === 'none')
|
|
407
|
+
return 'unsandboxed: this host has no usable command sandbox, so the command may access files and network beyond this workspace.';
|
|
408
|
+
const name = kind === 'seatbelt' ? 'Seatbelt' : 'bubblewrap';
|
|
409
|
+
const net = this.brokerNetwork()
|
|
410
|
+
? 'network: through the broker allowlist (hosts set by the stk policy), nothing direct.'
|
|
411
|
+
: 'has no network.';
|
|
412
|
+
return level === 'read-only'
|
|
413
|
+
? `in a ${name} sandbox (read-only): it can read this workspace but write nothing, and ${net}`
|
|
414
|
+
: `in a ${name} sandbox (workspace-write): it can write only inside this workspace, and ${net}`;
|
|
415
|
+
}
|
|
416
|
+
/** Broker network applies only to a sandboxed command: unsandboxed ones are not confined anyway. */
|
|
417
|
+
private brokerNetwork(): { socket: string } | null {
|
|
418
|
+
return this.sandbox.network &&
|
|
419
|
+
this.sandbox.kind !== 'none' &&
|
|
420
|
+
this.sandbox.level !== 'full-access'
|
|
421
|
+
? this.sandbox.network
|
|
422
|
+
: null;
|
|
423
|
+
}
|
|
424
|
+
/** The process a command runs as. Under the tools user, the host-side shell reports its own
|
|
425
|
+
* process group (the one a stop kills) before exec-ing bubblewrap, whose PID namespace would
|
|
426
|
+
* otherwise report a number that means nothing on the host. */
|
|
427
|
+
private commandArgv(
|
|
428
|
+
root: string,
|
|
429
|
+
script: string,
|
|
430
|
+
tmp?: string,
|
|
431
|
+
relay?: BrokerRelay | null,
|
|
432
|
+
): string[] {
|
|
433
|
+
const sandboxed = this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access';
|
|
434
|
+
const inner = (s: string) =>
|
|
435
|
+
!sandboxed
|
|
436
|
+
? ['/bin/sh', '-c', s]
|
|
437
|
+
: this.sandbox.kind === 'seatbelt'
|
|
438
|
+
? seatbeltArgv(
|
|
439
|
+
this.sandbox.level,
|
|
440
|
+
root,
|
|
441
|
+
tmp ?? '',
|
|
442
|
+
['/bin/sh', '-c', s],
|
|
443
|
+
this.sandbox.seatbelt ?? null,
|
|
444
|
+
{ network: false, ...(relay?.port ? { brokerPort: relay.port } : {}) },
|
|
445
|
+
)
|
|
446
|
+
: sandboxArgv(this.sandbox.level, root, ['/bin/sh', '-c', s], this.sandbox.bwrap, {
|
|
447
|
+
network: false,
|
|
448
|
+
...(relay?.dir ? { broker: { dir: relay.dir, node: process.execPath } } : {}),
|
|
449
|
+
});
|
|
450
|
+
if (this.tools.mode !== 'tools-user') return inner(script);
|
|
451
|
+
const marker = 'umask 0002\nprintf \'salaam-tool-pgid:%s\\n\' "$$"\n';
|
|
452
|
+
return sandboxed
|
|
453
|
+
? toolArgv(this.tools, [
|
|
454
|
+
'/usr/bin/setsid',
|
|
455
|
+
'-w',
|
|
456
|
+
'/bin/sh',
|
|
457
|
+
'-c',
|
|
458
|
+
marker + 'exec "$@"',
|
|
459
|
+
'sh',
|
|
460
|
+
...inner(script),
|
|
461
|
+
])
|
|
462
|
+
: toolArgv(this.tools, ['/usr/bin/setsid', '-w', '/bin/sh', '-c', marker + script]);
|
|
463
|
+
}
|
|
319
464
|
private command(c: LocalCall, command: string, timeout: number): void {
|
|
465
|
+
const broker = this.brokerNetwork();
|
|
466
|
+
// The relay is Salaam's, outside the sandbox: it forwards to stk and counts what was reached.
|
|
467
|
+
const relayStarting = broker
|
|
468
|
+
? BrokerRelay.start(broker.socket, this.sandbox.kind === 'seatbelt' ? 'tcp' : 'unix')
|
|
469
|
+
: Promise.resolve(null);
|
|
470
|
+
// A stop that arrives while the relay starts still cancels the command once it is spawned.
|
|
471
|
+
let early = false;
|
|
472
|
+
let stop = () => {
|
|
473
|
+
early = true;
|
|
474
|
+
};
|
|
475
|
+
// Seatbelt has no tmpfs: each command gets its own private tmp, removed when it ends.
|
|
476
|
+
const tmp =
|
|
477
|
+
this.sandbox.kind === 'seatbelt' && this.sandbox.level !== 'full-access'
|
|
478
|
+
? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-cmd-')))
|
|
479
|
+
: undefined;
|
|
480
|
+
const set = (s: () => void) => {
|
|
481
|
+
stop = s;
|
|
482
|
+
if (early) s();
|
|
483
|
+
};
|
|
484
|
+
const done = relayStarting.then(
|
|
485
|
+
(relay) => this.runCommand(c, command, timeout, tmp, relay, set),
|
|
486
|
+
() => this.runCommand(c, command, timeout, tmp, null, set, !!broker),
|
|
487
|
+
);
|
|
488
|
+
this.jobs.set(c.id, { stop: () => stop(), done });
|
|
489
|
+
if (tmp) void done.finally(() => rmSync(tmp, { recursive: true, force: true }));
|
|
490
|
+
}
|
|
491
|
+
private runCommand(
|
|
492
|
+
c: LocalCall,
|
|
493
|
+
command: string,
|
|
494
|
+
timeout: number,
|
|
495
|
+
tmp: string | undefined,
|
|
496
|
+
relay: BrokerRelay | null,
|
|
497
|
+
setStop: (stop: () => void) => void,
|
|
498
|
+
relayFailed = false,
|
|
499
|
+
): Promise<void> {
|
|
320
500
|
let stop = () => {};
|
|
321
|
-
|
|
501
|
+
return new Promise<void>((resolve) => {
|
|
322
502
|
let output = '',
|
|
323
503
|
bytes = 0,
|
|
324
504
|
reason: string | null = null,
|
|
325
505
|
finished = false;
|
|
326
506
|
// Deliberately do not inherit the daemon's provider credentials or service tokens.
|
|
327
507
|
// Hold the shell before any user instruction until its identity is durably owned.
|
|
508
|
+
const tools = this.tools.mode === 'tools-user';
|
|
328
509
|
const script = 'IFS= read -r salaam_start_gate || exit 125\nexec 0</dev/null\n' + command;
|
|
329
|
-
|
|
510
|
+
// As the tools user, setsid makes the shell its own group leader so it can be killed by group.
|
|
511
|
+
const argv = this.commandArgv(c.root, script, tmp, relay);
|
|
512
|
+
let toolGroup: number | null = null,
|
|
513
|
+
marker = tools ? '' : null,
|
|
514
|
+
spawned = false;
|
|
515
|
+
// Release the gate only once the process is owned and, as the tools user, its group is
|
|
516
|
+
// known: otherwise a kill before the marker could miss a command already running.
|
|
517
|
+
const release = () => {
|
|
518
|
+
if (spawned && (!tools || toolGroup) && !reason && !finished) child.stdin.end('start\n');
|
|
519
|
+
};
|
|
520
|
+
const child = spawn(argv[0]!, argv.slice(1), {
|
|
330
521
|
cwd: c.root,
|
|
331
522
|
detached: true,
|
|
332
523
|
env: {
|
|
@@ -334,11 +525,14 @@ export class LocalCoding {
|
|
|
334
525
|
HOME: process.env.HOME ?? c.root,
|
|
335
526
|
LANG: 'en_US.UTF-8',
|
|
336
527
|
TMPDIR: '/tmp',
|
|
528
|
+
...(relay ? proxyEnv(relay.port ?? COMMAND_NETWORK_PORT) : {}),
|
|
337
529
|
},
|
|
338
530
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
339
531
|
});
|
|
340
532
|
let reap: NodeJS.Timeout | undefined;
|
|
341
533
|
const kill = () => {
|
|
534
|
+
if (toolGroup) killToolGroup(this.tools, toolGroup);
|
|
535
|
+
if (tools) child.stdin.destroy();
|
|
342
536
|
if (child.pid)
|
|
343
537
|
try {
|
|
344
538
|
process.kill(-child.pid, 'SIGKILL');
|
|
@@ -347,17 +541,24 @@ export class LocalCoding {
|
|
|
347
541
|
}
|
|
348
542
|
if (!finished && !reap)
|
|
349
543
|
reap = setTimeout(() => {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
544
|
+
if (toolGroup) killToolGroup(this.tools, toolGroup);
|
|
545
|
+
// A busy event loop runs timers before pending I/O: let an already delivered close
|
|
546
|
+
// settle the outcome before declaring it unknown.
|
|
547
|
+
setImmediate(() => {
|
|
548
|
+
if (finished) return;
|
|
549
|
+
reason = 'outcome_unknown';
|
|
550
|
+
child.stdout.destroy();
|
|
551
|
+
child.stderr.destroy();
|
|
552
|
+
child.unref();
|
|
553
|
+
finish(null);
|
|
554
|
+
});
|
|
355
555
|
}, 1500);
|
|
356
556
|
};
|
|
357
557
|
stop = () => {
|
|
358
558
|
reason ??= 'cancelled';
|
|
359
559
|
kill();
|
|
360
560
|
};
|
|
561
|
+
setStop(stop);
|
|
361
562
|
const deadline = setTimeout(() => {
|
|
362
563
|
reason = 'timed_out';
|
|
363
564
|
kill();
|
|
@@ -382,7 +583,35 @@ export class LocalCoding {
|
|
|
382
583
|
reason ??= 'outcome_unknown';
|
|
383
584
|
kill();
|
|
384
585
|
});
|
|
385
|
-
child.stdout.on('data',
|
|
586
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
587
|
+
if (marker === null) return collect(chunk);
|
|
588
|
+
marker += chunk.toString('latin1');
|
|
589
|
+
const end = marker.indexOf('\n');
|
|
590
|
+
if (end < 0) {
|
|
591
|
+
if (marker.length > 64) {
|
|
592
|
+
reason = 'outcome_unknown';
|
|
593
|
+
kill();
|
|
594
|
+
}
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const found = /^salaam-tool-pgid:(\d+)$/.exec(marker.slice(0, end));
|
|
598
|
+
const rest = Buffer.from(marker.slice(end + 1), 'latin1');
|
|
599
|
+
marker = null;
|
|
600
|
+
if (!found) {
|
|
601
|
+
reason = 'outcome_unknown';
|
|
602
|
+
kill();
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
toolGroup = Number(found[1]);
|
|
606
|
+
try {
|
|
607
|
+
this.store.transaction(() => this.change(c, 'running', { tool_pgid: toolGroup }));
|
|
608
|
+
} catch {
|
|
609
|
+
reason = 'outcome_unknown';
|
|
610
|
+
kill();
|
|
611
|
+
}
|
|
612
|
+
if (rest.length) collect(rest);
|
|
613
|
+
release();
|
|
614
|
+
});
|
|
386
615
|
child.stderr.on('data', collect);
|
|
387
616
|
child.once('spawn', () => {
|
|
388
617
|
try {
|
|
@@ -399,7 +628,8 @@ export class LocalCoding {
|
|
|
399
628
|
stop();
|
|
400
629
|
return;
|
|
401
630
|
}
|
|
402
|
-
|
|
631
|
+
spawned = true;
|
|
632
|
+
release();
|
|
403
633
|
} catch {
|
|
404
634
|
reason = 'outcome_unknown';
|
|
405
635
|
kill();
|
|
@@ -414,12 +644,35 @@ export class LocalCoding {
|
|
|
414
644
|
kill();
|
|
415
645
|
this.jobs.delete(c.id);
|
|
416
646
|
const state = reason ?? (error ? 'failed' : code === 0 ? 'succeeded' : 'failed');
|
|
647
|
+
const network = relay
|
|
648
|
+
? relay.summary()
|
|
649
|
+
: relayFailed
|
|
650
|
+
? { broker: 'unavailable' as const, connections: [] }
|
|
651
|
+
: null;
|
|
652
|
+
void relay?.close();
|
|
653
|
+
// What the broker refused, in its own words, so the reason reaches the card.
|
|
654
|
+
const refusals = (network?.connections ?? [])
|
|
655
|
+
.filter((n) => n.refused && n.reason)
|
|
656
|
+
.map((n) => `[network] refused ${n.target}: ${n.reason}`)
|
|
657
|
+
.join('\n');
|
|
417
658
|
const result = {
|
|
418
659
|
exit_code: code,
|
|
419
|
-
output
|
|
660
|
+
output: refusals
|
|
661
|
+
? output + (output.endsWith('\n') || !output ? '' : '\n') + refusals + '\n'
|
|
662
|
+
: output,
|
|
420
663
|
state,
|
|
421
664
|
error: error ? 'Command could not start' : null,
|
|
422
|
-
isolation:
|
|
665
|
+
isolation:
|
|
666
|
+
(tools ? 'tools-user-' : 'local-user-') +
|
|
667
|
+
(this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access'
|
|
668
|
+
? this.sandbox.kind + '-' + this.sandbox.level
|
|
669
|
+
: 'no-os-sandbox'),
|
|
670
|
+
sandbox:
|
|
671
|
+
this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access'
|
|
672
|
+
? this.sandbox.kind
|
|
673
|
+
: 'none',
|
|
674
|
+
tool_uid: this.tools.uid,
|
|
675
|
+
...(network ? { network } : {}),
|
|
423
676
|
};
|
|
424
677
|
this.store.transaction(() => {
|
|
425
678
|
this.change(c, state, result);
|
|
@@ -437,7 +690,6 @@ export class LocalCoding {
|
|
|
437
690
|
child.once('error', (e) => finish(null, e));
|
|
438
691
|
child.once('close', (code) => finish(code));
|
|
439
692
|
});
|
|
440
|
-
this.jobs.set(c.id, { stop: () => stop(), done });
|
|
441
693
|
}
|
|
442
694
|
async close(): Promise<void> {
|
|
443
695
|
this.closed = true;
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
import type { ToolEdit } from './tool-identity.js';
|
|
6
|
+
|
|
7
|
+
const root = process.argv[2];
|
|
8
|
+
if (!root) throw new Error('usage: local-edit-helper <root>');
|
|
9
|
+
const edits = JSON.parse(readFileSync(0, 'utf8')) as ToolEdit[];
|
|
10
|
+
for (const { remove, ...edit } of edits) {
|
|
11
|
+
if (remove) {
|
|
12
|
+
verifyLocalEdits(root, [edit]);
|
|
13
|
+
const path = localPath(root, edit.path);
|
|
14
|
+
rmSync(path);
|
|
15
|
+
const directory = openSync(dirname(path), constants.O_RDONLY);
|
|
16
|
+
try {
|
|
17
|
+
fsyncSync(directory);
|
|
18
|
+
} finally {
|
|
19
|
+
closeSync(directory);
|
|
20
|
+
}
|
|
21
|
+
process.stdout.write('applied ' + JSON.stringify(edit.path) + '\n');
|
|
22
|
+
} else
|
|
23
|
+
applyLocalEdits(root, [edit], (path) =>
|
|
24
|
+
process.stdout.write('applied ' + JSON.stringify(path) + '\n'),
|
|
25
|
+
);
|
|
26
|
+
}
|
package/src/local-undo.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { rmSync, openSync, closeSync, fsyncSync, constants } from 'node:fs';
|
|
|
3
3
|
import { assertPermissionBinding } from './autonomy.js';
|
|
4
4
|
import type { Store } from './store.js';
|
|
5
5
|
import { digest, ServiceError, type Controller } from './protocol.js';
|
|
6
|
+
import { applyEditsAsTools, type ToolIdentity } from './tool-identity.js';
|
|
6
7
|
import { applyLocalEdits, localPath, verifyLocalEdits, type LocalEdit } from './local-edits.js';
|
|
7
8
|
|
|
8
9
|
export interface UndoReview {
|
|
@@ -53,6 +54,7 @@ export function applyUndo(
|
|
|
53
54
|
reviewed: string,
|
|
54
55
|
controller: Controller,
|
|
55
56
|
idle: () => void,
|
|
57
|
+
tools?: ToolIdentity,
|
|
56
58
|
) {
|
|
57
59
|
const input = { kind: 'local-undo', id, reviewed };
|
|
58
60
|
const saved = store.cachedRequest<{ state: string }>('local-operator', request, input);
|
|
@@ -79,20 +81,33 @@ export function applyUndo(
|
|
|
79
81
|
return { state: 'outcome_unknown', ...review };
|
|
80
82
|
});
|
|
81
83
|
// Intent is durable before touching files. A lost process never repeats this mutation.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
84
|
+
if (tools?.mode === 'tools-user')
|
|
85
|
+
applyEditsAsTools(
|
|
86
|
+
tools,
|
|
87
|
+
root,
|
|
88
|
+
work.edits.map((edit: LocalEdit) => ({
|
|
89
|
+
...edit,
|
|
90
|
+
before: edit.after,
|
|
91
|
+
after: edit.before ?? '',
|
|
92
|
+
...(edit.before === null ? { remove: true } : {}),
|
|
93
|
+
})),
|
|
94
|
+
() => {},
|
|
95
|
+
);
|
|
96
|
+
else
|
|
97
|
+
for (const edit of work.edits) {
|
|
98
|
+
const inverse = { ...edit, before: edit.after, after: edit.before ?? '' };
|
|
99
|
+
if (edit.before === null) {
|
|
100
|
+
verifyLocalEdits(root, [inverse]);
|
|
101
|
+
const path = localPath(root, edit.path);
|
|
102
|
+
rmSync(path);
|
|
103
|
+
const directory = openSync(dirname(path), constants.O_RDONLY);
|
|
104
|
+
try {
|
|
105
|
+
fsyncSync(directory);
|
|
106
|
+
} finally {
|
|
107
|
+
closeSync(directory);
|
|
108
|
+
}
|
|
109
|
+
} else applyLocalEdits(root, [inverse], () => {});
|
|
110
|
+
}
|
|
96
111
|
verifyLocalEdits(root, work.edits);
|
|
97
112
|
return store.transaction(() => {
|
|
98
113
|
const result = { state: 'completed', call_id: work.call_id };
|
package/src/managed.ts
CHANGED
|
@@ -503,7 +503,7 @@ export class ManagedWork {
|
|
|
503
503
|
revision: number,
|
|
504
504
|
binding: string,
|
|
505
505
|
decision: 'approve' | 'deny',
|
|
506
|
-
scoped?: { receipt: Record<string, unknown>; authorize: () => void },
|
|
506
|
+
scoped?: { receipt: Record<string, unknown>; authorize: () => void; decidedBy?: string },
|
|
507
507
|
): Approval {
|
|
508
508
|
const requestWork = this.store.db.isTransaction
|
|
509
509
|
? this.store.requestInTransaction.bind(this.store)
|
|
@@ -553,7 +553,7 @@ export class ManagedWork {
|
|
|
553
553
|
.prepare(
|
|
554
554
|
"UPDATE approvals SET state='decided',decision=?,decided_by=?,revision=revision+1 WHERE id=?",
|
|
555
555
|
)
|
|
556
|
-
.run(decision, scoped ? 'scoped-policy' : 'local-operator', id);
|
|
556
|
+
.run(decision, scoped ? (scoped.decidedBy ?? 'scoped-policy') : 'local-operator', id);
|
|
557
557
|
if (decision === 'deny')
|
|
558
558
|
this.transition(op, 'cancelled_before_dispatch', 'Denied by operator');
|
|
559
559
|
this.store.event(
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
declarationDigest,
|
|
3
|
+
operatorMcpPath,
|
|
4
|
+
readOperatorMcpFile,
|
|
5
|
+
validateDeclaration,
|
|
6
|
+
writeOperatorMcpFile,
|
|
7
|
+
type McpDeclaration,
|
|
8
|
+
} from './operator-mcp.js';
|
|
9
|
+
|
|
10
|
+
export const MCP_HELP = `salaam mcp: operator-declared MCP servers
|
|
11
|
+
|
|
12
|
+
salaam mcp add <name> [--env K='\${VAR}']... -- <command> [args...]
|
|
13
|
+
salaam mcp add <name> --url <https-url> [--header K='Bearer \${VAR}']...
|
|
14
|
+
salaam mcp list
|
|
15
|
+
salaam mcp rm <name>
|
|
16
|
+
|
|
17
|
+
Options: --config <path> (default $XDG_CONFIG_HOME/salaam/mcp.json)
|
|
18
|
+
Values in --env and --header must reference environment variables as \${VAR};
|
|
19
|
+
inline secrets are refused. Changes apply at the next salaam service start.`;
|
|
20
|
+
|
|
21
|
+
function pairs(values: string[], flag: string): Record<string, string> | undefined {
|
|
22
|
+
if (!values.length) return undefined;
|
|
23
|
+
const out: Record<string, string> = {};
|
|
24
|
+
for (const value of values) {
|
|
25
|
+
const i = value.indexOf('=');
|
|
26
|
+
if (i < 1) throw new Error(`${flag} expects K=V`);
|
|
27
|
+
const key = value.slice(0, i);
|
|
28
|
+
if (Object.hasOwn(out, key)) throw new Error(`${flag} ${key} given twice`);
|
|
29
|
+
out[key] = value.slice(i + 1);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `salaam mcp ...`; argv excludes the leading "mcp". Returns text for stdout. */
|
|
35
|
+
export function mcpCli(argv: string[], env: NodeJS.ProcessEnv = process.env): string {
|
|
36
|
+
const dash = argv.indexOf('--');
|
|
37
|
+
const head = dash < 0 ? [...argv] : argv.slice(0, dash);
|
|
38
|
+
const command = dash < 0 ? [] : argv.slice(dash + 1);
|
|
39
|
+
const take = (flag: string, many = false): string[] => {
|
|
40
|
+
const found: string[] = [];
|
|
41
|
+
for (let i = head.indexOf(flag); i >= 0; i = head.indexOf(flag)) {
|
|
42
|
+
const value = head[i + 1];
|
|
43
|
+
if (value === undefined) throw new Error(`Missing value for ${flag}`);
|
|
44
|
+
found.push(value);
|
|
45
|
+
head.splice(i, 2);
|
|
46
|
+
if (!many) break;
|
|
47
|
+
}
|
|
48
|
+
return found;
|
|
49
|
+
};
|
|
50
|
+
const path = take('--config')[0] ?? operatorMcpPath(env);
|
|
51
|
+
const url = take('--url')[0];
|
|
52
|
+
const headers = pairs(take('--header', true), '--header');
|
|
53
|
+
const envs = pairs(take('--env', true), '--env');
|
|
54
|
+
const [verb, name, ...rest] = head;
|
|
55
|
+
if (rest.length || head.some((x) => x.startsWith('--')))
|
|
56
|
+
throw new Error('Unexpected arguments; run salaam mcp --help');
|
|
57
|
+
const file = readOperatorMcpFile(path);
|
|
58
|
+
if (verb === 'list') {
|
|
59
|
+
if (dash >= 0 || name) throw new Error('salaam mcp list takes no arguments');
|
|
60
|
+
return JSON.stringify(
|
|
61
|
+
{
|
|
62
|
+
config: path,
|
|
63
|
+
servers: file.servers.map((s) => ({ ...s, declaration_digest: declarationDigest(s) })),
|
|
64
|
+
},
|
|
65
|
+
null,
|
|
66
|
+
2,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (!name) throw new Error('A server name is required');
|
|
70
|
+
if (verb === 'rm') {
|
|
71
|
+
if (dash >= 0 || url || headers || envs) throw new Error('salaam mcp rm takes only a name');
|
|
72
|
+
const servers = file.servers.filter((s) => s.name !== name);
|
|
73
|
+
if (servers.length === file.servers.length) throw new Error(`No declared server "${name}"`);
|
|
74
|
+
writeOperatorMcpFile(path, { version: 1, servers });
|
|
75
|
+
return JSON.stringify({ removed: name, config: path });
|
|
76
|
+
}
|
|
77
|
+
if (verb === 'add') {
|
|
78
|
+
if (file.servers.some((s) => s.name === name))
|
|
79
|
+
throw new Error(`Server "${name}" is already declared; rm it first`);
|
|
80
|
+
let declaration: McpDeclaration;
|
|
81
|
+
if (url) {
|
|
82
|
+
if (command.length || envs) throw new Error('Use either --url or -- <command>, not both');
|
|
83
|
+
declaration = validateDeclaration({
|
|
84
|
+
name,
|
|
85
|
+
transport: 'http',
|
|
86
|
+
url,
|
|
87
|
+
...(headers ? { headers } : {}),
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
if (!command.length) throw new Error('Supply -- <command> [args...] or --url <url>');
|
|
91
|
+
if (headers) throw new Error('--header applies to --url servers');
|
|
92
|
+
declaration = validateDeclaration({
|
|
93
|
+
name,
|
|
94
|
+
transport: 'stdio',
|
|
95
|
+
command: command[0],
|
|
96
|
+
args: command.slice(1),
|
|
97
|
+
...(envs ? { env: envs } : {}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
writeOperatorMcpFile(path, { version: 1, servers: [...file.servers, declaration] });
|
|
101
|
+
return JSON.stringify({
|
|
102
|
+
added: name,
|
|
103
|
+
config: path,
|
|
104
|
+
declaration_digest: declarationDigest(declaration),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
throw new Error('Use salaam mcp add, list or rm');
|
|
108
|
+
}
|