@superjackfruitlabs/salaam 0.0.4 → 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 +41 -0
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +23 -19
- package/dist/src/acp.js +258 -11
- 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/cli-help.js +34 -4
- package/dist/src/cli.js +55 -3
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-sandbox.js +116 -6
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host.js +96 -4
- package/dist/src/house-extension.js +133 -7
- package/dist/src/local-coding.js +553 -58
- package/dist/src/local-edits.js +168 -6
- package/dist/src/mentions.js +277 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +4 -1
- package/dist/src/service.js +342 -5
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/store.js +51 -4
- package/dist/src/supervisor.js +233 -22
- package/dist/src/task-context.js +8 -2
- package/dist/src/terminal-status.js +88 -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/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 +25 -21
- package/src/acp.ts +338 -28
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/cli-help.ts +33 -4
- package/src/cli.ts +66 -2
- package/src/command-danger.ts +886 -0
- package/src/command-sandbox.ts +128 -8
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host.ts +122 -1
- package/src/house-extension.ts +142 -7
- package/src/local-coding.ts +674 -75
- package/src/local-edits.ts +191 -8
- package/src/mentions.ts +304 -0
- package/src/pi-sessions.ts +9 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +19 -2
- package/src/service.ts +488 -5
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/store.ts +52 -3
- package/src/supervisor.ts +262 -24
- package/src/task-context.ts +9 -2
- package/src/terminal-status.ts +103 -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/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
|
@@ -3,13 +3,20 @@ import { sandboxArgv, seatbeltArgv, UNSANDBOXED_REFUSAL, } from './command-sandb
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
|
|
6
|
-
import { tmpdir } from 'node:os';
|
|
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';
|
|
7
10
|
import { join } from 'node:path';
|
|
8
11
|
import { digest, object, string, ServiceError } from './protocol.js';
|
|
9
|
-
import { prepareLocalEdits, verifyLocalEdits, applyLocalEdits, } from './local-edits.js';
|
|
10
|
-
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';
|
|
11
15
|
import { assertUndoSettled } from './local-undo.js';
|
|
12
|
-
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;
|
|
13
20
|
import { applyEditsAsTools, killToolGroup, serviceToolIdentity, toolArgv, } from './tool-identity.js';
|
|
14
21
|
/** The proxy a broker-network command uses: only the relay on loopback, nothing direct. */
|
|
15
22
|
function proxyEnv(port) {
|
|
@@ -30,8 +37,21 @@ export class LocalCoding {
|
|
|
30
37
|
tools;
|
|
31
38
|
sandbox;
|
|
32
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;
|
|
33
46
|
closed = false;
|
|
34
47
|
unregister;
|
|
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;
|
|
35
55
|
constructor(store, managed, active, tools = serviceToolIdentity(),
|
|
36
56
|
/** Chosen by the service from the host and the operator's setting. */
|
|
37
57
|
sandbox = { kind: 'none', level: 'full-access', bwrap: null }) {
|
|
@@ -51,6 +71,19 @@ export class LocalCoding {
|
|
|
51
71
|
throw new ServiceError('local_busy', 'Command resources are still active');
|
|
52
72
|
},
|
|
53
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
|
+
}
|
|
54
87
|
// A lost controller must never retry a possibly executed edit or command.
|
|
55
88
|
for (const raw of store.db.prepare("SELECT * FROM local_calls WHERE state='running'").all()) {
|
|
56
89
|
const c = raw;
|
|
@@ -73,6 +106,22 @@ export class LocalCoding {
|
|
|
73
106
|
});
|
|
74
107
|
}
|
|
75
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
|
+
}
|
|
76
125
|
get(id) {
|
|
77
126
|
const row = this.store.db.prepare('SELECT * FROM local_calls WHERE id=?').get(id);
|
|
78
127
|
if (!row)
|
|
@@ -114,7 +163,51 @@ export class LocalCoding {
|
|
|
114
163
|
.get(c.root, c.id))
|
|
115
164
|
throw new ServiceError('local_busy', 'Another operation or uncertain outcome owns this workspace');
|
|
116
165
|
}
|
|
117
|
-
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) {
|
|
118
211
|
const prior = this.store.db
|
|
119
212
|
.prepare('SELECT id,input_digest FROM local_calls WHERE session_id=? AND epoch=? AND tool_call=?')
|
|
120
213
|
.get(session, epoch, toolCall);
|
|
@@ -123,6 +216,7 @@ export class LocalCoding {
|
|
|
123
216
|
throw new ServiceError('request_conflict', 'Tool call already binds different arguments');
|
|
124
217
|
return this.view(String(prior.id));
|
|
125
218
|
}
|
|
219
|
+
this.assertShellBinding(session, attempt, input);
|
|
126
220
|
const root = this.store.session(session).cwd;
|
|
127
221
|
const c = {
|
|
128
222
|
id: randomUUID(),
|
|
@@ -148,36 +242,72 @@ export class LocalCoding {
|
|
|
148
242
|
const timeout = input.timeout_ms ?? 30000;
|
|
149
243
|
if (!Number.isInteger(timeout) || Number(timeout) < 100 || Number(timeout) > 120000)
|
|
150
244
|
throw new ServiceError('invalid_field', 'Command timeout must be 100–120000 ms');
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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)
|
|
154
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');
|
|
155
|
-
if (
|
|
156
|
-
this.sandbox.level !== 'full-access' &&
|
|
157
|
-
!this.sandbox.allowUnsandboxed)
|
|
270
|
+
if (sb.kind === 'none' && sb.level !== 'full-access' && !sb.allowUnsandboxed)
|
|
158
271
|
throw new ServiceError('sandbox_unavailable', UNSANDBOXED_REFUSAL);
|
|
159
272
|
args = {
|
|
160
273
|
action: 'command',
|
|
161
274
|
command: string(input.command, 'command', 8192),
|
|
162
|
-
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 } : {}),
|
|
163
279
|
// Bound into the approval and the operation digest: the sandbox is part of what is approved.
|
|
164
280
|
sandbox: {
|
|
165
|
-
kind:
|
|
166
|
-
level:
|
|
167
|
-
bwrap:
|
|
168
|
-
...(this.brokerNetwork() ? { network: 'broker' } : {}),
|
|
281
|
+
kind: sb.kind,
|
|
282
|
+
level: sb.level,
|
|
283
|
+
bwrap: sb.bwrap?.version ?? null,
|
|
284
|
+
...(this.brokerNetwork(sb) ? { network: 'broker' } : {}),
|
|
169
285
|
},
|
|
170
286
|
};
|
|
171
287
|
}
|
|
172
288
|
else
|
|
173
289
|
throw new ServiceError('invalid_field', 'Choose edit or command');
|
|
174
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;
|
|
175
307
|
this.store.transaction(() => {
|
|
176
308
|
const reviewed = args.action === 'edit'
|
|
177
|
-
? args.edits
|
|
178
|
-
|
|
179
|
-
.join('\n\n')
|
|
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}`;
|
|
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}`;
|
|
181
311
|
const permission = this.managed.prepareWorkspaceInTransaction(session, epoch, attempt, toolCall, `Local ${kind}: ${root}`, reviewed, digest({ root, args }));
|
|
182
312
|
c.operation_id = permission.operation.id;
|
|
183
313
|
this.store.db
|
|
@@ -189,9 +319,73 @@ export class LocalCoding {
|
|
|
189
319
|
action: kind,
|
|
190
320
|
});
|
|
191
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
|
+
}));
|
|
192
331
|
const policy = permissionPolicy(this.store, session);
|
|
193
|
-
|
|
194
|
-
|
|
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)) {
|
|
195
389
|
const approval = this.managed.forOperation(c.operation_id);
|
|
196
390
|
this.managed.decide(approval.id, 'scoped:' + c.id, approval.revision, approval.operation_digest, 'approve', {
|
|
197
391
|
receipt: {
|
|
@@ -203,13 +397,25 @@ export class LocalCoding {
|
|
|
203
397
|
authorize: () => {
|
|
204
398
|
this.guard(c);
|
|
205
399
|
const fresh = permissionPolicy(this.store, session);
|
|
206
|
-
if (permissionDigest(fresh) !== permissionDigest(policy) ||
|
|
207
|
-
args.action !== 'edit' ||
|
|
208
|
-
!matchesEditScope(fresh, args.edits.map((e) => e.path)))
|
|
400
|
+
if (permissionDigest(fresh) !== permissionDigest(policy) || !covered(fresh))
|
|
209
401
|
throw new ServiceError('permission_changed', 'Scoped edit policy changed');
|
|
210
|
-
|
|
402
|
+
if (args.action === 'edit')
|
|
403
|
+
verifyLocalEdits(root, args.edits);
|
|
211
404
|
},
|
|
212
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
|
+
}));
|
|
213
419
|
}
|
|
214
420
|
return this.view(c.id);
|
|
215
421
|
}
|
|
@@ -278,44 +484,154 @@ export class LocalCoding {
|
|
|
278
484
|
});
|
|
279
485
|
}
|
|
280
486
|
}
|
|
487
|
+
else if (args.background === true)
|
|
488
|
+
this.background(c, String(args.command), this.bound(args));
|
|
281
489
|
else
|
|
282
|
-
this.command(c, String(args.command), Number(args.timeout_ms));
|
|
490
|
+
this.command(c, String(args.command), Number(args.timeout_ms), this.bound(args));
|
|
283
491
|
return this.view(id);
|
|
284
492
|
}
|
|
285
|
-
|
|
286
|
-
|
|
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.';
|
|
287
524
|
if (level === 'full-access')
|
|
288
|
-
return
|
|
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.';
|
|
289
528
|
if (kind === 'none')
|
|
290
529
|
return 'unsandboxed: this host has no usable command sandbox, so the command may access files and network beyond this workspace.';
|
|
291
530
|
const name = kind === 'seatbelt' ? 'Seatbelt' : 'bubblewrap';
|
|
292
|
-
const net = this.brokerNetwork()
|
|
531
|
+
const net = this.brokerNetwork(sb)
|
|
293
532
|
? 'network: through the broker allowlist (hosts set by the stk policy), nothing direct.'
|
|
294
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
|
+
: '';
|
|
295
537
|
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}`;
|
|
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}`;
|
|
298
540
|
}
|
|
299
541
|
/** Broker network applies only to a sandboxed command: unsandboxed ones are not confined anyway. */
|
|
300
|
-
brokerNetwork() {
|
|
301
|
-
return this.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
});
|
|
306
616
|
}
|
|
307
617
|
/** The process a command runs as. Under the tools user, the host-side shell reports its own
|
|
308
618
|
* process group (the one a stop kills) before exec-ing bubblewrap, whose PID namespace would
|
|
309
619
|
* otherwise report a number that means nothing on the host. */
|
|
310
|
-
commandArgv(root, script, tmp, relay) {
|
|
311
|
-
const sandboxed = this.
|
|
620
|
+
commandArgv(root, script, tmp, relay, sb = this.sandbox, listen = false) {
|
|
621
|
+
const sandboxed = this.sandboxed(sb);
|
|
312
622
|
const inner = (s) => !sandboxed
|
|
313
623
|
? ['/bin/sh', '-c', s]
|
|
314
|
-
:
|
|
315
|
-
? seatbeltArgv(
|
|
316
|
-
|
|
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, {
|
|
317
632
|
network: false,
|
|
318
633
|
...(relay?.dir ? { broker: { dir: relay.dir, node: process.execPath } } : {}),
|
|
634
|
+
readPaths: sb.readPaths ?? [],
|
|
319
635
|
});
|
|
320
636
|
if (this.tools.mode !== 'tools-user')
|
|
321
637
|
return inner(script);
|
|
@@ -332,11 +648,11 @@ export class LocalCoding {
|
|
|
332
648
|
])
|
|
333
649
|
: toolArgv(this.tools, ['/usr/bin/setsid', '-w', '/bin/sh', '-c', marker + script]);
|
|
334
650
|
}
|
|
335
|
-
command(c, command, timeout) {
|
|
336
|
-
const broker = this.brokerNetwork();
|
|
651
|
+
command(c, command, timeout, sb) {
|
|
652
|
+
const broker = this.brokerNetwork(sb);
|
|
337
653
|
// The relay is Salaam's, outside the sandbox: it forwards to stk and counts what was reached.
|
|
338
654
|
const relayStarting = broker
|
|
339
|
-
? BrokerRelay.start(broker.socket,
|
|
655
|
+
? BrokerRelay.start(broker.socket, sb.kind === 'seatbelt' ? 'tcp' : 'unix')
|
|
340
656
|
: Promise.resolve(null);
|
|
341
657
|
// A stop that arrives while the relay starts still cancels the command once it is spawned.
|
|
342
658
|
let early = false;
|
|
@@ -344,7 +660,7 @@ export class LocalCoding {
|
|
|
344
660
|
early = true;
|
|
345
661
|
};
|
|
346
662
|
// Seatbelt has no tmpfs: each command gets its own private tmp, removed when it ends.
|
|
347
|
-
const tmp =
|
|
663
|
+
const tmp = sb.kind === 'seatbelt' && sb.level !== 'full-access'
|
|
348
664
|
? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-cmd-')))
|
|
349
665
|
: undefined;
|
|
350
666
|
const set = (s) => {
|
|
@@ -352,12 +668,12 @@ export class LocalCoding {
|
|
|
352
668
|
if (early)
|
|
353
669
|
s();
|
|
354
670
|
};
|
|
355
|
-
const done = relayStarting.then((relay) => this.runCommand(c, command, timeout, tmp, relay, set), () => this.runCommand(c, command, timeout, tmp, null, set, !!broker));
|
|
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));
|
|
356
672
|
this.jobs.set(c.id, { stop: () => stop(), done });
|
|
357
673
|
if (tmp)
|
|
358
674
|
void done.finally(() => rmSync(tmp, { recursive: true, force: true }));
|
|
359
675
|
}
|
|
360
|
-
runCommand(c, command, timeout, tmp, relay, setStop, relayFailed = false) {
|
|
676
|
+
runCommand(c, command, timeout, tmp, relay, setStop, relayFailed = false, sb = this.sandbox) {
|
|
361
677
|
let stop = () => { };
|
|
362
678
|
return new Promise((resolve) => {
|
|
363
679
|
let output = '', bytes = 0, reason = null, finished = false;
|
|
@@ -366,7 +682,7 @@ export class LocalCoding {
|
|
|
366
682
|
const tools = this.tools.mode === 'tools-user';
|
|
367
683
|
const script = 'IFS= read -r salaam_start_gate || exit 125\nexec 0</dev/null\n' + command;
|
|
368
684
|
// 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);
|
|
685
|
+
const argv = this.commandArgv(c.root, script, tmp, relay, sb, JSON.parse(c.args).listen === true);
|
|
370
686
|
let toolGroup = null, marker = tools ? '' : null, spawned = false;
|
|
371
687
|
// Release the gate only once the process is owned and, as the tools user, its group is
|
|
372
688
|
// known: otherwise a kill before the marker could miss a command already running.
|
|
@@ -382,6 +698,7 @@ export class LocalCoding {
|
|
|
382
698
|
HOME: process.env.HOME ?? c.root,
|
|
383
699
|
LANG: 'en_US.UTF-8',
|
|
384
700
|
TMPDIR: '/tmp',
|
|
701
|
+
...(this.sandboxed(sb) ? gitIdentityEnv(c.root) : {}),
|
|
385
702
|
...(relay ? proxyEnv(relay.port ?? COMMAND_NETWORK_PORT) : {}),
|
|
386
703
|
},
|
|
387
704
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -532,12 +849,8 @@ export class LocalCoding {
|
|
|
532
849
|
state,
|
|
533
850
|
error: error ? 'Command could not start' : null,
|
|
534
851
|
isolation: (tools ? 'tools-user-' : 'local-user-') +
|
|
535
|
-
(this.
|
|
536
|
-
|
|
537
|
-
: 'no-os-sandbox'),
|
|
538
|
-
sandbox: this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access'
|
|
539
|
-
? this.sandbox.kind
|
|
540
|
-
: 'none',
|
|
852
|
+
(this.sandboxed(sb) ? sb.kind + '-' + sb.level : 'no-os-sandbox'),
|
|
853
|
+
sandbox: this.sandboxed(sb) ? sb.kind : 'none',
|
|
541
854
|
tool_uid: this.tools.uid,
|
|
542
855
|
...(network ? { network } : {}),
|
|
543
856
|
};
|
|
@@ -554,12 +867,194 @@ export class LocalCoding {
|
|
|
554
867
|
child.once('close', (code) => finish(code));
|
|
555
868
|
});
|
|
556
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) };
|
|
1048
|
+
}
|
|
557
1049
|
async close() {
|
|
558
1050
|
this.closed = true;
|
|
559
1051
|
const jobs = [...this.jobs.values()];
|
|
560
1052
|
for (const job of jobs)
|
|
561
1053
|
job.stop();
|
|
562
|
-
|
|
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)]);
|
|
563
1058
|
this.unregister();
|
|
564
1059
|
}
|
|
565
1060
|
}
|