@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/src/local-coding.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
|
+
import { BrokerRelay, COMMAND_NETWORK_PORT } from './command-network.js';
|
|
2
|
+
import {
|
|
3
|
+
sandboxArgv,
|
|
4
|
+
seatbeltArgv,
|
|
5
|
+
UNSANDBOXED_REFUSAL,
|
|
6
|
+
type Bwrap,
|
|
7
|
+
type SandboxKind,
|
|
8
|
+
type SandboxLevel,
|
|
9
|
+
type Seatbelt,
|
|
10
|
+
} from './command-sandbox.js';
|
|
1
11
|
import { spawn } from 'node:child_process';
|
|
2
12
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { realpathSync } from 'node:fs';
|
|
13
|
+
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
|
|
14
|
+
import { homedir, tmpdir } from 'node:os';
|
|
15
|
+
import { classifyCommand, type DangerVerdict } from './command-danger.js';
|
|
16
|
+
import { gitIdentityEnv, publishesToRemote } from './git-helpers.js';
|
|
17
|
+
import { boundShellCommand } from './shell-passthrough.js';
|
|
18
|
+
import type { ClassifierReview } from './danger-classifier.js';
|
|
19
|
+
import { join } from 'node:path';
|
|
4
20
|
import type { Store } from './store.js';
|
|
5
21
|
import type { ManagedWork } from './managed.js';
|
|
6
22
|
import { digest, object, string, ServiceError, type Operation } from './protocol.js';
|
|
@@ -8,16 +24,56 @@ import {
|
|
|
8
24
|
prepareLocalEdits,
|
|
9
25
|
verifyLocalEdits,
|
|
10
26
|
applyLocalEdits,
|
|
27
|
+
reviewLocalEdits,
|
|
11
28
|
type LocalEdit,
|
|
12
29
|
} from './local-edits.js';
|
|
13
30
|
import {
|
|
14
31
|
permissionPolicy,
|
|
32
|
+
autoApprovesEdits,
|
|
33
|
+
autoApprovesCommands,
|
|
15
34
|
permissionDigest,
|
|
16
|
-
matchesEditScope,
|
|
17
35
|
assertPermissionBinding,
|
|
18
36
|
} from './autonomy.js';
|
|
37
|
+
import {
|
|
38
|
+
commandArgv,
|
|
39
|
+
evaluateRules,
|
|
40
|
+
type ApprovalRules,
|
|
41
|
+
type RuleRequest,
|
|
42
|
+
} from './approval-rules.js';
|
|
19
43
|
import { assertUndoSettled } from './local-undo.js';
|
|
20
|
-
import {
|
|
44
|
+
import type { HookRun } from './hooks.js';
|
|
45
|
+
import { processIdentity, processStart } from './supervisor.js';
|
|
46
|
+
|
|
47
|
+
const TASK_TAIL = 65536;
|
|
48
|
+
const TASK_LIMIT = 4;
|
|
49
|
+
const BACKGROUND_LIFETIME = 2 * 3600 * 1000;
|
|
50
|
+
type TaskState = 'running' | 'exited' | 'stopped' | 'timed_out' | 'lost';
|
|
51
|
+
interface TaskRow {
|
|
52
|
+
id: string;
|
|
53
|
+
session_id: string;
|
|
54
|
+
call_id: string;
|
|
55
|
+
command: string;
|
|
56
|
+
state: TaskState;
|
|
57
|
+
pid: number | null;
|
|
58
|
+
identity: string | null;
|
|
59
|
+
exit_code: number | null;
|
|
60
|
+
started_at: string;
|
|
61
|
+
ended_at: string | null;
|
|
62
|
+
}
|
|
63
|
+
interface BackgroundTask {
|
|
64
|
+
id: string;
|
|
65
|
+
session: string;
|
|
66
|
+
done: Promise<void>;
|
|
67
|
+
output: () => { output: string; bytes: number; truncated: boolean };
|
|
68
|
+
stop: (why: TaskState) => void;
|
|
69
|
+
}
|
|
70
|
+
import {
|
|
71
|
+
applyEditsAsTools,
|
|
72
|
+
killToolGroup,
|
|
73
|
+
serviceToolIdentity,
|
|
74
|
+
toolArgv,
|
|
75
|
+
type ToolIdentity,
|
|
76
|
+
} from './tool-identity.js';
|
|
21
77
|
|
|
22
78
|
interface LocalCall {
|
|
23
79
|
id: string;
|
|
@@ -34,14 +90,63 @@ interface LocalCall {
|
|
|
34
90
|
pid: number | null;
|
|
35
91
|
identity: string | null;
|
|
36
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Item 10: how this host runs local commands. `bwrap` (Linux) or `seatbelt` (macOS): under the OS
|
|
95
|
+
* sandbox at `level` (full-access only by explicit operator setting). `none`: a host with no usable
|
|
96
|
+
* sandbox, which refuses local commands unless the operator allowed them unsandboxed
|
|
97
|
+
* (`allowUnsandboxed`); then it runs them and says so in every approval.
|
|
98
|
+
*/
|
|
99
|
+
export interface LocalSandbox {
|
|
100
|
+
kind: SandboxKind;
|
|
101
|
+
level: SandboxLevel;
|
|
102
|
+
bwrap: Bwrap | null;
|
|
103
|
+
seatbelt?: Seatbelt | null;
|
|
104
|
+
/** The host config's `unsandboxed_commands: "allow"` (operator ruling 2026-10-30, option A). */
|
|
105
|
+
allowUnsandboxed?: boolean;
|
|
106
|
+
/** The host config's `command_network: "broker"`: a sandboxed command reaches the network only
|
|
107
|
+
* through this stk local-commands socket (operator rulings 2026-10-30). */
|
|
108
|
+
network?: { socket: string };
|
|
109
|
+
/** Operator-allowed read-only paths outside the workspace (Phase 1, 2026-10-31). */
|
|
110
|
+
readPaths?: string[];
|
|
111
|
+
}
|
|
112
|
+
/** The proxy a broker-network command uses: only the relay on loopback, nothing direct. */
|
|
113
|
+
function proxyEnv(port: number): Record<string, string> {
|
|
114
|
+
const url = `http://127.0.0.1:${port}`;
|
|
115
|
+
return {
|
|
116
|
+
HTTP_PROXY: url,
|
|
117
|
+
HTTPS_PROXY: url,
|
|
118
|
+
http_proxy: url,
|
|
119
|
+
https_proxy: url,
|
|
120
|
+
NO_PROXY: 'localhost,127.0.0.1',
|
|
121
|
+
no_proxy: 'localhost,127.0.0.1',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
37
124
|
export class LocalCoding {
|
|
38
125
|
private jobs = new Map<string, { stop: () => void; done: Promise<void> }>();
|
|
126
|
+
/** Background tasks this controller owns; they belong to the session, not to a turn. */
|
|
127
|
+
private live = new Map<string, BackgroundTask>();
|
|
128
|
+
/** Output of recently ended tasks, so the model can read why one stopped. */
|
|
129
|
+
private kept = new Map<string, { output: string; bytes: number; truncated: boolean }>();
|
|
130
|
+
/** Background commands are for a person's own desk session only (Phase 3c). */
|
|
131
|
+
public interactive: (session: string) => boolean = () => false;
|
|
39
132
|
private closed = false;
|
|
40
133
|
private unregister: () => void;
|
|
134
|
+
/** Desk-session approval rules (allow/ask/deny), set by the service; absent means every request asks. */
|
|
135
|
+
public rules?: ApprovalRules;
|
|
136
|
+
/** Operator ruling 2026-11-01 (option 1): one command may ask to run outside the OS sandbox.
|
|
137
|
+
* Off where the host forbids full access, since it is full access for that one command. */
|
|
138
|
+
public allowOutsideSandbox = true;
|
|
139
|
+
/** The optional small-model command reviewer (Phase 4c); absent means the rules alone decide. */
|
|
140
|
+
public classifier?: {
|
|
141
|
+
review(session: string, command: string, cwd: string): Promise<ClassifierReview | null>;
|
|
142
|
+
};
|
|
41
143
|
constructor(
|
|
42
144
|
readonly store: Store,
|
|
43
145
|
readonly managed: ManagedWork,
|
|
44
146
|
readonly active: (s: string, e: number, a: string) => void,
|
|
147
|
+
readonly tools: ToolIdentity = serviceToolIdentity(),
|
|
148
|
+
/** Chosen by the service from the host and the operator's setting. */
|
|
149
|
+
public sandbox: LocalSandbox = { kind: 'none', level: 'full-access', bwrap: null },
|
|
45
150
|
) {
|
|
46
151
|
this.unregister = managed.registerMcpController('local-coding-v1', {
|
|
47
152
|
owns: (op) => !!store.db.prepare('SELECT 1 FROM local_calls WHERE operation_id=?').get(op.id),
|
|
@@ -54,6 +159,20 @@ export class LocalCoding {
|
|
|
54
159
|
throw new ServiceError('local_busy', 'Command resources are still active');
|
|
55
160
|
},
|
|
56
161
|
});
|
|
162
|
+
store.db.exec(
|
|
163
|
+
'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)',
|
|
164
|
+
);
|
|
165
|
+
// A background task never outlives its controller: kill what a restart left and say so.
|
|
166
|
+
for (const raw of store.db.prepare("SELECT * FROM local_tasks WHERE state='running'").all()) {
|
|
167
|
+
const t = raw as unknown as TaskRow;
|
|
168
|
+
if (t.pid && t.identity && processStart(t.pid) === t.identity)
|
|
169
|
+
try {
|
|
170
|
+
process.kill(-t.pid, 'SIGKILL');
|
|
171
|
+
} catch {
|
|
172
|
+
/* already gone */
|
|
173
|
+
}
|
|
174
|
+
this.endTask(t, 'lost', null);
|
|
175
|
+
}
|
|
57
176
|
// A lost controller must never retry a possibly executed edit or command.
|
|
58
177
|
for (const raw of store.db.prepare("SELECT * FROM local_calls WHERE state='running'").all()) {
|
|
59
178
|
const c = raw as unknown as LocalCall;
|
|
@@ -64,6 +183,8 @@ export class LocalCoding {
|
|
|
64
183
|
/* outcome remains uncertain */
|
|
65
184
|
}
|
|
66
185
|
}
|
|
186
|
+
const pgid = c.result ? Number(JSON.parse(c.result).tool_pgid) : NaN;
|
|
187
|
+
if (Number.isSafeInteger(pgid)) killToolGroup(this.tools, pgid);
|
|
67
188
|
store.transaction(() => {
|
|
68
189
|
this.change(c, 'outcome_unknown', {
|
|
69
190
|
error:
|
|
@@ -73,6 +194,26 @@ export class LocalCoding {
|
|
|
73
194
|
});
|
|
74
195
|
}
|
|
75
196
|
}
|
|
197
|
+
/** The exact edits an edit request carries, or none for a command; files over 256 KiB are left out. */
|
|
198
|
+
edits(
|
|
199
|
+
session: string,
|
|
200
|
+
by: { operation: string } | { toolCall: string },
|
|
201
|
+
): { root: string; edits: LocalEdit[] } | null {
|
|
202
|
+
const row = this.store.db
|
|
203
|
+
.prepare(
|
|
204
|
+
`SELECT root,args FROM local_calls WHERE session_id=? AND ${'operation' in by ? 'operation_id' : 'tool_call'}=? ORDER BY rowid DESC LIMIT 1`,
|
|
205
|
+
)
|
|
206
|
+
.get(session, 'operation' in by ? by.operation : by.toolCall) as
|
|
207
|
+
{ root: string; args: string } | undefined;
|
|
208
|
+
if (!row) return null;
|
|
209
|
+
const args = object(JSON.parse(row.args));
|
|
210
|
+
if (args.action !== 'edit') return null;
|
|
211
|
+
const small = (text: string | null) => text === null || Buffer.byteLength(text) <= 256 * 1024;
|
|
212
|
+
return {
|
|
213
|
+
root: row.root,
|
|
214
|
+
edits: (args.edits as LocalEdit[]).filter((e) => small(e.before) && small(e.after)),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
76
217
|
get(id: string): LocalCall {
|
|
77
218
|
const row = this.store.db.prepare('SELECT * FROM local_calls WHERE id=?').get(id);
|
|
78
219
|
if (!row) throw new ServiceError('not_found', 'Local operation not found', 404);
|
|
@@ -121,12 +262,70 @@ export class LocalCoding {
|
|
|
121
262
|
'Another operation or uncertain outcome owns this workspace',
|
|
122
263
|
);
|
|
123
264
|
}
|
|
265
|
+
/** prepare, after the optional reviewer has looked at a command the session would run unasked. */
|
|
266
|
+
async prepareReviewed(
|
|
267
|
+
session: string,
|
|
268
|
+
epoch: number,
|
|
269
|
+
attempt: string,
|
|
270
|
+
toolCall: string,
|
|
271
|
+
input: Record<string, unknown>,
|
|
272
|
+
) {
|
|
273
|
+
let review: ClassifierReview | null = null;
|
|
274
|
+
if (this.classifier && input.action === 'command' && typeof input.command === 'string') {
|
|
275
|
+
const p = permissionPolicy(this.store, session);
|
|
276
|
+
// Only where the mode would run the command without asking; review mode asks anyway.
|
|
277
|
+
if (p.access || p.mode === 'auto') {
|
|
278
|
+
const root = this.store.session(session).cwd;
|
|
279
|
+
if (!classifyCommand(input.command, this.dangerContext(root)).dangerous)
|
|
280
|
+
review = await this.classifier.review(session, input.command, root);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return this.prepare(session, epoch, attempt, toolCall, input, review);
|
|
284
|
+
}
|
|
285
|
+
private dangerContext(root: string) {
|
|
286
|
+
return {
|
|
287
|
+
root,
|
|
288
|
+
home: homedir(),
|
|
289
|
+
tmp: [...new Set(['/tmp', '/private/tmp', tmpdir(), realpathSync(tmpdir())])],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/** Has this tool call already been prepared? */
|
|
293
|
+
hasCall(session: string, epoch: number, toolCall: string): boolean {
|
|
294
|
+
return !!this.store.db
|
|
295
|
+
.prepare('SELECT 1 FROM local_calls WHERE session_id=? AND epoch=? AND tool_call=?')
|
|
296
|
+
.get(session, epoch, toolCall);
|
|
297
|
+
}
|
|
298
|
+
/** A `!command` turn runs only what was typed, once (trial polish #12): enforced here, not by
|
|
299
|
+
* the model following its instruction, so it holds in auto, full access and YOLO alike. */
|
|
300
|
+
private assertShellBinding(session: string, attempt: string, input: Record<string, unknown>) {
|
|
301
|
+
const row = this.store.db
|
|
302
|
+
.prepare(
|
|
303
|
+
'SELECT s.text FROM attempts a JOIN submissions s ON s.id=a.submission_id WHERE a.id=? AND a.session_id=?',
|
|
304
|
+
)
|
|
305
|
+
.get(attempt, session) as { text: string } | undefined;
|
|
306
|
+
const bound = row ? boundShellCommand(row.text) : undefined;
|
|
307
|
+
if (bound === undefined) return;
|
|
308
|
+
if (input.action !== 'command' || input.command !== bound || input.background)
|
|
309
|
+
throw new ServiceError(
|
|
310
|
+
'shell_binding',
|
|
311
|
+
`This turn runs only the command you typed: ${bound}. Run exactly that, unchanged.`,
|
|
312
|
+
);
|
|
313
|
+
const ran = this.store.db
|
|
314
|
+
.prepare('SELECT 1 FROM local_calls WHERE session_id=? AND attempt_id=?')
|
|
315
|
+
.get(session, attempt);
|
|
316
|
+
if (ran)
|
|
317
|
+
throw new ServiceError(
|
|
318
|
+
'shell_binding',
|
|
319
|
+
'This turn already ran the command you typed. Report its output; do not run anything else.',
|
|
320
|
+
);
|
|
321
|
+
}
|
|
124
322
|
prepare(
|
|
125
323
|
session: string,
|
|
126
324
|
epoch: number,
|
|
127
325
|
attempt: string,
|
|
128
326
|
toolCall: string,
|
|
129
327
|
input: Record<string, unknown>,
|
|
328
|
+
review: ClassifierReview | null = null,
|
|
130
329
|
) {
|
|
131
330
|
const prior = this.store.db
|
|
132
331
|
.prepare(
|
|
@@ -138,6 +337,7 @@ export class LocalCoding {
|
|
|
138
337
|
throw new ServiceError('request_conflict', 'Tool call already binds different arguments');
|
|
139
338
|
return this.view(String(prior.id));
|
|
140
339
|
}
|
|
340
|
+
this.assertShellBinding(session, attempt, input);
|
|
141
341
|
const root = this.store.session(session).cwd;
|
|
142
342
|
const c: LocalCall = {
|
|
143
343
|
id: randomUUID(),
|
|
@@ -158,26 +358,102 @@ export class LocalCoding {
|
|
|
158
358
|
const kind = string(input.action, 'action');
|
|
159
359
|
let args:
|
|
160
360
|
| { action: 'edit'; edits: LocalEdit[] }
|
|
161
|
-
| {
|
|
361
|
+
| {
|
|
362
|
+
action: 'command';
|
|
363
|
+
command: string;
|
|
364
|
+
timeout_ms: number;
|
|
365
|
+
background?: true;
|
|
366
|
+
listen?: true;
|
|
367
|
+
/** Why this one command asked to run without the OS sandbox. */
|
|
368
|
+
outside_sandbox?: string;
|
|
369
|
+
sandbox: { kind: string; level: string; bwrap: string | null; network?: 'broker' };
|
|
370
|
+
};
|
|
162
371
|
if (kind === 'edit') args = { action: 'edit', edits: prepareLocalEdits(root, input.edits) };
|
|
163
372
|
else if (kind === 'command') {
|
|
164
373
|
const timeout = input.timeout_ms ?? 30000;
|
|
165
374
|
if (!Number.isInteger(timeout) || Number(timeout) < 100 || Number(timeout) > 120000)
|
|
166
375
|
throw new ServiceError('invalid_field', 'Command timeout must be 100–120000 ms');
|
|
376
|
+
const background = input.background === true;
|
|
377
|
+
if (input.background !== undefined && typeof input.background !== 'boolean')
|
|
378
|
+
throw new ServiceError('invalid_field', 'background must be true or false');
|
|
379
|
+
if (background) this.assertBackground(session);
|
|
380
|
+
if (input.listen !== undefined && typeof input.listen !== 'boolean')
|
|
381
|
+
throw new ServiceError('invalid_field', 'listen must be true or false');
|
|
382
|
+
if (
|
|
383
|
+
input.outside_sandbox !== undefined &&
|
|
384
|
+
(typeof input.outside_sandbox !== 'string' || !input.outside_sandbox.trim())
|
|
385
|
+
)
|
|
386
|
+
throw new ServiceError(
|
|
387
|
+
'invalid_field',
|
|
388
|
+
'outside_sandbox must say why the command needs to run without the sandbox',
|
|
389
|
+
);
|
|
390
|
+
const session_sb = this.sandboxFor(session);
|
|
391
|
+
// Only a sandboxed session has anything to step out of; under full access it changes nothing.
|
|
392
|
+
const outside =
|
|
393
|
+
typeof input.outside_sandbox === 'string' && this.sandboxed(session_sb)
|
|
394
|
+
? input.outside_sandbox.trim().slice(0, 300)
|
|
395
|
+
: undefined;
|
|
396
|
+
if (outside !== undefined && (!this.allowOutsideSandbox || this.tools.mode === 'tools-user'))
|
|
397
|
+
throw new ServiceError(
|
|
398
|
+
'outside_sandbox_denied',
|
|
399
|
+
'The operator does not allow commands outside the OS sandbox on this host. Ask the person to run it themselves.',
|
|
400
|
+
);
|
|
401
|
+
const sb: LocalSandbox =
|
|
402
|
+
outside !== undefined ? { ...session_sb, level: 'full-access' } : session_sb;
|
|
403
|
+
// Operator ruling 2026-11-01 (option C): a sandboxed command listens only when a person
|
|
404
|
+
// approves that exact command to. Linux's sandbox has its own network, so it cannot serve.
|
|
405
|
+
const listen = input.listen === true && this.sandboxed(sb);
|
|
406
|
+
if (listen && sb.kind === 'bwrap')
|
|
407
|
+
throw new ServiceError(
|
|
408
|
+
'listen_unreachable',
|
|
409
|
+
'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.',
|
|
410
|
+
);
|
|
411
|
+
if (sb.kind === 'bwrap' && sb.level !== 'full-access' && !sb.bwrap)
|
|
412
|
+
throw new ServiceError(
|
|
413
|
+
'sandbox_unavailable',
|
|
414
|
+
'This host has no usable bubblewrap, so it cannot run a sandboxed command; an operator can choose full access explicitly',
|
|
415
|
+
);
|
|
416
|
+
if (sb.kind === 'none' && sb.level !== 'full-access' && !sb.allowUnsandboxed)
|
|
417
|
+
throw new ServiceError('sandbox_unavailable', UNSANDBOXED_REFUSAL);
|
|
167
418
|
args = {
|
|
168
419
|
action: 'command',
|
|
169
420
|
command: string(input.command, 'command', 8192),
|
|
170
|
-
timeout_ms: Number(timeout),
|
|
421
|
+
timeout_ms: background ? BACKGROUND_LIFETIME : Number(timeout),
|
|
422
|
+
...(background ? { background: true as const } : {}),
|
|
423
|
+
...(listen ? { listen: true as const } : {}),
|
|
424
|
+
...(outside !== undefined ? { outside_sandbox: outside } : {}),
|
|
425
|
+
// Bound into the approval and the operation digest: the sandbox is part of what is approved.
|
|
426
|
+
sandbox: {
|
|
427
|
+
kind: sb.kind,
|
|
428
|
+
level: sb.level,
|
|
429
|
+
bwrap: sb.bwrap?.version ?? null,
|
|
430
|
+
...(this.brokerNetwork(sb) ? { network: 'broker' as const } : {}),
|
|
431
|
+
},
|
|
171
432
|
};
|
|
172
433
|
} else throw new ServiceError('invalid_field', 'Choose edit or command');
|
|
173
434
|
c.args = JSON.stringify(args);
|
|
435
|
+
// Operator ruling 2026-10-31: full access asks only for commands this check calls dangerous.
|
|
436
|
+
const rulesDanger: DangerVerdict =
|
|
437
|
+
args.action === 'command'
|
|
438
|
+
? classifyCommand(args.command, this.dangerContext(root))
|
|
439
|
+
: { dangerous: false };
|
|
440
|
+
// Phase 4c: the reviewer can only add a reason to ask, never remove one.
|
|
441
|
+
const escalated = !rulesDanger.dangerous && args.action === 'command' && !!review?.escalate;
|
|
442
|
+
const danger: DangerVerdict = escalated
|
|
443
|
+
? {
|
|
444
|
+
dangerous: true,
|
|
445
|
+
category: 'classifier',
|
|
446
|
+
reason: `${review!.reason} (reviewed by ${review!.model})`,
|
|
447
|
+
segment: (args as { command: string }).command,
|
|
448
|
+
}
|
|
449
|
+
: rulesDanger;
|
|
450
|
+
// Pushing and opening a pull request ask in every mode but YOLO (operator ruling 2026-10-31).
|
|
451
|
+
const publishes = args.action === 'command' ? publishesToRemote(args.command) : null;
|
|
174
452
|
this.store.transaction(() => {
|
|
175
453
|
const reviewed =
|
|
176
454
|
args.action === 'edit'
|
|
177
|
-
? args.edits
|
|
178
|
-
|
|
179
|
-
.join('\n\n')
|
|
180
|
-
: `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}`;
|
|
455
|
+
? reviewLocalEdits(args.edits)
|
|
456
|
+
: `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
457
|
const permission = this.managed.prepareWorkspaceInTransaction(
|
|
182
458
|
session,
|
|
183
459
|
epoch,
|
|
@@ -210,14 +486,100 @@ export class LocalCoding {
|
|
|
210
486
|
action: kind,
|
|
211
487
|
});
|
|
212
488
|
});
|
|
489
|
+
if (review)
|
|
490
|
+
this.store.transaction(() =>
|
|
491
|
+
this.store.event(session, 'permission.classifier', {
|
|
492
|
+
call_id: c.id,
|
|
493
|
+
operation_id: c.operation_id,
|
|
494
|
+
model: review.model,
|
|
495
|
+
verdict: review.verdict,
|
|
496
|
+
escalated,
|
|
497
|
+
...(review.reason ? { reason: review.reason } : {}),
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
213
500
|
const policy = permissionPolicy(this.store, session);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
args.
|
|
219
|
-
|
|
220
|
-
|
|
501
|
+
// Desk-session rules: a matching deny refuses, a matching allow approves and is recorded with the
|
|
502
|
+
// rule that decided it; anything else falls through to the scoped-edit policy and then asks.
|
|
503
|
+
if (this.rules && policy.mode !== 'inspect' && this.rules.applies(session)) {
|
|
504
|
+
const request: RuleRequest =
|
|
505
|
+
args.action === 'command'
|
|
506
|
+
? { tool: 'command', argv: commandArgv(args.command) }
|
|
507
|
+
: { tool: 'edit', paths: args.edits.map((e) => e.path) };
|
|
508
|
+
// A repo's allow rules cannot approve a dangerous or publishing command: only the person's own
|
|
509
|
+
// rules can.
|
|
510
|
+
const usable = (all: ReturnType<ApprovalRules['list']>) =>
|
|
511
|
+
danger.dangerous ||
|
|
512
|
+
publishes ||
|
|
513
|
+
(args.action === 'command' && (args.listen || args.outside_sandbox))
|
|
514
|
+
? all.filter((r) => !(r.source === 'repo' && r.effect === 'allow'))
|
|
515
|
+
: all;
|
|
516
|
+
const decided = evaluateRules(usable(this.rules.list(session)), request);
|
|
517
|
+
const approval = this.managed.forOperation(c.operation_id);
|
|
518
|
+
const rule = decided.rule && {
|
|
519
|
+
id: decided.rule.id,
|
|
520
|
+
source: decided.rule.source,
|
|
521
|
+
effect: decided.rule.effect,
|
|
522
|
+
tool: decided.rule.tool,
|
|
523
|
+
...(decided.rule.command ? { command: decided.rule.command } : {}),
|
|
524
|
+
...(decided.rule.paths ? { paths: decided.rule.paths } : {}),
|
|
525
|
+
};
|
|
526
|
+
if (decided.effect === 'deny' && rule) {
|
|
527
|
+
this.managed.decide(
|
|
528
|
+
approval.id,
|
|
529
|
+
'rule-deny:' + c.id,
|
|
530
|
+
approval.revision,
|
|
531
|
+
approval.operation_digest,
|
|
532
|
+
'deny',
|
|
533
|
+
);
|
|
534
|
+
this.store.transaction(() =>
|
|
535
|
+
this.store.event(session, 'permission.rule-denied', {
|
|
536
|
+
rule,
|
|
537
|
+
call_id: c.id,
|
|
538
|
+
operation_id: c.operation_id,
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
541
|
+
} else if (decided.effect === 'allow' && rule) {
|
|
542
|
+
const rules = this.rules;
|
|
543
|
+
this.managed.decide(
|
|
544
|
+
approval.id,
|
|
545
|
+
'rule:' + c.id,
|
|
546
|
+
approval.revision,
|
|
547
|
+
approval.operation_digest,
|
|
548
|
+
'approve',
|
|
549
|
+
{
|
|
550
|
+
receipt: {
|
|
551
|
+
policy_digest: permissionDigest(policy),
|
|
552
|
+
policy_revision: policy.revision,
|
|
553
|
+
rule,
|
|
554
|
+
root,
|
|
555
|
+
call_id: c.id,
|
|
556
|
+
},
|
|
557
|
+
decidedBy: 'rule:' + rule.source,
|
|
558
|
+
authorize: () => {
|
|
559
|
+
this.guard(c);
|
|
560
|
+
const again = evaluateRules(usable(rules.list(session)), request);
|
|
561
|
+
if (again.effect !== 'allow' || again.rule?.id !== rule.id)
|
|
562
|
+
throw new ServiceError('permission_changed', 'Approval rules changed');
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// The session's mode approves by itself only what it covers (Phase 2b): scoped or ordinary
|
|
569
|
+
// project edits, and, in auto, commands that stay inside the OS sandbox. Re-checked at decision.
|
|
570
|
+
const covered = (p: typeof policy) =>
|
|
571
|
+
args.action === 'edit'
|
|
572
|
+
? autoApprovesEdits(
|
|
573
|
+
p,
|
|
574
|
+
args.edits.map((e) => e.path),
|
|
575
|
+
)
|
|
576
|
+
: autoApprovesCommands(
|
|
577
|
+
p,
|
|
578
|
+
this.sandboxed(this.bound(args)),
|
|
579
|
+
danger.dangerous,
|
|
580
|
+
publishes !== null || args.listen === true || !!args.outside_sandbox,
|
|
581
|
+
) && !(escalated && p.access !== 'yolo');
|
|
582
|
+
if (this.managed.forOperation(c.operation_id).state === 'pending' && covered(policy)) {
|
|
221
583
|
const approval = this.managed.forOperation(c.operation_id);
|
|
222
584
|
this.managed.decide(
|
|
223
585
|
approval.id,
|
|
@@ -235,19 +597,27 @@ export class LocalCoding {
|
|
|
235
597
|
authorize: () => {
|
|
236
598
|
this.guard(c);
|
|
237
599
|
const fresh = permissionPolicy(this.store, session);
|
|
238
|
-
if (
|
|
239
|
-
permissionDigest(fresh) !== permissionDigest(policy) ||
|
|
240
|
-
args.action !== 'edit' ||
|
|
241
|
-
!matchesEditScope(
|
|
242
|
-
fresh,
|
|
243
|
-
args.edits.map((e) => e.path),
|
|
244
|
-
)
|
|
245
|
-
)
|
|
600
|
+
if (permissionDigest(fresh) !== permissionDigest(policy) || !covered(fresh))
|
|
246
601
|
throw new ServiceError('permission_changed', 'Scoped edit policy changed');
|
|
247
|
-
verifyLocalEdits(root, args.edits);
|
|
602
|
+
if (args.action === 'edit') verifyLocalEdits(root, args.edits);
|
|
248
603
|
},
|
|
249
604
|
},
|
|
250
605
|
);
|
|
606
|
+
// YOLO approves everything; each approval is recorded with what full access would have asked.
|
|
607
|
+
if (policy.access === 'yolo')
|
|
608
|
+
this.store.transaction(() =>
|
|
609
|
+
this.store.event(session, 'permission.yolo-approved', {
|
|
610
|
+
call_id: c.id,
|
|
611
|
+
operation_id: c.operation_id,
|
|
612
|
+
action: args.action,
|
|
613
|
+
...(args.action === 'command' ? { command: args.command } : {}),
|
|
614
|
+
...(danger.dangerous
|
|
615
|
+
? { would_have_asked: { category: danger.category, reason: danger.reason } }
|
|
616
|
+
: publishes
|
|
617
|
+
? { would_have_asked: { category: 'publish', reason: publishes } }
|
|
618
|
+
: {}),
|
|
619
|
+
}),
|
|
620
|
+
);
|
|
251
621
|
}
|
|
252
622
|
return this.view(c.id);
|
|
253
623
|
}
|
|
@@ -287,16 +657,19 @@ export class LocalCoding {
|
|
|
287
657
|
try {
|
|
288
658
|
verifyLocalEdits(c.root, args.edits as LocalEdit[]);
|
|
289
659
|
entered = true;
|
|
290
|
-
|
|
660
|
+
const progress = (path: string) => {
|
|
291
661
|
applied.push(path);
|
|
292
662
|
this.store.transaction(() => this.change(c, 'running', { applied }));
|
|
293
|
-
}
|
|
663
|
+
};
|
|
664
|
+
if (this.tools.mode === 'tools-user')
|
|
665
|
+
applyEditsAsTools(this.tools, c.root, args.edits as LocalEdit[], progress);
|
|
666
|
+
else applyLocalEdits(c.root, args.edits as LocalEdit[], progress);
|
|
294
667
|
verifyLocalEdits(
|
|
295
668
|
c.root,
|
|
296
669
|
(args.edits as LocalEdit[]).map((edit) => ({ ...edit, before: edit.after })),
|
|
297
670
|
);
|
|
298
671
|
this.store.transaction(() => {
|
|
299
|
-
const result = { applied, verified: true };
|
|
672
|
+
const result = { applied, verified: true, tool_uid: this.tools.uid };
|
|
300
673
|
this.change(c, 'succeeded', result);
|
|
301
674
|
this.managed.workspaceResultInTransaction(c.operation_id, result);
|
|
302
675
|
});
|
|
@@ -313,20 +686,240 @@ export class LocalCoding {
|
|
|
313
686
|
else this.managed.workspaceDefiniteFailureInTransaction(c.operation_id);
|
|
314
687
|
});
|
|
315
688
|
}
|
|
316
|
-
} else this.
|
|
689
|
+
} else if (args.background === true) this.background(c, String(args.command), this.bound(args));
|
|
690
|
+
else this.command(c, String(args.command), Number(args.timeout_ms), this.bound(args));
|
|
317
691
|
return this.view(id);
|
|
318
692
|
}
|
|
319
|
-
|
|
693
|
+
/** The sandbox for a new command in this session: the host's, or full access if the person chose
|
|
694
|
+
* it for this session (Phase 2b). Bound into the command's approval and digest. */
|
|
695
|
+
private sandboxFor(session: string): LocalSandbox {
|
|
696
|
+
return permissionPolicy(this.store, session).access
|
|
697
|
+
? { ...this.sandbox, level: 'full-access' }
|
|
698
|
+
: this.sandbox;
|
|
699
|
+
}
|
|
700
|
+
/** The sandbox a prepared command was approved under. */
|
|
701
|
+
private bound(args: { sandbox?: { level?: string } }): LocalSandbox {
|
|
702
|
+
return args.sandbox?.level === 'full-access'
|
|
703
|
+
? { ...this.sandbox, level: 'full-access' }
|
|
704
|
+
: this.sandbox;
|
|
705
|
+
}
|
|
706
|
+
private sandboxed(sb: LocalSandbox): boolean {
|
|
707
|
+
return sb.kind !== 'none' && sb.level !== 'full-access';
|
|
708
|
+
}
|
|
709
|
+
/** Where this session's next command runs, in a word or two for the status and side panels. */
|
|
710
|
+
commandSandbox(session: string): string {
|
|
711
|
+
const access = permissionPolicy(this.store, session).access;
|
|
712
|
+
if (access) return access === 'yolo' ? 'YOLO' : 'full access';
|
|
713
|
+
if (this.sandbox.level === 'full-access') return 'no sandbox';
|
|
714
|
+
if (this.sandbox.kind === 'none')
|
|
715
|
+
return this.sandbox.allowUnsandboxed ? 'no sandbox' : 'refused';
|
|
716
|
+
return this.sandbox.kind === 'seatbelt' ? 'Seatbelt' : 'bubblewrap';
|
|
717
|
+
}
|
|
718
|
+
private sandboxText(sb: LocalSandbox = this.sandbox, outside = false): string {
|
|
719
|
+
const { kind, level } = sb;
|
|
720
|
+
if (outside)
|
|
721
|
+
return 'unsandboxed for this one command, at its own request: it can read and change any file your user can and reach the network.';
|
|
722
|
+
if (level === 'full-access')
|
|
723
|
+
return this.sandbox.level === 'full-access'
|
|
724
|
+
? 'unsandboxed: the operator chose full access. May access files and network beyond this workspace.'
|
|
725
|
+
: 'unsandboxed: you turned on full access for this session. May access files and network beyond this workspace.';
|
|
726
|
+
if (kind === 'none')
|
|
727
|
+
return 'unsandboxed: this host has no usable command sandbox, so the command may access files and network beyond this workspace.';
|
|
728
|
+
const name = kind === 'seatbelt' ? 'Seatbelt' : 'bubblewrap';
|
|
729
|
+
const net = this.brokerNetwork(sb)
|
|
730
|
+
? 'network: through the broker allowlist (hosts set by the stk policy), nothing direct.'
|
|
731
|
+
: 'has no network.';
|
|
732
|
+
const reads = this.sandbox.readPaths?.length
|
|
733
|
+
? ` It can also read ${this.sandbox.readPaths.length} operator-allowed path${this.sandbox.readPaths.length === 1 ? '' : 's'} outside the workspace.`
|
|
734
|
+
: '';
|
|
735
|
+
return level === 'read-only'
|
|
736
|
+
? `in a ${name} sandbox (read-only): it can read this workspace but write nothing, and ${net}${reads}`
|
|
737
|
+
: `in a ${name} sandbox (workspace-write): it can write only inside this workspace, and ${net}${reads}`;
|
|
738
|
+
}
|
|
739
|
+
/** Broker network applies only to a sandboxed command: unsandboxed ones are not confined anyway. */
|
|
740
|
+
private brokerNetwork(sb: LocalSandbox = this.sandbox): { socket: string } | null {
|
|
741
|
+
return sb.network && this.sandboxed(sb) ? sb.network : null;
|
|
742
|
+
}
|
|
743
|
+
/** Run a hook (Phase 4b) as one of this session's sandboxed commands would run: the same sandbox
|
|
744
|
+
* and level, no network, a minimal environment, `input` on stdin, bounded output and a timeout.
|
|
745
|
+
* Never as the tools user, whose process accounting is for approved operations only. */
|
|
746
|
+
runHook(session: string, command: string, input: string, timeoutMs: number): Promise<HookRun> {
|
|
747
|
+
const refused = (why: string): Promise<HookRun> =>
|
|
748
|
+
Promise.resolve({
|
|
749
|
+
code: null,
|
|
750
|
+
stdout: '',
|
|
751
|
+
stderr: '',
|
|
752
|
+
timedOut: false,
|
|
753
|
+
truncated: false,
|
|
754
|
+
refused: why,
|
|
755
|
+
});
|
|
756
|
+
if (this.tools.mode === 'tools-user')
|
|
757
|
+
return refused('Hooks do not run when commands run as a separate tools user');
|
|
758
|
+
const sb = this.sandboxFor(session);
|
|
759
|
+
if (sb.kind === 'bwrap' && sb.level !== 'full-access' && !sb.bwrap)
|
|
760
|
+
return refused('This host has no usable bubblewrap, so it cannot run a sandboxed hook');
|
|
761
|
+
if (sb.kind === 'none' && sb.level !== 'full-access' && !sb.allowUnsandboxed)
|
|
762
|
+
return refused(UNSANDBOXED_REFUSAL);
|
|
763
|
+
const root = this.store.session(session).cwd;
|
|
764
|
+
const tmp =
|
|
765
|
+
sb.kind === 'seatbelt' && sb.level !== 'full-access'
|
|
766
|
+
? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-hook-')))
|
|
767
|
+
: undefined;
|
|
768
|
+
const argv = this.commandArgv(root, command, tmp, null, sb);
|
|
769
|
+
const cap = 16 * 1024;
|
|
770
|
+
return new Promise<HookRun>((resolve) => {
|
|
771
|
+
let stdout = '',
|
|
772
|
+
stderr = '',
|
|
773
|
+
truncated = false,
|
|
774
|
+
timedOut = false;
|
|
775
|
+
const child = spawn(argv[0]!, argv.slice(1), {
|
|
776
|
+
cwd: root,
|
|
777
|
+
detached: true,
|
|
778
|
+
env: {
|
|
779
|
+
PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
|
|
780
|
+
HOME: process.env.HOME ?? root,
|
|
781
|
+
LANG: 'en_US.UTF-8',
|
|
782
|
+
TMPDIR: '/tmp',
|
|
783
|
+
SALAAM_PROJECT_DIR: root,
|
|
784
|
+
CLAUDE_PROJECT_DIR: root,
|
|
785
|
+
},
|
|
786
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
787
|
+
});
|
|
788
|
+
const take = (current: string, chunk: Buffer) => {
|
|
789
|
+
const room = cap - Buffer.byteLength(current);
|
|
790
|
+
if (chunk.length > room) truncated = true;
|
|
791
|
+
return room > 0 ? current + chunk.subarray(0, room).toString('utf8') : current;
|
|
792
|
+
};
|
|
793
|
+
child.stdout.on('data', (c: Buffer) => (stdout = take(stdout, c)));
|
|
794
|
+
child.stderr.on('data', (c: Buffer) => (stderr = take(stderr, c)));
|
|
795
|
+
child.stdin.on('error', () => {});
|
|
796
|
+
child.stdin.end(input);
|
|
797
|
+
const timer = setTimeout(() => {
|
|
798
|
+
timedOut = true;
|
|
799
|
+
try {
|
|
800
|
+
process.kill(-child.pid!, 'SIGKILL');
|
|
801
|
+
} catch {
|
|
802
|
+
/* already gone */
|
|
803
|
+
}
|
|
804
|
+
}, timeoutMs);
|
|
805
|
+
const finish = (code: number | null) => {
|
|
806
|
+
clearTimeout(timer);
|
|
807
|
+
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
|
808
|
+
resolve({ code: timedOut ? null : code, stdout, stderr, timedOut, truncated });
|
|
809
|
+
};
|
|
810
|
+
child.once('error', (error) => {
|
|
811
|
+
stderr = take(stderr, Buffer.from(error.message));
|
|
812
|
+
finish(null);
|
|
813
|
+
});
|
|
814
|
+
child.once('close', (code) => finish(code));
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
/** The process a command runs as. Under the tools user, the host-side shell reports its own
|
|
818
|
+
* process group (the one a stop kills) before exec-ing bubblewrap, whose PID namespace would
|
|
819
|
+
* otherwise report a number that means nothing on the host. */
|
|
820
|
+
private commandArgv(
|
|
821
|
+
root: string,
|
|
822
|
+
script: string,
|
|
823
|
+
tmp?: string,
|
|
824
|
+
relay?: BrokerRelay | null,
|
|
825
|
+
sb: LocalSandbox = this.sandbox,
|
|
826
|
+
listen = false,
|
|
827
|
+
): string[] {
|
|
828
|
+
const sandboxed = this.sandboxed(sb);
|
|
829
|
+
const inner = (s: string) =>
|
|
830
|
+
!sandboxed
|
|
831
|
+
? ['/bin/sh', '-c', s]
|
|
832
|
+
: sb.kind === 'seatbelt'
|
|
833
|
+
? seatbeltArgv(sb.level, root, tmp ?? '', ['/bin/sh', '-c', s], sb.seatbelt ?? null, {
|
|
834
|
+
network: false,
|
|
835
|
+
...(relay?.port ? { brokerPort: relay.port } : {}),
|
|
836
|
+
readPaths: sb.readPaths ?? [],
|
|
837
|
+
...(listen ? { listen: true } : {}),
|
|
838
|
+
})
|
|
839
|
+
: sandboxArgv(sb.level, root, ['/bin/sh', '-c', s], sb.bwrap, {
|
|
840
|
+
network: false,
|
|
841
|
+
...(relay?.dir ? { broker: { dir: relay.dir, node: process.execPath } } : {}),
|
|
842
|
+
readPaths: sb.readPaths ?? [],
|
|
843
|
+
});
|
|
844
|
+
if (this.tools.mode !== 'tools-user') return inner(script);
|
|
845
|
+
const marker = 'umask 0002\nprintf \'salaam-tool-pgid:%s\\n\' "$$"\n';
|
|
846
|
+
return sandboxed
|
|
847
|
+
? toolArgv(this.tools, [
|
|
848
|
+
'/usr/bin/setsid',
|
|
849
|
+
'-w',
|
|
850
|
+
'/bin/sh',
|
|
851
|
+
'-c',
|
|
852
|
+
marker + 'exec "$@"',
|
|
853
|
+
'sh',
|
|
854
|
+
...inner(script),
|
|
855
|
+
])
|
|
856
|
+
: toolArgv(this.tools, ['/usr/bin/setsid', '-w', '/bin/sh', '-c', marker + script]);
|
|
857
|
+
}
|
|
858
|
+
private command(c: LocalCall, command: string, timeout: number, sb: LocalSandbox): void {
|
|
859
|
+
const broker = this.brokerNetwork(sb);
|
|
860
|
+
// The relay is Salaam's, outside the sandbox: it forwards to stk and counts what was reached.
|
|
861
|
+
const relayStarting = broker
|
|
862
|
+
? BrokerRelay.start(broker.socket, sb.kind === 'seatbelt' ? 'tcp' : 'unix')
|
|
863
|
+
: Promise.resolve(null);
|
|
864
|
+
// A stop that arrives while the relay starts still cancels the command once it is spawned.
|
|
865
|
+
let early = false;
|
|
866
|
+
let stop = () => {
|
|
867
|
+
early = true;
|
|
868
|
+
};
|
|
869
|
+
// Seatbelt has no tmpfs: each command gets its own private tmp, removed when it ends.
|
|
870
|
+
const tmp =
|
|
871
|
+
sb.kind === 'seatbelt' && sb.level !== 'full-access'
|
|
872
|
+
? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-cmd-')))
|
|
873
|
+
: undefined;
|
|
874
|
+
const set = (s: () => void) => {
|
|
875
|
+
stop = s;
|
|
876
|
+
if (early) s();
|
|
877
|
+
};
|
|
878
|
+
const done = relayStarting.then(
|
|
879
|
+
(relay) => this.runCommand(c, command, timeout, tmp, relay, set, false, sb),
|
|
880
|
+
() => this.runCommand(c, command, timeout, tmp, null, set, !!broker, sb),
|
|
881
|
+
);
|
|
882
|
+
this.jobs.set(c.id, { stop: () => stop(), done });
|
|
883
|
+
if (tmp) void done.finally(() => rmSync(tmp, { recursive: true, force: true }));
|
|
884
|
+
}
|
|
885
|
+
private runCommand(
|
|
886
|
+
c: LocalCall,
|
|
887
|
+
command: string,
|
|
888
|
+
timeout: number,
|
|
889
|
+
tmp: string | undefined,
|
|
890
|
+
relay: BrokerRelay | null,
|
|
891
|
+
setStop: (stop: () => void) => void,
|
|
892
|
+
relayFailed = false,
|
|
893
|
+
sb: LocalSandbox = this.sandbox,
|
|
894
|
+
): Promise<void> {
|
|
320
895
|
let stop = () => {};
|
|
321
|
-
|
|
896
|
+
return new Promise<void>((resolve) => {
|
|
322
897
|
let output = '',
|
|
323
898
|
bytes = 0,
|
|
324
899
|
reason: string | null = null,
|
|
325
900
|
finished = false;
|
|
326
901
|
// Deliberately do not inherit the daemon's provider credentials or service tokens.
|
|
327
902
|
// Hold the shell before any user instruction until its identity is durably owned.
|
|
903
|
+
const tools = this.tools.mode === 'tools-user';
|
|
328
904
|
const script = 'IFS= read -r salaam_start_gate || exit 125\nexec 0</dev/null\n' + command;
|
|
329
|
-
|
|
905
|
+
// As the tools user, setsid makes the shell its own group leader so it can be killed by group.
|
|
906
|
+
const argv = this.commandArgv(
|
|
907
|
+
c.root,
|
|
908
|
+
script,
|
|
909
|
+
tmp,
|
|
910
|
+
relay,
|
|
911
|
+
sb,
|
|
912
|
+
JSON.parse(c.args).listen === true,
|
|
913
|
+
);
|
|
914
|
+
let toolGroup: number | null = null,
|
|
915
|
+
marker = tools ? '' : null,
|
|
916
|
+
spawned = false;
|
|
917
|
+
// Release the gate only once the process is owned and, as the tools user, its group is
|
|
918
|
+
// known: otherwise a kill before the marker could miss a command already running.
|
|
919
|
+
const release = () => {
|
|
920
|
+
if (spawned && (!tools || toolGroup) && !reason && !finished) child.stdin.end('start\n');
|
|
921
|
+
};
|
|
922
|
+
const child = spawn(argv[0]!, argv.slice(1), {
|
|
330
923
|
cwd: c.root,
|
|
331
924
|
detached: true,
|
|
332
925
|
env: {
|
|
@@ -334,11 +927,15 @@ export class LocalCoding {
|
|
|
334
927
|
HOME: process.env.HOME ?? c.root,
|
|
335
928
|
LANG: 'en_US.UTF-8',
|
|
336
929
|
TMPDIR: '/tmp',
|
|
930
|
+
...(this.sandboxed(sb) ? gitIdentityEnv(c.root) : {}),
|
|
931
|
+
...(relay ? proxyEnv(relay.port ?? COMMAND_NETWORK_PORT) : {}),
|
|
337
932
|
},
|
|
338
933
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
339
934
|
});
|
|
340
935
|
let reap: NodeJS.Timeout | undefined;
|
|
341
936
|
const kill = () => {
|
|
937
|
+
if (toolGroup) killToolGroup(this.tools, toolGroup);
|
|
938
|
+
if (tools) child.stdin.destroy();
|
|
342
939
|
if (child.pid)
|
|
343
940
|
try {
|
|
344
941
|
process.kill(-child.pid, 'SIGKILL');
|
|
@@ -347,17 +944,24 @@ export class LocalCoding {
|
|
|
347
944
|
}
|
|
348
945
|
if (!finished && !reap)
|
|
349
946
|
reap = setTimeout(() => {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
947
|
+
if (toolGroup) killToolGroup(this.tools, toolGroup);
|
|
948
|
+
// A busy event loop runs timers before pending I/O: let an already delivered close
|
|
949
|
+
// settle the outcome before declaring it unknown.
|
|
950
|
+
setImmediate(() => {
|
|
951
|
+
if (finished) return;
|
|
952
|
+
reason = 'outcome_unknown';
|
|
953
|
+
child.stdout.destroy();
|
|
954
|
+
child.stderr.destroy();
|
|
955
|
+
child.unref();
|
|
956
|
+
finish(null);
|
|
957
|
+
});
|
|
355
958
|
}, 1500);
|
|
356
959
|
};
|
|
357
960
|
stop = () => {
|
|
358
961
|
reason ??= 'cancelled';
|
|
359
962
|
kill();
|
|
360
963
|
};
|
|
964
|
+
setStop(stop);
|
|
361
965
|
const deadline = setTimeout(() => {
|
|
362
966
|
reason = 'timed_out';
|
|
363
967
|
kill();
|
|
@@ -382,7 +986,35 @@ export class LocalCoding {
|
|
|
382
986
|
reason ??= 'outcome_unknown';
|
|
383
987
|
kill();
|
|
384
988
|
});
|
|
385
|
-
child.stdout.on('data',
|
|
989
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
990
|
+
if (marker === null) return collect(chunk);
|
|
991
|
+
marker += chunk.toString('latin1');
|
|
992
|
+
const end = marker.indexOf('\n');
|
|
993
|
+
if (end < 0) {
|
|
994
|
+
if (marker.length > 64) {
|
|
995
|
+
reason = 'outcome_unknown';
|
|
996
|
+
kill();
|
|
997
|
+
}
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const found = /^salaam-tool-pgid:(\d+)$/.exec(marker.slice(0, end));
|
|
1001
|
+
const rest = Buffer.from(marker.slice(end + 1), 'latin1');
|
|
1002
|
+
marker = null;
|
|
1003
|
+
if (!found) {
|
|
1004
|
+
reason = 'outcome_unknown';
|
|
1005
|
+
kill();
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
toolGroup = Number(found[1]);
|
|
1009
|
+
try {
|
|
1010
|
+
this.store.transaction(() => this.change(c, 'running', { tool_pgid: toolGroup }));
|
|
1011
|
+
} catch {
|
|
1012
|
+
reason = 'outcome_unknown';
|
|
1013
|
+
kill();
|
|
1014
|
+
}
|
|
1015
|
+
if (rest.length) collect(rest);
|
|
1016
|
+
release();
|
|
1017
|
+
});
|
|
386
1018
|
child.stderr.on('data', collect);
|
|
387
1019
|
child.once('spawn', () => {
|
|
388
1020
|
try {
|
|
@@ -399,7 +1031,8 @@ export class LocalCoding {
|
|
|
399
1031
|
stop();
|
|
400
1032
|
return;
|
|
401
1033
|
}
|
|
402
|
-
|
|
1034
|
+
spawned = true;
|
|
1035
|
+
release();
|
|
403
1036
|
} catch {
|
|
404
1037
|
reason = 'outcome_unknown';
|
|
405
1038
|
kill();
|
|
@@ -414,12 +1047,30 @@ export class LocalCoding {
|
|
|
414
1047
|
kill();
|
|
415
1048
|
this.jobs.delete(c.id);
|
|
416
1049
|
const state = reason ?? (error ? 'failed' : code === 0 ? 'succeeded' : 'failed');
|
|
1050
|
+
const network = relay
|
|
1051
|
+
? relay.summary()
|
|
1052
|
+
: relayFailed
|
|
1053
|
+
? { broker: 'unavailable' as const, connections: [] }
|
|
1054
|
+
: null;
|
|
1055
|
+
void relay?.close();
|
|
1056
|
+
// What the broker refused, in its own words, so the reason reaches the card.
|
|
1057
|
+
const refusals = (network?.connections ?? [])
|
|
1058
|
+
.filter((n) => n.refused && n.reason)
|
|
1059
|
+
.map((n) => `[network] refused ${n.target}: ${n.reason}`)
|
|
1060
|
+
.join('\n');
|
|
417
1061
|
const result = {
|
|
418
1062
|
exit_code: code,
|
|
419
|
-
output
|
|
1063
|
+
output: refusals
|
|
1064
|
+
? output + (output.endsWith('\n') || !output ? '' : '\n') + refusals + '\n'
|
|
1065
|
+
: output,
|
|
420
1066
|
state,
|
|
421
1067
|
error: error ? 'Command could not start' : null,
|
|
422
|
-
isolation:
|
|
1068
|
+
isolation:
|
|
1069
|
+
(tools ? 'tools-user-' : 'local-user-') +
|
|
1070
|
+
(this.sandboxed(sb) ? sb.kind + '-' + sb.level : 'no-os-sandbox'),
|
|
1071
|
+
sandbox: this.sandboxed(sb) ? sb.kind : 'none',
|
|
1072
|
+
tool_uid: this.tools.uid,
|
|
1073
|
+
...(network ? { network } : {}),
|
|
423
1074
|
};
|
|
424
1075
|
this.store.transaction(() => {
|
|
425
1076
|
this.change(c, state, result);
|
|
@@ -437,13 +1088,213 @@ export class LocalCoding {
|
|
|
437
1088
|
child.once('error', (e) => finish(null, e));
|
|
438
1089
|
child.once('close', (code) => finish(code));
|
|
439
1090
|
});
|
|
440
|
-
|
|
1091
|
+
}
|
|
1092
|
+
private assertBackground(session: string): void {
|
|
1093
|
+
if (!this.interactive(session))
|
|
1094
|
+
throw new ServiceError(
|
|
1095
|
+
'background_denied',
|
|
1096
|
+
'Background commands are only for an interactive local coding session',
|
|
1097
|
+
);
|
|
1098
|
+
if (this.tools.mode === 'tools-user')
|
|
1099
|
+
throw new ServiceError(
|
|
1100
|
+
'background_denied',
|
|
1101
|
+
'Background commands do not yet run as a separate tools user',
|
|
1102
|
+
);
|
|
1103
|
+
if ([...this.live.values()].filter((t) => t.session === session).length >= TASK_LIMIT)
|
|
1104
|
+
throw new ServiceError(
|
|
1105
|
+
'background_limit',
|
|
1106
|
+
`A session runs at most ${TASK_LIMIT} background commands; stop one first`,
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
/** Launch an approved background command. Its operation settles once the process is owned;
|
|
1110
|
+
* the process then lives as a session task until it exits, is stopped, or the session ends. */
|
|
1111
|
+
private background(c: LocalCall, command: string, sb: LocalSandbox): void {
|
|
1112
|
+
const settle = (ok: boolean, result: Record<string, unknown>) =>
|
|
1113
|
+
this.store.transaction(() => {
|
|
1114
|
+
this.change(c, ok ? 'succeeded' : 'failed', result);
|
|
1115
|
+
this.managed.localExecutionResultInTransaction(c.operation_id, ok, result);
|
|
1116
|
+
});
|
|
1117
|
+
try {
|
|
1118
|
+
this.assertBackground(c.session_id);
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
settle(false, { error: (error as Error).message, background: true });
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
const id = 'task_' + randomUUID().slice(0, 8);
|
|
1124
|
+
const tmp =
|
|
1125
|
+
sb.kind === 'seatbelt' && sb.level !== 'full-access'
|
|
1126
|
+
? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-cmd-')))
|
|
1127
|
+
: undefined;
|
|
1128
|
+
// Network for a long-lived task needs a relay that lives as long; keep it simple and honest.
|
|
1129
|
+
const script = 'IFS= read -r salaam_start_gate || exit 125\nexec 0</dev/null\n' + command;
|
|
1130
|
+
const argv = this.commandArgv(
|
|
1131
|
+
c.root,
|
|
1132
|
+
script,
|
|
1133
|
+
tmp,
|
|
1134
|
+
null,
|
|
1135
|
+
sb,
|
|
1136
|
+
JSON.parse(c.args).listen === true,
|
|
1137
|
+
);
|
|
1138
|
+
const child = spawn(argv[0]!, argv.slice(1), {
|
|
1139
|
+
cwd: c.root,
|
|
1140
|
+
detached: true,
|
|
1141
|
+
env: {
|
|
1142
|
+
PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
|
|
1143
|
+
HOME: process.env.HOME ?? c.root,
|
|
1144
|
+
LANG: 'en_US.UTF-8',
|
|
1145
|
+
TMPDIR: '/tmp',
|
|
1146
|
+
...(this.sandboxed(sb) ? gitIdentityEnv(c.root) : {}),
|
|
1147
|
+
},
|
|
1148
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1149
|
+
});
|
|
1150
|
+
let tail = Buffer.alloc(0),
|
|
1151
|
+
bytes = 0,
|
|
1152
|
+
ended: TaskState | null = null,
|
|
1153
|
+
resolve = () => {};
|
|
1154
|
+
const done = new Promise<void>((r) => (resolve = r));
|
|
1155
|
+
const kill = () => {
|
|
1156
|
+
if (child.pid)
|
|
1157
|
+
try {
|
|
1158
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
1159
|
+
} catch {
|
|
1160
|
+
/* already gone */
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
const task: BackgroundTask = {
|
|
1164
|
+
id,
|
|
1165
|
+
session: c.session_id,
|
|
1166
|
+
done,
|
|
1167
|
+
output: () => ({ output: tail.toString('utf8'), bytes, truncated: bytes > tail.length }),
|
|
1168
|
+
stop: (why) => {
|
|
1169
|
+
ended ??= why;
|
|
1170
|
+
kill();
|
|
1171
|
+
},
|
|
1172
|
+
};
|
|
1173
|
+
const collect = (chunk: Buffer) => {
|
|
1174
|
+
bytes += chunk.length;
|
|
1175
|
+
tail = Buffer.concat([tail, chunk]);
|
|
1176
|
+
if (tail.length > TASK_TAIL) tail = tail.subarray(tail.length - TASK_TAIL);
|
|
1177
|
+
};
|
|
1178
|
+
child.stdout.on('data', collect);
|
|
1179
|
+
child.stderr.on('data', collect);
|
|
1180
|
+
child.stdin.on('error', () => {});
|
|
1181
|
+
const lifetime = setTimeout(() => task.stop('timed_out'), BACKGROUND_LIFETIME);
|
|
1182
|
+
// The session, not the turn, owns the task: it ends when local coding or its root goes away.
|
|
1183
|
+
const watch = setInterval(() => {
|
|
1184
|
+
try {
|
|
1185
|
+
if (this.closed || !this.store.localCoding(c.session_id)) throw Error('closed');
|
|
1186
|
+
if (this.store.session(c.session_id).cwd !== c.root) throw Error('moved');
|
|
1187
|
+
} catch {
|
|
1188
|
+
task.stop('stopped');
|
|
1189
|
+
}
|
|
1190
|
+
}, 1000);
|
|
1191
|
+
const finish = (code: number | null) => {
|
|
1192
|
+
clearTimeout(lifetime);
|
|
1193
|
+
clearInterval(watch);
|
|
1194
|
+
kill();
|
|
1195
|
+
this.live.delete(id);
|
|
1196
|
+
this.kept.set(id, task.output());
|
|
1197
|
+
if (this.kept.size > 32) this.kept.delete(this.kept.keys().next().value!);
|
|
1198
|
+
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
|
1199
|
+
const row = this.store.db.prepare('SELECT * FROM local_tasks WHERE id=?').get(id) as
|
|
1200
|
+
TaskRow | undefined;
|
|
1201
|
+
if (row) this.endTask(row, ended ?? 'exited', code);
|
|
1202
|
+
else settle(false, { error: 'Command could not start', background: true });
|
|
1203
|
+
resolve();
|
|
1204
|
+
};
|
|
1205
|
+
child.once('error', () => finish(null));
|
|
1206
|
+
child.once('close', (code) => finish(code));
|
|
1207
|
+
child.once('spawn', () => {
|
|
1208
|
+
try {
|
|
1209
|
+
// The start time, not processIdentity: the gated shell execs into the command.
|
|
1210
|
+
const identity = processStart(child.pid!);
|
|
1211
|
+
if (!identity) throw Error('No process identity');
|
|
1212
|
+
this.store.transaction(() =>
|
|
1213
|
+
this.store.db
|
|
1214
|
+
.prepare(
|
|
1215
|
+
"INSERT INTO local_tasks(id,session_id,call_id,command,state,pid,identity,started_at) VALUES (?,?,?,?,'running',?,?,?)",
|
|
1216
|
+
)
|
|
1217
|
+
.run(id, c.session_id, c.id, command, child.pid!, identity, new Date().toISOString()),
|
|
1218
|
+
);
|
|
1219
|
+
this.live.set(id, task);
|
|
1220
|
+
settle(true, {
|
|
1221
|
+
background: true,
|
|
1222
|
+
task_id: id,
|
|
1223
|
+
state: 'running',
|
|
1224
|
+
isolation:
|
|
1225
|
+
'local-user-' + (this.sandboxed(sb) ? sb.kind + '-' + sb.level : 'no-os-sandbox'),
|
|
1226
|
+
sandbox: this.sandboxed(sb) ? sb.kind : 'none',
|
|
1227
|
+
note: 'Read its output with salaam_tasks; it keeps running after this turn.',
|
|
1228
|
+
});
|
|
1229
|
+
child.stdin.end('start\n');
|
|
1230
|
+
} catch {
|
|
1231
|
+
ended = 'lost';
|
|
1232
|
+
kill();
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
private endTask(t: TaskRow, state: TaskState, code: number | null): void {
|
|
1237
|
+
this.store.transaction(() => {
|
|
1238
|
+
this.store.db
|
|
1239
|
+
.prepare(
|
|
1240
|
+
"UPDATE local_tasks SET state=?,exit_code=?,ended_at=? WHERE id=? AND state='running'",
|
|
1241
|
+
)
|
|
1242
|
+
.run(state, code, new Date().toISOString(), t.id);
|
|
1243
|
+
this.store.event(t.session_id, 'local.task_finished', {
|
|
1244
|
+
task_id: t.id,
|
|
1245
|
+
command: t.command.slice(0, 200),
|
|
1246
|
+
state,
|
|
1247
|
+
exit_code: code,
|
|
1248
|
+
});
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
/** The session's background tasks, newest first. */
|
|
1252
|
+
tasks(session: string) {
|
|
1253
|
+
return (
|
|
1254
|
+
this.store.db
|
|
1255
|
+
.prepare(
|
|
1256
|
+
'SELECT * FROM local_tasks WHERE session_id=? ORDER BY started_at DESC, rowid DESC',
|
|
1257
|
+
)
|
|
1258
|
+
.all(session) as unknown as TaskRow[]
|
|
1259
|
+
).map((t) => ({
|
|
1260
|
+
id: t.id,
|
|
1261
|
+
command: t.command,
|
|
1262
|
+
state: t.state,
|
|
1263
|
+
exit_code: t.exit_code,
|
|
1264
|
+
pid: t.pid,
|
|
1265
|
+
started_at: t.started_at,
|
|
1266
|
+
ended_at: t.ended_at,
|
|
1267
|
+
bytes: this.live.get(t.id)?.output().bytes ?? null,
|
|
1268
|
+
}));
|
|
1269
|
+
}
|
|
1270
|
+
private task(session: string, id: string): TaskRow {
|
|
1271
|
+
const row = this.store.db
|
|
1272
|
+
.prepare('SELECT * FROM local_tasks WHERE id=? AND session_id=?')
|
|
1273
|
+
.get(id, session) as TaskRow | undefined;
|
|
1274
|
+
if (!row) throw new ServiceError('not_found', 'No such background task in this session', 404);
|
|
1275
|
+
return row;
|
|
1276
|
+
}
|
|
1277
|
+
/** The kept tail of a task's output. Output of a task this controller no longer owns is gone. */
|
|
1278
|
+
taskOutput(session: string, id: string) {
|
|
1279
|
+
const row = this.task(session, id);
|
|
1280
|
+
const live = this.live.get(id);
|
|
1281
|
+
return {
|
|
1282
|
+
task: { id: row.id, state: row.state, exit_code: row.exit_code },
|
|
1283
|
+
...(live?.output() ?? this.kept.get(id) ?? { output: '', bytes: 0, truncated: false }),
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
stopTask(session: string, id: string) {
|
|
1287
|
+
this.task(session, id);
|
|
1288
|
+
this.live.get(id)?.stop('stopped');
|
|
1289
|
+
return { stopping: this.live.has(id) };
|
|
441
1290
|
}
|
|
442
1291
|
async close(): Promise<void> {
|
|
443
1292
|
this.closed = true;
|
|
444
1293
|
const jobs = [...this.jobs.values()];
|
|
445
1294
|
for (const job of jobs) job.stop();
|
|
446
|
-
|
|
1295
|
+
const tasks = [...this.live.values()];
|
|
1296
|
+
for (const t of tasks) t.stop('stopped');
|
|
1297
|
+
await Promise.all([...jobs.map((j) => j.done), ...tasks.map((t) => t.done)]);
|
|
447
1298
|
this.unregister();
|
|
448
1299
|
}
|
|
449
1300
|
}
|