@superjackfruitlabs/salaam 0.0.3 → 0.0.5
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 +86 -0
- package/README.md +12 -11
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +182 -18
- package/dist/src/acp.js +296 -15
- 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/approval-rules.js +287 -0
- package/dist/src/attach.js +3 -1
- package/dist/src/autonomy.js +80 -9
- package/dist/src/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +61 -6
- package/dist/src/cli.js +140 -4
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +327 -0
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- 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 +284 -6
- package/dist/src/house-extension.js +292 -61
- package/dist/src/local-coding.js +726 -35
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-edits.js +168 -6
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/mentions.js +277 -0
- 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-sessions.js +5 -1
- package/dist/src/pi.js +22 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +9 -3
- 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 +482 -10
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- 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 +70 -4
- package/dist/src/supervisor.js +408 -25
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-context.js +8 -2
- 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/terminal-status.js +88 -0
- package/dist/src/tool-identity.js +69 -0
- package/dist/src/tui-activity.js +99 -0
- package/dist/src/tui-classifier.js +25 -0
- package/dist/src/tui-client.js +186 -5
- package/dist/src/tui-commands.js +23 -5
- package/dist/src/tui-composer.js +113 -0
- package/dist/src/tui-keymap.js +116 -0
- package/dist/src/tui-mentions.js +38 -0
- package/dist/src/tui-modals.js +157 -34
- package/dist/src/tui-notify.js +62 -0
- package/dist/src/tui-review.js +55 -0
- package/dist/src/tui-turn-keys.js +52 -0
- package/dist/src/tui-view.js +243 -69
- package/dist/src/tui.js +875 -118
- package/dist/src/turn-checkpoints.js +221 -0
- package/dist/src/turn-rewind.js +168 -0
- package/dist/src/usage.js +1 -1
- package/dist/src/web-fetch.js +114 -0
- package/dist/src/web-guard.js +163 -0
- package/dist/src/web-search.js +302 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-desk.ts +155 -0
- package/src/acp-outcome.ts +174 -20
- package/src/acp.ts +386 -30
- 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/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +60 -6
- package/src/cli.ts +174 -4
- package/src/command-danger.ts +886 -0
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +393 -0
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/execution-evidence.ts +31 -1
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- 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 +365 -6
- package/src/house-extension.ts +313 -60
- package/src/local-coding.ts +894 -43
- package/src/local-edit-helper.ts +26 -0
- package/src/local-edits.ts +191 -8
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/mentions.ts +304 -0
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi-sessions.ts +9 -1
- package/src/pi.ts +23 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +25 -3
- 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 +709 -10
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- 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 +70 -3
- package/src/supervisor.ts +463 -26
- package/src/task-budget.ts +48 -3
- package/src/task-context.ts +9 -2
- 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/terminal-status.ts +103 -0
- package/src/tool-identity.ts +110 -0
- package/src/tui-activity.ts +133 -0
- package/src/tui-classifier.ts +38 -0
- package/src/tui-client.ts +239 -8
- package/src/tui-commands.ts +49 -10
- package/src/tui-composer.ts +124 -0
- package/src/tui-keymap.ts +152 -0
- package/src/tui-mentions.ts +43 -0
- package/src/tui-modals.ts +173 -43
- package/src/tui-notify.ts +77 -0
- package/src/tui-review.ts +76 -0
- package/src/tui-turn-keys.ts +77 -0
- package/src/tui-view.ts +341 -88
- package/src/tui.ts +1047 -137
- package/src/turn-checkpoints.ts +253 -0
- package/src/turn-rewind.ts +258 -0
- package/src/usage.ts +1 -1
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
package/dist/src/local-coding.js
CHANGED
|
@@ -1,22 +1,65 @@
|
|
|
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 { homedir, tmpdir } from 'node:os';
|
|
7
|
+
import { classifyCommand } from './command-danger.js';
|
|
8
|
+
import { gitIdentityEnv, publishesToRemote } from './git-helpers.js';
|
|
9
|
+
import { boundShellCommand } from './shell-passthrough.js';
|
|
10
|
+
import { join } from 'node:path';
|
|
4
11
|
import { digest, object, string, ServiceError } from './protocol.js';
|
|
5
|
-
import { prepareLocalEdits, verifyLocalEdits, applyLocalEdits, } from './local-edits.js';
|
|
6
|
-
import { permissionPolicy,
|
|
12
|
+
import { prepareLocalEdits, verifyLocalEdits, applyLocalEdits, reviewLocalEdits, } from './local-edits.js';
|
|
13
|
+
import { permissionPolicy, autoApprovesEdits, autoApprovesCommands, permissionDigest, assertPermissionBinding, } from './autonomy.js';
|
|
14
|
+
import { commandArgv, evaluateRules, } from './approval-rules.js';
|
|
7
15
|
import { assertUndoSettled } from './local-undo.js';
|
|
8
|
-
import { processIdentity } from './supervisor.js';
|
|
16
|
+
import { processIdentity, processStart } from './supervisor.js';
|
|
17
|
+
const TASK_TAIL = 65536;
|
|
18
|
+
const TASK_LIMIT = 4;
|
|
19
|
+
const BACKGROUND_LIFETIME = 2 * 3600 * 1000;
|
|
20
|
+
import { applyEditsAsTools, killToolGroup, serviceToolIdentity, toolArgv, } from './tool-identity.js';
|
|
21
|
+
/** The proxy a broker-network command uses: only the relay on loopback, nothing direct. */
|
|
22
|
+
function proxyEnv(port) {
|
|
23
|
+
const url = `http://127.0.0.1:${port}`;
|
|
24
|
+
return {
|
|
25
|
+
HTTP_PROXY: url,
|
|
26
|
+
HTTPS_PROXY: url,
|
|
27
|
+
http_proxy: url,
|
|
28
|
+
https_proxy: url,
|
|
29
|
+
NO_PROXY: 'localhost,127.0.0.1',
|
|
30
|
+
no_proxy: 'localhost,127.0.0.1',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
9
33
|
export class LocalCoding {
|
|
10
34
|
store;
|
|
11
35
|
managed;
|
|
12
36
|
active;
|
|
37
|
+
tools;
|
|
38
|
+
sandbox;
|
|
13
39
|
jobs = new Map();
|
|
40
|
+
/** Background tasks this controller owns; they belong to the session, not to a turn. */
|
|
41
|
+
live = new Map();
|
|
42
|
+
/** Output of recently ended tasks, so the model can read why one stopped. */
|
|
43
|
+
kept = new Map();
|
|
44
|
+
/** Background commands are for a person's own desk session only (Phase 3c). */
|
|
45
|
+
interactive = () => false;
|
|
14
46
|
closed = false;
|
|
15
47
|
unregister;
|
|
16
|
-
|
|
48
|
+
/** Desk-session approval rules (allow/ask/deny), set by the service; absent means every request asks. */
|
|
49
|
+
rules;
|
|
50
|
+
/** Operator ruling 2026-11-01 (option 1): one command may ask to run outside the OS sandbox.
|
|
51
|
+
* Off where the host forbids full access, since it is full access for that one command. */
|
|
52
|
+
allowOutsideSandbox = true;
|
|
53
|
+
/** The optional small-model command reviewer (Phase 4c); absent means the rules alone decide. */
|
|
54
|
+
classifier;
|
|
55
|
+
constructor(store, managed, active, tools = serviceToolIdentity(),
|
|
56
|
+
/** Chosen by the service from the host and the operator's setting. */
|
|
57
|
+
sandbox = { kind: 'none', level: 'full-access', bwrap: null }) {
|
|
17
58
|
this.store = store;
|
|
18
59
|
this.managed = managed;
|
|
19
60
|
this.active = active;
|
|
61
|
+
this.tools = tools;
|
|
62
|
+
this.sandbox = sandbox;
|
|
20
63
|
this.unregister = managed.registerMcpController('local-coding-v1', {
|
|
21
64
|
owns: (op) => !!store.db.prepare('SELECT 1 FROM local_calls WHERE operation_id=?').get(op.id),
|
|
22
65
|
assert: (op) => this.guard(this.byOperation(op)),
|
|
@@ -28,6 +71,19 @@ export class LocalCoding {
|
|
|
28
71
|
throw new ServiceError('local_busy', 'Command resources are still active');
|
|
29
72
|
},
|
|
30
73
|
});
|
|
74
|
+
store.db.exec('CREATE TABLE IF NOT EXISTS local_tasks(id TEXT PRIMARY KEY,session_id TEXT NOT NULL,call_id TEXT NOT NULL,command TEXT NOT NULL,state TEXT NOT NULL,pid INTEGER,identity TEXT,exit_code INTEGER,started_at TEXT NOT NULL,ended_at TEXT)');
|
|
75
|
+
// A background task never outlives its controller: kill what a restart left and say so.
|
|
76
|
+
for (const raw of store.db.prepare("SELECT * FROM local_tasks WHERE state='running'").all()) {
|
|
77
|
+
const t = raw;
|
|
78
|
+
if (t.pid && t.identity && processStart(t.pid) === t.identity)
|
|
79
|
+
try {
|
|
80
|
+
process.kill(-t.pid, 'SIGKILL');
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* already gone */
|
|
84
|
+
}
|
|
85
|
+
this.endTask(t, 'lost', null);
|
|
86
|
+
}
|
|
31
87
|
// A lost controller must never retry a possibly executed edit or command.
|
|
32
88
|
for (const raw of store.db.prepare("SELECT * FROM local_calls WHERE state='running'").all()) {
|
|
33
89
|
const c = raw;
|
|
@@ -39,6 +95,9 @@ export class LocalCoding {
|
|
|
39
95
|
/* outcome remains uncertain */
|
|
40
96
|
}
|
|
41
97
|
}
|
|
98
|
+
const pgid = c.result ? Number(JSON.parse(c.result).tool_pgid) : NaN;
|
|
99
|
+
if (Number.isSafeInteger(pgid))
|
|
100
|
+
killToolGroup(this.tools, pgid);
|
|
42
101
|
store.transaction(() => {
|
|
43
102
|
this.change(c, 'outcome_unknown', {
|
|
44
103
|
error: 'Controller restarted during execution. Inspect the recorded intent and workspace; this operation will not replay.',
|
|
@@ -47,6 +106,22 @@ export class LocalCoding {
|
|
|
47
106
|
});
|
|
48
107
|
}
|
|
49
108
|
}
|
|
109
|
+
/** The exact edits an edit request carries, or none for a command; files over 256 KiB are left out. */
|
|
110
|
+
edits(session, by) {
|
|
111
|
+
const row = this.store.db
|
|
112
|
+
.prepare(`SELECT root,args FROM local_calls WHERE session_id=? AND ${'operation' in by ? 'operation_id' : 'tool_call'}=? ORDER BY rowid DESC LIMIT 1`)
|
|
113
|
+
.get(session, 'operation' in by ? by.operation : by.toolCall);
|
|
114
|
+
if (!row)
|
|
115
|
+
return null;
|
|
116
|
+
const args = object(JSON.parse(row.args));
|
|
117
|
+
if (args.action !== 'edit')
|
|
118
|
+
return null;
|
|
119
|
+
const small = (text) => text === null || Buffer.byteLength(text) <= 256 * 1024;
|
|
120
|
+
return {
|
|
121
|
+
root: row.root,
|
|
122
|
+
edits: args.edits.filter((e) => small(e.before) && small(e.after)),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
50
125
|
get(id) {
|
|
51
126
|
const row = this.store.db.prepare('SELECT * FROM local_calls WHERE id=?').get(id);
|
|
52
127
|
if (!row)
|
|
@@ -88,7 +163,51 @@ export class LocalCoding {
|
|
|
88
163
|
.get(c.root, c.id))
|
|
89
164
|
throw new ServiceError('local_busy', 'Another operation or uncertain outcome owns this workspace');
|
|
90
165
|
}
|
|
91
|
-
prepare
|
|
166
|
+
/** prepare, after the optional reviewer has looked at a command the session would run unasked. */
|
|
167
|
+
async prepareReviewed(session, epoch, attempt, toolCall, input) {
|
|
168
|
+
let review = null;
|
|
169
|
+
if (this.classifier && input.action === 'command' && typeof input.command === 'string') {
|
|
170
|
+
const p = permissionPolicy(this.store, session);
|
|
171
|
+
// Only where the mode would run the command without asking; review mode asks anyway.
|
|
172
|
+
if (p.access || p.mode === 'auto') {
|
|
173
|
+
const root = this.store.session(session).cwd;
|
|
174
|
+
if (!classifyCommand(input.command, this.dangerContext(root)).dangerous)
|
|
175
|
+
review = await this.classifier.review(session, input.command, root);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return this.prepare(session, epoch, attempt, toolCall, input, review);
|
|
179
|
+
}
|
|
180
|
+
dangerContext(root) {
|
|
181
|
+
return {
|
|
182
|
+
root,
|
|
183
|
+
home: homedir(),
|
|
184
|
+
tmp: [...new Set(['/tmp', '/private/tmp', tmpdir(), realpathSync(tmpdir())])],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/** Has this tool call already been prepared? */
|
|
188
|
+
hasCall(session, epoch, toolCall) {
|
|
189
|
+
return !!this.store.db
|
|
190
|
+
.prepare('SELECT 1 FROM local_calls WHERE session_id=? AND epoch=? AND tool_call=?')
|
|
191
|
+
.get(session, epoch, toolCall);
|
|
192
|
+
}
|
|
193
|
+
/** A `!command` turn runs only what was typed, once (trial polish #12): enforced here, not by
|
|
194
|
+
* the model following its instruction, so it holds in auto, full access and YOLO alike. */
|
|
195
|
+
assertShellBinding(session, attempt, input) {
|
|
196
|
+
const row = this.store.db
|
|
197
|
+
.prepare('SELECT s.text FROM attempts a JOIN submissions s ON s.id=a.submission_id WHERE a.id=? AND a.session_id=?')
|
|
198
|
+
.get(attempt, session);
|
|
199
|
+
const bound = row ? boundShellCommand(row.text) : undefined;
|
|
200
|
+
if (bound === undefined)
|
|
201
|
+
return;
|
|
202
|
+
if (input.action !== 'command' || input.command !== bound || input.background)
|
|
203
|
+
throw new ServiceError('shell_binding', `This turn runs only the command you typed: ${bound}. Run exactly that, unchanged.`);
|
|
204
|
+
const ran = this.store.db
|
|
205
|
+
.prepare('SELECT 1 FROM local_calls WHERE session_id=? AND attempt_id=?')
|
|
206
|
+
.get(session, attempt);
|
|
207
|
+
if (ran)
|
|
208
|
+
throw new ServiceError('shell_binding', 'This turn already ran the command you typed. Report its output; do not run anything else.');
|
|
209
|
+
}
|
|
210
|
+
prepare(session, epoch, attempt, toolCall, input, review = null) {
|
|
92
211
|
const prior = this.store.db
|
|
93
212
|
.prepare('SELECT id,input_digest FROM local_calls WHERE session_id=? AND epoch=? AND tool_call=?')
|
|
94
213
|
.get(session, epoch, toolCall);
|
|
@@ -97,6 +216,7 @@ export class LocalCoding {
|
|
|
97
216
|
throw new ServiceError('request_conflict', 'Tool call already binds different arguments');
|
|
98
217
|
return this.view(String(prior.id));
|
|
99
218
|
}
|
|
219
|
+
this.assertShellBinding(session, attempt, input);
|
|
100
220
|
const root = this.store.session(session).cwd;
|
|
101
221
|
const c = {
|
|
102
222
|
id: randomUUID(),
|
|
@@ -122,21 +242,72 @@ export class LocalCoding {
|
|
|
122
242
|
const timeout = input.timeout_ms ?? 30000;
|
|
123
243
|
if (!Number.isInteger(timeout) || Number(timeout) < 100 || Number(timeout) > 120000)
|
|
124
244
|
throw new ServiceError('invalid_field', 'Command timeout must be 100–120000 ms');
|
|
245
|
+
const background = input.background === true;
|
|
246
|
+
if (input.background !== undefined && typeof input.background !== 'boolean')
|
|
247
|
+
throw new ServiceError('invalid_field', 'background must be true or false');
|
|
248
|
+
if (background)
|
|
249
|
+
this.assertBackground(session);
|
|
250
|
+
if (input.listen !== undefined && typeof input.listen !== 'boolean')
|
|
251
|
+
throw new ServiceError('invalid_field', 'listen must be true or false');
|
|
252
|
+
if (input.outside_sandbox !== undefined &&
|
|
253
|
+
(typeof input.outside_sandbox !== 'string' || !input.outside_sandbox.trim()))
|
|
254
|
+
throw new ServiceError('invalid_field', 'outside_sandbox must say why the command needs to run without the sandbox');
|
|
255
|
+
const session_sb = this.sandboxFor(session);
|
|
256
|
+
// Only a sandboxed session has anything to step out of; under full access it changes nothing.
|
|
257
|
+
const outside = typeof input.outside_sandbox === 'string' && this.sandboxed(session_sb)
|
|
258
|
+
? input.outside_sandbox.trim().slice(0, 300)
|
|
259
|
+
: undefined;
|
|
260
|
+
if (outside !== undefined && (!this.allowOutsideSandbox || this.tools.mode === 'tools-user'))
|
|
261
|
+
throw new ServiceError('outside_sandbox_denied', 'The operator does not allow commands outside the OS sandbox on this host. Ask the person to run it themselves.');
|
|
262
|
+
const sb = outside !== undefined ? { ...session_sb, level: 'full-access' } : session_sb;
|
|
263
|
+
// Operator ruling 2026-11-01 (option C): a sandboxed command listens only when a person
|
|
264
|
+
// approves that exact command to. Linux's sandbox has its own network, so it cannot serve.
|
|
265
|
+
const listen = input.listen === true && this.sandboxed(sb);
|
|
266
|
+
if (listen && sb.kind === 'bwrap')
|
|
267
|
+
throw new ServiceError('listen_unreachable', 'On Linux the command sandbox has its own network namespace, so a server it starts cannot be reached from the host. Ask for full access for this command, or run the server yourself.');
|
|
268
|
+
if (sb.kind === 'bwrap' && sb.level !== 'full-access' && !sb.bwrap)
|
|
269
|
+
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');
|
|
270
|
+
if (sb.kind === 'none' && sb.level !== 'full-access' && !sb.allowUnsandboxed)
|
|
271
|
+
throw new ServiceError('sandbox_unavailable', UNSANDBOXED_REFUSAL);
|
|
125
272
|
args = {
|
|
126
273
|
action: 'command',
|
|
127
274
|
command: string(input.command, 'command', 8192),
|
|
128
|
-
timeout_ms: Number(timeout),
|
|
275
|
+
timeout_ms: background ? BACKGROUND_LIFETIME : Number(timeout),
|
|
276
|
+
...(background ? { background: true } : {}),
|
|
277
|
+
...(listen ? { listen: true } : {}),
|
|
278
|
+
...(outside !== undefined ? { outside_sandbox: outside } : {}),
|
|
279
|
+
// Bound into the approval and the operation digest: the sandbox is part of what is approved.
|
|
280
|
+
sandbox: {
|
|
281
|
+
kind: sb.kind,
|
|
282
|
+
level: sb.level,
|
|
283
|
+
bwrap: sb.bwrap?.version ?? null,
|
|
284
|
+
...(this.brokerNetwork(sb) ? { network: 'broker' } : {}),
|
|
285
|
+
},
|
|
129
286
|
};
|
|
130
287
|
}
|
|
131
288
|
else
|
|
132
289
|
throw new ServiceError('invalid_field', 'Choose edit or command');
|
|
133
290
|
c.args = JSON.stringify(args);
|
|
291
|
+
// Operator ruling 2026-10-31: full access asks only for commands this check calls dangerous.
|
|
292
|
+
const rulesDanger = args.action === 'command'
|
|
293
|
+
? classifyCommand(args.command, this.dangerContext(root))
|
|
294
|
+
: { dangerous: false };
|
|
295
|
+
// Phase 4c: the reviewer can only add a reason to ask, never remove one.
|
|
296
|
+
const escalated = !rulesDanger.dangerous && args.action === 'command' && !!review?.escalate;
|
|
297
|
+
const danger = escalated
|
|
298
|
+
? {
|
|
299
|
+
dangerous: true,
|
|
300
|
+
category: 'classifier',
|
|
301
|
+
reason: `${review.reason} (reviewed by ${review.model})`,
|
|
302
|
+
segment: args.command,
|
|
303
|
+
}
|
|
304
|
+
: rulesDanger;
|
|
305
|
+
// Pushing and opening a pull request ask in every mode but YOLO (operator ruling 2026-10-31).
|
|
306
|
+
const publishes = args.action === 'command' ? publishesToRemote(args.command) : null;
|
|
134
307
|
this.store.transaction(() => {
|
|
135
308
|
const reviewed = args.action === 'edit'
|
|
136
|
-
? args.edits
|
|
137
|
-
|
|
138
|
-
.join('\n\n')
|
|
139
|
-
: `Runs as your local user, outside an OS sandbox. May access files and network beyond this workspace.\nDirectory: ${root}\nTimeout: ${args.timeout_ms} ms\nCommand:\n${args.command}`;
|
|
309
|
+
? reviewLocalEdits(args.edits)
|
|
310
|
+
: `Runs as ${this.tools.mode === 'tools-user' ? `tools user ${this.tools.user} (uid ${this.tools.uid})` : 'your local user'}, ${this.sandboxText(this.bound(args), !!args.outside_sandbox)}\nDirectory: ${root}${args.background ? `\nRuns in the background until stopped (at most ${BACKGROUND_LIFETIME / 3600000} h); the last ${TASK_TAIL / 1024} KiB of output is kept` : `\nTimeout: ${args.timeout_ms} ms`}\n${danger.dangerous ? `Dangerous (${danger.category}): ${danger.reason}\n` : ''}${publishes ? `Publishes: ${publishes}; this always asks, except in YOLO\n` : ''}${args.outside_sandbox ? `Outside the sandbox: ${args.outside_sandbox}. This one command runs without the OS sandbox; the session stays sandboxed. This always asks, except in YOLO or your own rule\n` : ''}${args.listen ? 'May listen: this command may accept network connections. macOS cannot limit listening to localhost, so a server bound to 0.0.0.0 is reachable from your network. This always asks, except in YOLO or your own rule\n' : ''}Command:\n${args.command}`;
|
|
140
311
|
const permission = this.managed.prepareWorkspaceInTransaction(session, epoch, attempt, toolCall, `Local ${kind}: ${root}`, reviewed, digest({ root, args }));
|
|
141
312
|
c.operation_id = permission.operation.id;
|
|
142
313
|
this.store.db
|
|
@@ -148,9 +319,73 @@ export class LocalCoding {
|
|
|
148
319
|
action: kind,
|
|
149
320
|
});
|
|
150
321
|
});
|
|
322
|
+
if (review)
|
|
323
|
+
this.store.transaction(() => this.store.event(session, 'permission.classifier', {
|
|
324
|
+
call_id: c.id,
|
|
325
|
+
operation_id: c.operation_id,
|
|
326
|
+
model: review.model,
|
|
327
|
+
verdict: review.verdict,
|
|
328
|
+
escalated,
|
|
329
|
+
...(review.reason ? { reason: review.reason } : {}),
|
|
330
|
+
}));
|
|
151
331
|
const policy = permissionPolicy(this.store, session);
|
|
152
|
-
|
|
153
|
-
|
|
332
|
+
// Desk-session rules: a matching deny refuses, a matching allow approves and is recorded with the
|
|
333
|
+
// rule that decided it; anything else falls through to the scoped-edit policy and then asks.
|
|
334
|
+
if (this.rules && policy.mode !== 'inspect' && this.rules.applies(session)) {
|
|
335
|
+
const request = args.action === 'command'
|
|
336
|
+
? { tool: 'command', argv: commandArgv(args.command) }
|
|
337
|
+
: { tool: 'edit', paths: args.edits.map((e) => e.path) };
|
|
338
|
+
// A repo's allow rules cannot approve a dangerous or publishing command: only the person's own
|
|
339
|
+
// rules can.
|
|
340
|
+
const usable = (all) => danger.dangerous ||
|
|
341
|
+
publishes ||
|
|
342
|
+
(args.action === 'command' && (args.listen || args.outside_sandbox))
|
|
343
|
+
? all.filter((r) => !(r.source === 'repo' && r.effect === 'allow'))
|
|
344
|
+
: all;
|
|
345
|
+
const decided = evaluateRules(usable(this.rules.list(session)), request);
|
|
346
|
+
const approval = this.managed.forOperation(c.operation_id);
|
|
347
|
+
const rule = decided.rule && {
|
|
348
|
+
id: decided.rule.id,
|
|
349
|
+
source: decided.rule.source,
|
|
350
|
+
effect: decided.rule.effect,
|
|
351
|
+
tool: decided.rule.tool,
|
|
352
|
+
...(decided.rule.command ? { command: decided.rule.command } : {}),
|
|
353
|
+
...(decided.rule.paths ? { paths: decided.rule.paths } : {}),
|
|
354
|
+
};
|
|
355
|
+
if (decided.effect === 'deny' && rule) {
|
|
356
|
+
this.managed.decide(approval.id, 'rule-deny:' + c.id, approval.revision, approval.operation_digest, 'deny');
|
|
357
|
+
this.store.transaction(() => this.store.event(session, 'permission.rule-denied', {
|
|
358
|
+
rule,
|
|
359
|
+
call_id: c.id,
|
|
360
|
+
operation_id: c.operation_id,
|
|
361
|
+
}));
|
|
362
|
+
}
|
|
363
|
+
else if (decided.effect === 'allow' && rule) {
|
|
364
|
+
const rules = this.rules;
|
|
365
|
+
this.managed.decide(approval.id, 'rule:' + c.id, approval.revision, approval.operation_digest, 'approve', {
|
|
366
|
+
receipt: {
|
|
367
|
+
policy_digest: permissionDigest(policy),
|
|
368
|
+
policy_revision: policy.revision,
|
|
369
|
+
rule,
|
|
370
|
+
root,
|
|
371
|
+
call_id: c.id,
|
|
372
|
+
},
|
|
373
|
+
decidedBy: 'rule:' + rule.source,
|
|
374
|
+
authorize: () => {
|
|
375
|
+
this.guard(c);
|
|
376
|
+
const again = evaluateRules(usable(rules.list(session)), request);
|
|
377
|
+
if (again.effect !== 'allow' || again.rule?.id !== rule.id)
|
|
378
|
+
throw new ServiceError('permission_changed', 'Approval rules changed');
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// The session's mode approves by itself only what it covers (Phase 2b): scoped or ordinary
|
|
384
|
+
// project edits, and, in auto, commands that stay inside the OS sandbox. Re-checked at decision.
|
|
385
|
+
const covered = (p) => args.action === 'edit'
|
|
386
|
+
? autoApprovesEdits(p, args.edits.map((e) => e.path))
|
|
387
|
+
: autoApprovesCommands(p, this.sandboxed(this.bound(args)), danger.dangerous, publishes !== null || args.listen === true || !!args.outside_sandbox) && !(escalated && p.access !== 'yolo');
|
|
388
|
+
if (this.managed.forOperation(c.operation_id).state === 'pending' && covered(policy)) {
|
|
154
389
|
const approval = this.managed.forOperation(c.operation_id);
|
|
155
390
|
this.managed.decide(approval.id, 'scoped:' + c.id, approval.revision, approval.operation_digest, 'approve', {
|
|
156
391
|
receipt: {
|
|
@@ -162,13 +397,25 @@ export class LocalCoding {
|
|
|
162
397
|
authorize: () => {
|
|
163
398
|
this.guard(c);
|
|
164
399
|
const fresh = permissionPolicy(this.store, session);
|
|
165
|
-
if (permissionDigest(fresh) !== permissionDigest(policy) ||
|
|
166
|
-
args.action !== 'edit' ||
|
|
167
|
-
!matchesEditScope(fresh, args.edits.map((e) => e.path)))
|
|
400
|
+
if (permissionDigest(fresh) !== permissionDigest(policy) || !covered(fresh))
|
|
168
401
|
throw new ServiceError('permission_changed', 'Scoped edit policy changed');
|
|
169
|
-
|
|
402
|
+
if (args.action === 'edit')
|
|
403
|
+
verifyLocalEdits(root, args.edits);
|
|
170
404
|
},
|
|
171
405
|
});
|
|
406
|
+
// YOLO approves everything; each approval is recorded with what full access would have asked.
|
|
407
|
+
if (policy.access === 'yolo')
|
|
408
|
+
this.store.transaction(() => this.store.event(session, 'permission.yolo-approved', {
|
|
409
|
+
call_id: c.id,
|
|
410
|
+
operation_id: c.operation_id,
|
|
411
|
+
action: args.action,
|
|
412
|
+
...(args.action === 'command' ? { command: args.command } : {}),
|
|
413
|
+
...(danger.dangerous
|
|
414
|
+
? { would_have_asked: { category: danger.category, reason: danger.reason } }
|
|
415
|
+
: publishes
|
|
416
|
+
? { would_have_asked: { category: 'publish', reason: publishes } }
|
|
417
|
+
: {}),
|
|
418
|
+
}));
|
|
172
419
|
}
|
|
173
420
|
return this.view(c.id);
|
|
174
421
|
}
|
|
@@ -207,13 +454,17 @@ export class LocalCoding {
|
|
|
207
454
|
try {
|
|
208
455
|
verifyLocalEdits(c.root, args.edits);
|
|
209
456
|
entered = true;
|
|
210
|
-
|
|
457
|
+
const progress = (path) => {
|
|
211
458
|
applied.push(path);
|
|
212
459
|
this.store.transaction(() => this.change(c, 'running', { applied }));
|
|
213
|
-
}
|
|
460
|
+
};
|
|
461
|
+
if (this.tools.mode === 'tools-user')
|
|
462
|
+
applyEditsAsTools(this.tools, c.root, args.edits, progress);
|
|
463
|
+
else
|
|
464
|
+
applyLocalEdits(c.root, args.edits, progress);
|
|
214
465
|
verifyLocalEdits(c.root, args.edits.map((edit) => ({ ...edit, before: edit.after })));
|
|
215
466
|
this.store.transaction(() => {
|
|
216
|
-
const result = { applied, verified: true };
|
|
467
|
+
const result = { applied, verified: true, tool_uid: this.tools.uid };
|
|
217
468
|
this.change(c, 'succeeded', result);
|
|
218
469
|
this.managed.workspaceResultInTransaction(c.operation_id, result);
|
|
219
470
|
});
|
|
@@ -233,18 +484,213 @@ export class LocalCoding {
|
|
|
233
484
|
});
|
|
234
485
|
}
|
|
235
486
|
}
|
|
487
|
+
else if (args.background === true)
|
|
488
|
+
this.background(c, String(args.command), this.bound(args));
|
|
236
489
|
else
|
|
237
|
-
this.command(c, String(args.command), Number(args.timeout_ms));
|
|
490
|
+
this.command(c, String(args.command), Number(args.timeout_ms), this.bound(args));
|
|
238
491
|
return this.view(id);
|
|
239
492
|
}
|
|
240
|
-
command
|
|
493
|
+
/** The sandbox for a new command in this session: the host's, or full access if the person chose
|
|
494
|
+
* it for this session (Phase 2b). Bound into the command's approval and digest. */
|
|
495
|
+
sandboxFor(session) {
|
|
496
|
+
return permissionPolicy(this.store, session).access
|
|
497
|
+
? { ...this.sandbox, level: 'full-access' }
|
|
498
|
+
: this.sandbox;
|
|
499
|
+
}
|
|
500
|
+
/** The sandbox a prepared command was approved under. */
|
|
501
|
+
bound(args) {
|
|
502
|
+
return args.sandbox?.level === 'full-access'
|
|
503
|
+
? { ...this.sandbox, level: 'full-access' }
|
|
504
|
+
: this.sandbox;
|
|
505
|
+
}
|
|
506
|
+
sandboxed(sb) {
|
|
507
|
+
return sb.kind !== 'none' && sb.level !== 'full-access';
|
|
508
|
+
}
|
|
509
|
+
/** Where this session's next command runs, in a word or two for the status and side panels. */
|
|
510
|
+
commandSandbox(session) {
|
|
511
|
+
const access = permissionPolicy(this.store, session).access;
|
|
512
|
+
if (access)
|
|
513
|
+
return access === 'yolo' ? 'YOLO' : 'full access';
|
|
514
|
+
if (this.sandbox.level === 'full-access')
|
|
515
|
+
return 'no sandbox';
|
|
516
|
+
if (this.sandbox.kind === 'none')
|
|
517
|
+
return this.sandbox.allowUnsandboxed ? 'no sandbox' : 'refused';
|
|
518
|
+
return this.sandbox.kind === 'seatbelt' ? 'Seatbelt' : 'bubblewrap';
|
|
519
|
+
}
|
|
520
|
+
sandboxText(sb = this.sandbox, outside = false) {
|
|
521
|
+
const { kind, level } = sb;
|
|
522
|
+
if (outside)
|
|
523
|
+
return 'unsandboxed for this one command, at its own request: it can read and change any file your user can and reach the network.';
|
|
524
|
+
if (level === 'full-access')
|
|
525
|
+
return this.sandbox.level === 'full-access'
|
|
526
|
+
? 'unsandboxed: the operator chose full access. May access files and network beyond this workspace.'
|
|
527
|
+
: 'unsandboxed: you turned on full access for this session. May access files and network beyond this workspace.';
|
|
528
|
+
if (kind === 'none')
|
|
529
|
+
return 'unsandboxed: this host has no usable command sandbox, so the command may access files and network beyond this workspace.';
|
|
530
|
+
const name = kind === 'seatbelt' ? 'Seatbelt' : 'bubblewrap';
|
|
531
|
+
const net = this.brokerNetwork(sb)
|
|
532
|
+
? 'network: through the broker allowlist (hosts set by the stk policy), nothing direct.'
|
|
533
|
+
: 'has no network.';
|
|
534
|
+
const reads = this.sandbox.readPaths?.length
|
|
535
|
+
? ` It can also read ${this.sandbox.readPaths.length} operator-allowed path${this.sandbox.readPaths.length === 1 ? '' : 's'} outside the workspace.`
|
|
536
|
+
: '';
|
|
537
|
+
return level === 'read-only'
|
|
538
|
+
? `in a ${name} sandbox (read-only): it can read this workspace but write nothing, and ${net}${reads}`
|
|
539
|
+
: `in a ${name} sandbox (workspace-write): it can write only inside this workspace, and ${net}${reads}`;
|
|
540
|
+
}
|
|
541
|
+
/** Broker network applies only to a sandboxed command: unsandboxed ones are not confined anyway. */
|
|
542
|
+
brokerNetwork(sb = this.sandbox) {
|
|
543
|
+
return sb.network && this.sandboxed(sb) ? sb.network : null;
|
|
544
|
+
}
|
|
545
|
+
/** Run a hook (Phase 4b) as one of this session's sandboxed commands would run: the same sandbox
|
|
546
|
+
* and level, no network, a minimal environment, `input` on stdin, bounded output and a timeout.
|
|
547
|
+
* Never as the tools user, whose process accounting is for approved operations only. */
|
|
548
|
+
runHook(session, command, input, timeoutMs) {
|
|
549
|
+
const refused = (why) => Promise.resolve({
|
|
550
|
+
code: null,
|
|
551
|
+
stdout: '',
|
|
552
|
+
stderr: '',
|
|
553
|
+
timedOut: false,
|
|
554
|
+
truncated: false,
|
|
555
|
+
refused: why,
|
|
556
|
+
});
|
|
557
|
+
if (this.tools.mode === 'tools-user')
|
|
558
|
+
return refused('Hooks do not run when commands run as a separate tools user');
|
|
559
|
+
const sb = this.sandboxFor(session);
|
|
560
|
+
if (sb.kind === 'bwrap' && sb.level !== 'full-access' && !sb.bwrap)
|
|
561
|
+
return refused('This host has no usable bubblewrap, so it cannot run a sandboxed hook');
|
|
562
|
+
if (sb.kind === 'none' && sb.level !== 'full-access' && !sb.allowUnsandboxed)
|
|
563
|
+
return refused(UNSANDBOXED_REFUSAL);
|
|
564
|
+
const root = this.store.session(session).cwd;
|
|
565
|
+
const tmp = sb.kind === 'seatbelt' && sb.level !== 'full-access'
|
|
566
|
+
? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-hook-')))
|
|
567
|
+
: undefined;
|
|
568
|
+
const argv = this.commandArgv(root, command, tmp, null, sb);
|
|
569
|
+
const cap = 16 * 1024;
|
|
570
|
+
return new Promise((resolve) => {
|
|
571
|
+
let stdout = '', stderr = '', truncated = false, timedOut = false;
|
|
572
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
573
|
+
cwd: root,
|
|
574
|
+
detached: true,
|
|
575
|
+
env: {
|
|
576
|
+
PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
|
|
577
|
+
HOME: process.env.HOME ?? root,
|
|
578
|
+
LANG: 'en_US.UTF-8',
|
|
579
|
+
TMPDIR: '/tmp',
|
|
580
|
+
SALAAM_PROJECT_DIR: root,
|
|
581
|
+
CLAUDE_PROJECT_DIR: root,
|
|
582
|
+
},
|
|
583
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
584
|
+
});
|
|
585
|
+
const take = (current, chunk) => {
|
|
586
|
+
const room = cap - Buffer.byteLength(current);
|
|
587
|
+
if (chunk.length > room)
|
|
588
|
+
truncated = true;
|
|
589
|
+
return room > 0 ? current + chunk.subarray(0, room).toString('utf8') : current;
|
|
590
|
+
};
|
|
591
|
+
child.stdout.on('data', (c) => (stdout = take(stdout, c)));
|
|
592
|
+
child.stderr.on('data', (c) => (stderr = take(stderr, c)));
|
|
593
|
+
child.stdin.on('error', () => { });
|
|
594
|
+
child.stdin.end(input);
|
|
595
|
+
const timer = setTimeout(() => {
|
|
596
|
+
timedOut = true;
|
|
597
|
+
try {
|
|
598
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
/* already gone */
|
|
602
|
+
}
|
|
603
|
+
}, timeoutMs);
|
|
604
|
+
const finish = (code) => {
|
|
605
|
+
clearTimeout(timer);
|
|
606
|
+
if (tmp)
|
|
607
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
608
|
+
resolve({ code: timedOut ? null : code, stdout, stderr, timedOut, truncated });
|
|
609
|
+
};
|
|
610
|
+
child.once('error', (error) => {
|
|
611
|
+
stderr = take(stderr, Buffer.from(error.message));
|
|
612
|
+
finish(null);
|
|
613
|
+
});
|
|
614
|
+
child.once('close', (code) => finish(code));
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
/** The process a command runs as. Under the tools user, the host-side shell reports its own
|
|
618
|
+
* process group (the one a stop kills) before exec-ing bubblewrap, whose PID namespace would
|
|
619
|
+
* otherwise report a number that means nothing on the host. */
|
|
620
|
+
commandArgv(root, script, tmp, relay, sb = this.sandbox, listen = false) {
|
|
621
|
+
const sandboxed = this.sandboxed(sb);
|
|
622
|
+
const inner = (s) => !sandboxed
|
|
623
|
+
? ['/bin/sh', '-c', s]
|
|
624
|
+
: sb.kind === 'seatbelt'
|
|
625
|
+
? seatbeltArgv(sb.level, root, tmp ?? '', ['/bin/sh', '-c', s], sb.seatbelt ?? null, {
|
|
626
|
+
network: false,
|
|
627
|
+
...(relay?.port ? { brokerPort: relay.port } : {}),
|
|
628
|
+
readPaths: sb.readPaths ?? [],
|
|
629
|
+
...(listen ? { listen: true } : {}),
|
|
630
|
+
})
|
|
631
|
+
: sandboxArgv(sb.level, root, ['/bin/sh', '-c', s], sb.bwrap, {
|
|
632
|
+
network: false,
|
|
633
|
+
...(relay?.dir ? { broker: { dir: relay.dir, node: process.execPath } } : {}),
|
|
634
|
+
readPaths: sb.readPaths ?? [],
|
|
635
|
+
});
|
|
636
|
+
if (this.tools.mode !== 'tools-user')
|
|
637
|
+
return inner(script);
|
|
638
|
+
const marker = 'umask 0002\nprintf \'salaam-tool-pgid:%s\\n\' "$$"\n';
|
|
639
|
+
return sandboxed
|
|
640
|
+
? toolArgv(this.tools, [
|
|
641
|
+
'/usr/bin/setsid',
|
|
642
|
+
'-w',
|
|
643
|
+
'/bin/sh',
|
|
644
|
+
'-c',
|
|
645
|
+
marker + 'exec "$@"',
|
|
646
|
+
'sh',
|
|
647
|
+
...inner(script),
|
|
648
|
+
])
|
|
649
|
+
: toolArgv(this.tools, ['/usr/bin/setsid', '-w', '/bin/sh', '-c', marker + script]);
|
|
650
|
+
}
|
|
651
|
+
command(c, command, timeout, sb) {
|
|
652
|
+
const broker = this.brokerNetwork(sb);
|
|
653
|
+
// The relay is Salaam's, outside the sandbox: it forwards to stk and counts what was reached.
|
|
654
|
+
const relayStarting = broker
|
|
655
|
+
? BrokerRelay.start(broker.socket, sb.kind === 'seatbelt' ? 'tcp' : 'unix')
|
|
656
|
+
: Promise.resolve(null);
|
|
657
|
+
// A stop that arrives while the relay starts still cancels the command once it is spawned.
|
|
658
|
+
let early = false;
|
|
659
|
+
let stop = () => {
|
|
660
|
+
early = true;
|
|
661
|
+
};
|
|
662
|
+
// Seatbelt has no tmpfs: each command gets its own private tmp, removed when it ends.
|
|
663
|
+
const tmp = sb.kind === 'seatbelt' && sb.level !== 'full-access'
|
|
664
|
+
? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-cmd-')))
|
|
665
|
+
: undefined;
|
|
666
|
+
const set = (s) => {
|
|
667
|
+
stop = s;
|
|
668
|
+
if (early)
|
|
669
|
+
s();
|
|
670
|
+
};
|
|
671
|
+
const done = relayStarting.then((relay) => this.runCommand(c, command, timeout, tmp, relay, set, false, sb), () => this.runCommand(c, command, timeout, tmp, null, set, !!broker, sb));
|
|
672
|
+
this.jobs.set(c.id, { stop: () => stop(), done });
|
|
673
|
+
if (tmp)
|
|
674
|
+
void done.finally(() => rmSync(tmp, { recursive: true, force: true }));
|
|
675
|
+
}
|
|
676
|
+
runCommand(c, command, timeout, tmp, relay, setStop, relayFailed = false, sb = this.sandbox) {
|
|
241
677
|
let stop = () => { };
|
|
242
|
-
|
|
678
|
+
return new Promise((resolve) => {
|
|
243
679
|
let output = '', bytes = 0, reason = null, finished = false;
|
|
244
680
|
// Deliberately do not inherit the daemon's provider credentials or service tokens.
|
|
245
681
|
// Hold the shell before any user instruction until its identity is durably owned.
|
|
682
|
+
const tools = this.tools.mode === 'tools-user';
|
|
246
683
|
const script = 'IFS= read -r salaam_start_gate || exit 125\nexec 0</dev/null\n' + command;
|
|
247
|
-
|
|
684
|
+
// As the tools user, setsid makes the shell its own group leader so it can be killed by group.
|
|
685
|
+
const argv = this.commandArgv(c.root, script, tmp, relay, sb, JSON.parse(c.args).listen === true);
|
|
686
|
+
let toolGroup = null, marker = tools ? '' : null, spawned = false;
|
|
687
|
+
// Release the gate only once the process is owned and, as the tools user, its group is
|
|
688
|
+
// known: otherwise a kill before the marker could miss a command already running.
|
|
689
|
+
const release = () => {
|
|
690
|
+
if (spawned && (!tools || toolGroup) && !reason && !finished)
|
|
691
|
+
child.stdin.end('start\n');
|
|
692
|
+
};
|
|
693
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
248
694
|
cwd: c.root,
|
|
249
695
|
detached: true,
|
|
250
696
|
env: {
|
|
@@ -252,11 +698,17 @@ export class LocalCoding {
|
|
|
252
698
|
HOME: process.env.HOME ?? c.root,
|
|
253
699
|
LANG: 'en_US.UTF-8',
|
|
254
700
|
TMPDIR: '/tmp',
|
|
701
|
+
...(this.sandboxed(sb) ? gitIdentityEnv(c.root) : {}),
|
|
702
|
+
...(relay ? proxyEnv(relay.port ?? COMMAND_NETWORK_PORT) : {}),
|
|
255
703
|
},
|
|
256
704
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
257
705
|
});
|
|
258
706
|
let reap;
|
|
259
707
|
const kill = () => {
|
|
708
|
+
if (toolGroup)
|
|
709
|
+
killToolGroup(this.tools, toolGroup);
|
|
710
|
+
if (tools)
|
|
711
|
+
child.stdin.destroy();
|
|
260
712
|
if (child.pid)
|
|
261
713
|
try {
|
|
262
714
|
process.kill(-child.pid, 'SIGKILL');
|
|
@@ -266,17 +718,26 @@ export class LocalCoding {
|
|
|
266
718
|
}
|
|
267
719
|
if (!finished && !reap)
|
|
268
720
|
reap = setTimeout(() => {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
721
|
+
if (toolGroup)
|
|
722
|
+
killToolGroup(this.tools, toolGroup);
|
|
723
|
+
// A busy event loop runs timers before pending I/O: let an already delivered close
|
|
724
|
+
// settle the outcome before declaring it unknown.
|
|
725
|
+
setImmediate(() => {
|
|
726
|
+
if (finished)
|
|
727
|
+
return;
|
|
728
|
+
reason = 'outcome_unknown';
|
|
729
|
+
child.stdout.destroy();
|
|
730
|
+
child.stderr.destroy();
|
|
731
|
+
child.unref();
|
|
732
|
+
finish(null);
|
|
733
|
+
});
|
|
274
734
|
}, 1500);
|
|
275
735
|
};
|
|
276
736
|
stop = () => {
|
|
277
737
|
reason ??= 'cancelled';
|
|
278
738
|
kill();
|
|
279
739
|
};
|
|
740
|
+
setStop(stop);
|
|
280
741
|
const deadline = setTimeout(() => {
|
|
281
742
|
reason = 'timed_out';
|
|
282
743
|
kill();
|
|
@@ -302,7 +763,38 @@ export class LocalCoding {
|
|
|
302
763
|
reason ??= 'outcome_unknown';
|
|
303
764
|
kill();
|
|
304
765
|
});
|
|
305
|
-
child.stdout.on('data',
|
|
766
|
+
child.stdout.on('data', (chunk) => {
|
|
767
|
+
if (marker === null)
|
|
768
|
+
return collect(chunk);
|
|
769
|
+
marker += chunk.toString('latin1');
|
|
770
|
+
const end = marker.indexOf('\n');
|
|
771
|
+
if (end < 0) {
|
|
772
|
+
if (marker.length > 64) {
|
|
773
|
+
reason = 'outcome_unknown';
|
|
774
|
+
kill();
|
|
775
|
+
}
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const found = /^salaam-tool-pgid:(\d+)$/.exec(marker.slice(0, end));
|
|
779
|
+
const rest = Buffer.from(marker.slice(end + 1), 'latin1');
|
|
780
|
+
marker = null;
|
|
781
|
+
if (!found) {
|
|
782
|
+
reason = 'outcome_unknown';
|
|
783
|
+
kill();
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
toolGroup = Number(found[1]);
|
|
787
|
+
try {
|
|
788
|
+
this.store.transaction(() => this.change(c, 'running', { tool_pgid: toolGroup }));
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
reason = 'outcome_unknown';
|
|
792
|
+
kill();
|
|
793
|
+
}
|
|
794
|
+
if (rest.length)
|
|
795
|
+
collect(rest);
|
|
796
|
+
release();
|
|
797
|
+
});
|
|
306
798
|
child.stderr.on('data', collect);
|
|
307
799
|
child.once('spawn', () => {
|
|
308
800
|
try {
|
|
@@ -319,7 +811,8 @@ export class LocalCoding {
|
|
|
319
811
|
stop();
|
|
320
812
|
return;
|
|
321
813
|
}
|
|
322
|
-
|
|
814
|
+
spawned = true;
|
|
815
|
+
release();
|
|
323
816
|
}
|
|
324
817
|
catch {
|
|
325
818
|
reason = 'outcome_unknown';
|
|
@@ -337,12 +830,29 @@ export class LocalCoding {
|
|
|
337
830
|
kill();
|
|
338
831
|
this.jobs.delete(c.id);
|
|
339
832
|
const state = reason ?? (error ? 'failed' : code === 0 ? 'succeeded' : 'failed');
|
|
833
|
+
const network = relay
|
|
834
|
+
? relay.summary()
|
|
835
|
+
: relayFailed
|
|
836
|
+
? { broker: 'unavailable', connections: [] }
|
|
837
|
+
: null;
|
|
838
|
+
void relay?.close();
|
|
839
|
+
// What the broker refused, in its own words, so the reason reaches the card.
|
|
840
|
+
const refusals = (network?.connections ?? [])
|
|
841
|
+
.filter((n) => n.refused && n.reason)
|
|
842
|
+
.map((n) => `[network] refused ${n.target}: ${n.reason}`)
|
|
843
|
+
.join('\n');
|
|
340
844
|
const result = {
|
|
341
845
|
exit_code: code,
|
|
342
|
-
output
|
|
846
|
+
output: refusals
|
|
847
|
+
? output + (output.endsWith('\n') || !output ? '' : '\n') + refusals + '\n'
|
|
848
|
+
: output,
|
|
343
849
|
state,
|
|
344
850
|
error: error ? 'Command could not start' : null,
|
|
345
|
-
isolation: '
|
|
851
|
+
isolation: (tools ? 'tools-user-' : 'local-user-') +
|
|
852
|
+
(this.sandboxed(sb) ? sb.kind + '-' + sb.level : 'no-os-sandbox'),
|
|
853
|
+
sandbox: this.sandboxed(sb) ? sb.kind : 'none',
|
|
854
|
+
tool_uid: this.tools.uid,
|
|
855
|
+
...(network ? { network } : {}),
|
|
346
856
|
};
|
|
347
857
|
this.store.transaction(() => {
|
|
348
858
|
this.change(c, state, result);
|
|
@@ -356,14 +866,195 @@ export class LocalCoding {
|
|
|
356
866
|
child.once('error', (e) => finish(null, e));
|
|
357
867
|
child.once('close', (code) => finish(code));
|
|
358
868
|
});
|
|
359
|
-
|
|
869
|
+
}
|
|
870
|
+
assertBackground(session) {
|
|
871
|
+
if (!this.interactive(session))
|
|
872
|
+
throw new ServiceError('background_denied', 'Background commands are only for an interactive local coding session');
|
|
873
|
+
if (this.tools.mode === 'tools-user')
|
|
874
|
+
throw new ServiceError('background_denied', 'Background commands do not yet run as a separate tools user');
|
|
875
|
+
if ([...this.live.values()].filter((t) => t.session === session).length >= TASK_LIMIT)
|
|
876
|
+
throw new ServiceError('background_limit', `A session runs at most ${TASK_LIMIT} background commands; stop one first`);
|
|
877
|
+
}
|
|
878
|
+
/** Launch an approved background command. Its operation settles once the process is owned;
|
|
879
|
+
* the process then lives as a session task until it exits, is stopped, or the session ends. */
|
|
880
|
+
background(c, command, sb) {
|
|
881
|
+
const settle = (ok, result) => this.store.transaction(() => {
|
|
882
|
+
this.change(c, ok ? 'succeeded' : 'failed', result);
|
|
883
|
+
this.managed.localExecutionResultInTransaction(c.operation_id, ok, result);
|
|
884
|
+
});
|
|
885
|
+
try {
|
|
886
|
+
this.assertBackground(c.session_id);
|
|
887
|
+
}
|
|
888
|
+
catch (error) {
|
|
889
|
+
settle(false, { error: error.message, background: true });
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
const id = 'task_' + randomUUID().slice(0, 8);
|
|
893
|
+
const tmp = sb.kind === 'seatbelt' && sb.level !== 'full-access'
|
|
894
|
+
? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-cmd-')))
|
|
895
|
+
: undefined;
|
|
896
|
+
// Network for a long-lived task needs a relay that lives as long; keep it simple and honest.
|
|
897
|
+
const script = 'IFS= read -r salaam_start_gate || exit 125\nexec 0</dev/null\n' + command;
|
|
898
|
+
const argv = this.commandArgv(c.root, script, tmp, null, sb, JSON.parse(c.args).listen === true);
|
|
899
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
900
|
+
cwd: c.root,
|
|
901
|
+
detached: true,
|
|
902
|
+
env: {
|
|
903
|
+
PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
|
|
904
|
+
HOME: process.env.HOME ?? c.root,
|
|
905
|
+
LANG: 'en_US.UTF-8',
|
|
906
|
+
TMPDIR: '/tmp',
|
|
907
|
+
...(this.sandboxed(sb) ? gitIdentityEnv(c.root) : {}),
|
|
908
|
+
},
|
|
909
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
910
|
+
});
|
|
911
|
+
let tail = Buffer.alloc(0), bytes = 0, ended = null, resolve = () => { };
|
|
912
|
+
const done = new Promise((r) => (resolve = r));
|
|
913
|
+
const kill = () => {
|
|
914
|
+
if (child.pid)
|
|
915
|
+
try {
|
|
916
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
917
|
+
}
|
|
918
|
+
catch {
|
|
919
|
+
/* already gone */
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
const task = {
|
|
923
|
+
id,
|
|
924
|
+
session: c.session_id,
|
|
925
|
+
done,
|
|
926
|
+
output: () => ({ output: tail.toString('utf8'), bytes, truncated: bytes > tail.length }),
|
|
927
|
+
stop: (why) => {
|
|
928
|
+
ended ??= why;
|
|
929
|
+
kill();
|
|
930
|
+
},
|
|
931
|
+
};
|
|
932
|
+
const collect = (chunk) => {
|
|
933
|
+
bytes += chunk.length;
|
|
934
|
+
tail = Buffer.concat([tail, chunk]);
|
|
935
|
+
if (tail.length > TASK_TAIL)
|
|
936
|
+
tail = tail.subarray(tail.length - TASK_TAIL);
|
|
937
|
+
};
|
|
938
|
+
child.stdout.on('data', collect);
|
|
939
|
+
child.stderr.on('data', collect);
|
|
940
|
+
child.stdin.on('error', () => { });
|
|
941
|
+
const lifetime = setTimeout(() => task.stop('timed_out'), BACKGROUND_LIFETIME);
|
|
942
|
+
// The session, not the turn, owns the task: it ends when local coding or its root goes away.
|
|
943
|
+
const watch = setInterval(() => {
|
|
944
|
+
try {
|
|
945
|
+
if (this.closed || !this.store.localCoding(c.session_id))
|
|
946
|
+
throw Error('closed');
|
|
947
|
+
if (this.store.session(c.session_id).cwd !== c.root)
|
|
948
|
+
throw Error('moved');
|
|
949
|
+
}
|
|
950
|
+
catch {
|
|
951
|
+
task.stop('stopped');
|
|
952
|
+
}
|
|
953
|
+
}, 1000);
|
|
954
|
+
const finish = (code) => {
|
|
955
|
+
clearTimeout(lifetime);
|
|
956
|
+
clearInterval(watch);
|
|
957
|
+
kill();
|
|
958
|
+
this.live.delete(id);
|
|
959
|
+
this.kept.set(id, task.output());
|
|
960
|
+
if (this.kept.size > 32)
|
|
961
|
+
this.kept.delete(this.kept.keys().next().value);
|
|
962
|
+
if (tmp)
|
|
963
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
964
|
+
const row = this.store.db.prepare('SELECT * FROM local_tasks WHERE id=?').get(id);
|
|
965
|
+
if (row)
|
|
966
|
+
this.endTask(row, ended ?? 'exited', code);
|
|
967
|
+
else
|
|
968
|
+
settle(false, { error: 'Command could not start', background: true });
|
|
969
|
+
resolve();
|
|
970
|
+
};
|
|
971
|
+
child.once('error', () => finish(null));
|
|
972
|
+
child.once('close', (code) => finish(code));
|
|
973
|
+
child.once('spawn', () => {
|
|
974
|
+
try {
|
|
975
|
+
// The start time, not processIdentity: the gated shell execs into the command.
|
|
976
|
+
const identity = processStart(child.pid);
|
|
977
|
+
if (!identity)
|
|
978
|
+
throw Error('No process identity');
|
|
979
|
+
this.store.transaction(() => this.store.db
|
|
980
|
+
.prepare("INSERT INTO local_tasks(id,session_id,call_id,command,state,pid,identity,started_at) VALUES (?,?,?,?,'running',?,?,?)")
|
|
981
|
+
.run(id, c.session_id, c.id, command, child.pid, identity, new Date().toISOString()));
|
|
982
|
+
this.live.set(id, task);
|
|
983
|
+
settle(true, {
|
|
984
|
+
background: true,
|
|
985
|
+
task_id: id,
|
|
986
|
+
state: 'running',
|
|
987
|
+
isolation: 'local-user-' + (this.sandboxed(sb) ? sb.kind + '-' + sb.level : 'no-os-sandbox'),
|
|
988
|
+
sandbox: this.sandboxed(sb) ? sb.kind : 'none',
|
|
989
|
+
note: 'Read its output with salaam_tasks; it keeps running after this turn.',
|
|
990
|
+
});
|
|
991
|
+
child.stdin.end('start\n');
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
ended = 'lost';
|
|
995
|
+
kill();
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
endTask(t, state, code) {
|
|
1000
|
+
this.store.transaction(() => {
|
|
1001
|
+
this.store.db
|
|
1002
|
+
.prepare("UPDATE local_tasks SET state=?,exit_code=?,ended_at=? WHERE id=? AND state='running'")
|
|
1003
|
+
.run(state, code, new Date().toISOString(), t.id);
|
|
1004
|
+
this.store.event(t.session_id, 'local.task_finished', {
|
|
1005
|
+
task_id: t.id,
|
|
1006
|
+
command: t.command.slice(0, 200),
|
|
1007
|
+
state,
|
|
1008
|
+
exit_code: code,
|
|
1009
|
+
});
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
/** The session's background tasks, newest first. */
|
|
1013
|
+
tasks(session) {
|
|
1014
|
+
return this.store.db
|
|
1015
|
+
.prepare('SELECT * FROM local_tasks WHERE session_id=? ORDER BY started_at DESC, rowid DESC')
|
|
1016
|
+
.all(session).map((t) => ({
|
|
1017
|
+
id: t.id,
|
|
1018
|
+
command: t.command,
|
|
1019
|
+
state: t.state,
|
|
1020
|
+
exit_code: t.exit_code,
|
|
1021
|
+
pid: t.pid,
|
|
1022
|
+
started_at: t.started_at,
|
|
1023
|
+
ended_at: t.ended_at,
|
|
1024
|
+
bytes: this.live.get(t.id)?.output().bytes ?? null,
|
|
1025
|
+
}));
|
|
1026
|
+
}
|
|
1027
|
+
task(session, id) {
|
|
1028
|
+
const row = this.store.db
|
|
1029
|
+
.prepare('SELECT * FROM local_tasks WHERE id=? AND session_id=?')
|
|
1030
|
+
.get(id, session);
|
|
1031
|
+
if (!row)
|
|
1032
|
+
throw new ServiceError('not_found', 'No such background task in this session', 404);
|
|
1033
|
+
return row;
|
|
1034
|
+
}
|
|
1035
|
+
/** The kept tail of a task's output. Output of a task this controller no longer owns is gone. */
|
|
1036
|
+
taskOutput(session, id) {
|
|
1037
|
+
const row = this.task(session, id);
|
|
1038
|
+
const live = this.live.get(id);
|
|
1039
|
+
return {
|
|
1040
|
+
task: { id: row.id, state: row.state, exit_code: row.exit_code },
|
|
1041
|
+
...(live?.output() ?? this.kept.get(id) ?? { output: '', bytes: 0, truncated: false }),
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
stopTask(session, id) {
|
|
1045
|
+
this.task(session, id);
|
|
1046
|
+
this.live.get(id)?.stop('stopped');
|
|
1047
|
+
return { stopping: this.live.has(id) };
|
|
360
1048
|
}
|
|
361
1049
|
async close() {
|
|
362
1050
|
this.closed = true;
|
|
363
1051
|
const jobs = [...this.jobs.values()];
|
|
364
1052
|
for (const job of jobs)
|
|
365
1053
|
job.stop();
|
|
366
|
-
|
|
1054
|
+
const tasks = [...this.live.values()];
|
|
1055
|
+
for (const t of tasks)
|
|
1056
|
+
t.stop('stopped');
|
|
1057
|
+
await Promise.all([...jobs.map((j) => j.done), ...tasks.map((t) => t.done)]);
|
|
367
1058
|
this.unregister();
|
|
368
1059
|
}
|
|
369
1060
|
}
|