@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.
Files changed (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/src/acp-desk.js +140 -0
  3. package/dist/src/acp-outcome.js +23 -19
  4. package/dist/src/acp.js +258 -11
  5. package/dist/src/approval-rules.js +287 -0
  6. package/dist/src/attach.js +3 -1
  7. package/dist/src/autonomy.js +80 -9
  8. package/dist/src/cli-help.js +34 -4
  9. package/dist/src/cli.js +55 -3
  10. package/dist/src/command-danger.js +797 -0
  11. package/dist/src/command-sandbox.js +116 -6
  12. package/dist/src/custom-commands.js +159 -0
  13. package/dist/src/danger-classifier.js +286 -0
  14. package/dist/src/git-helpers.js +160 -0
  15. package/dist/src/hooks.js +351 -0
  16. package/dist/src/host.js +96 -4
  17. package/dist/src/house-extension.js +133 -7
  18. package/dist/src/local-coding.js +553 -58
  19. package/dist/src/local-edits.js +168 -6
  20. package/dist/src/mentions.js +277 -0
  21. package/dist/src/pi-sessions.js +5 -1
  22. package/dist/src/project-instructions.js +195 -0
  23. package/dist/src/protocol.js +4 -1
  24. package/dist/src/service.js +342 -5
  25. package/dist/src/session-pick.js +77 -0
  26. package/dist/src/shell-passthrough.js +39 -0
  27. package/dist/src/store.js +51 -4
  28. package/dist/src/supervisor.js +233 -22
  29. package/dist/src/task-context.js +8 -2
  30. package/dist/src/terminal-status.js +88 -0
  31. package/dist/src/tui-activity.js +99 -0
  32. package/dist/src/tui-classifier.js +25 -0
  33. package/dist/src/tui-client.js +186 -5
  34. package/dist/src/tui-commands.js +23 -5
  35. package/dist/src/tui-composer.js +113 -0
  36. package/dist/src/tui-keymap.js +116 -0
  37. package/dist/src/tui-mentions.js +38 -0
  38. package/dist/src/tui-modals.js +157 -34
  39. package/dist/src/tui-notify.js +62 -0
  40. package/dist/src/tui-review.js +55 -0
  41. package/dist/src/tui-turn-keys.js +52 -0
  42. package/dist/src/tui-view.js +243 -69
  43. package/dist/src/tui.js +875 -118
  44. package/dist/src/turn-checkpoints.js +221 -0
  45. package/dist/src/turn-rewind.js +168 -0
  46. package/dist/src/web-fetch.js +114 -0
  47. package/dist/src/web-guard.js +163 -0
  48. package/dist/src/web-search.js +302 -0
  49. package/npm-shrinkwrap.json +2 -2
  50. package/package.json +1 -1
  51. package/src/acp-desk.ts +155 -0
  52. package/src/acp-outcome.ts +25 -21
  53. package/src/acp.ts +338 -28
  54. package/src/approval-rules.ts +360 -0
  55. package/src/attach.ts +4 -1
  56. package/src/autonomy.ts +111 -10
  57. package/src/cli-help.ts +33 -4
  58. package/src/cli.ts +66 -2
  59. package/src/command-danger.ts +886 -0
  60. package/src/command-sandbox.ts +128 -8
  61. package/src/custom-commands.ts +175 -0
  62. package/src/danger-classifier.ts +321 -0
  63. package/src/git-helpers.ts +177 -0
  64. package/src/hooks.ts +425 -0
  65. package/src/host.ts +122 -1
  66. package/src/house-extension.ts +142 -7
  67. package/src/local-coding.ts +674 -75
  68. package/src/local-edits.ts +191 -8
  69. package/src/mentions.ts +304 -0
  70. package/src/pi-sessions.ts +9 -1
  71. package/src/project-instructions.ts +229 -0
  72. package/src/protocol.ts +19 -2
  73. package/src/service.ts +488 -5
  74. package/src/session-pick.ts +102 -0
  75. package/src/shell-passthrough.ts +38 -0
  76. package/src/store.ts +52 -3
  77. package/src/supervisor.ts +262 -24
  78. package/src/task-context.ts +9 -2
  79. package/src/terminal-status.ts +103 -0
  80. package/src/tui-activity.ts +133 -0
  81. package/src/tui-classifier.ts +38 -0
  82. package/src/tui-client.ts +239 -8
  83. package/src/tui-commands.ts +49 -10
  84. package/src/tui-composer.ts +124 -0
  85. package/src/tui-keymap.ts +152 -0
  86. package/src/tui-mentions.ts +43 -0
  87. package/src/tui-modals.ts +173 -43
  88. package/src/tui-notify.ts +77 -0
  89. package/src/tui-review.ts +76 -0
  90. package/src/tui-turn-keys.ts +77 -0
  91. package/src/tui-view.ts +341 -88
  92. package/src/tui.ts +1047 -137
  93. package/src/turn-checkpoints.ts +253 -0
  94. package/src/turn-rewind.ts +258 -0
  95. package/src/web-fetch.ts +140 -0
  96. package/src/web-guard.ts +195 -0
  97. package/src/web-search.ts +402 -0
@@ -11,7 +11,11 @@ import {
11
11
  import { spawn } from 'node:child_process';
12
12
  import { randomUUID } from 'node:crypto';
13
13
  import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
14
- import { tmpdir } from 'node:os';
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';
15
19
  import { join } from 'node:path';
16
20
  import type { Store } from './store.js';
17
21
  import type { ManagedWork } from './managed.js';
@@ -20,16 +24,49 @@ import {
20
24
  prepareLocalEdits,
21
25
  verifyLocalEdits,
22
26
  applyLocalEdits,
27
+ reviewLocalEdits,
23
28
  type LocalEdit,
24
29
  } from './local-edits.js';
25
30
  import {
26
31
  permissionPolicy,
32
+ autoApprovesEdits,
33
+ autoApprovesCommands,
27
34
  permissionDigest,
28
- matchesEditScope,
29
35
  assertPermissionBinding,
30
36
  } from './autonomy.js';
37
+ import {
38
+ commandArgv,
39
+ evaluateRules,
40
+ type ApprovalRules,
41
+ type RuleRequest,
42
+ } from './approval-rules.js';
31
43
  import { assertUndoSettled } from './local-undo.js';
32
- import { processIdentity } from './supervisor.js';
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
+ }
33
70
  import {
34
71
  applyEditsAsTools,
35
72
  killToolGroup,
@@ -69,6 +106,8 @@ export interface LocalSandbox {
69
106
  /** The host config's `command_network: "broker"`: a sandboxed command reaches the network only
70
107
  * through this stk local-commands socket (operator rulings 2026-10-30). */
71
108
  network?: { socket: string };
109
+ /** Operator-allowed read-only paths outside the workspace (Phase 1, 2026-10-31). */
110
+ readPaths?: string[];
72
111
  }
73
112
  /** The proxy a broker-network command uses: only the relay on loopback, nothing direct. */
74
113
  function proxyEnv(port: number): Record<string, string> {
@@ -84,8 +123,23 @@ function proxyEnv(port: number): Record<string, string> {
84
123
  }
85
124
  export class LocalCoding {
86
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;
87
132
  private closed = false;
88
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
+ };
89
143
  constructor(
90
144
  readonly store: Store,
91
145
  readonly managed: ManagedWork,
@@ -105,6 +159,20 @@ export class LocalCoding {
105
159
  throw new ServiceError('local_busy', 'Command resources are still active');
106
160
  },
107
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
+ }
108
176
  // A lost controller must never retry a possibly executed edit or command.
109
177
  for (const raw of store.db.prepare("SELECT * FROM local_calls WHERE state='running'").all()) {
110
178
  const c = raw as unknown as LocalCall;
@@ -126,6 +194,26 @@ export class LocalCoding {
126
194
  });
127
195
  }
128
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
+ }
129
217
  get(id: string): LocalCall {
130
218
  const row = this.store.db.prepare('SELECT * FROM local_calls WHERE id=?').get(id);
131
219
  if (!row) throw new ServiceError('not_found', 'Local operation not found', 404);
@@ -174,12 +262,70 @@ export class LocalCoding {
174
262
  'Another operation or uncertain outcome owns this workspace',
175
263
  );
176
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
+ }
177
322
  prepare(
178
323
  session: string,
179
324
  epoch: number,
180
325
  attempt: string,
181
326
  toolCall: string,
182
327
  input: Record<string, unknown>,
328
+ review: ClassifierReview | null = null,
183
329
  ) {
184
330
  const prior = this.store.db
185
331
  .prepare(
@@ -191,6 +337,7 @@ export class LocalCoding {
191
337
  throw new ServiceError('request_conflict', 'Tool call already binds different arguments');
192
338
  return this.view(String(prior.id));
193
339
  }
340
+ this.assertShellBinding(session, attempt, input);
194
341
  const root = this.store.session(session).cwd;
195
342
  const c: LocalCall = {
196
343
  id: randomUUID(),
@@ -215,6 +362,10 @@ export class LocalCoding {
215
362
  action: 'command';
216
363
  command: string;
217
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;
218
369
  sandbox: { kind: string; level: string; bwrap: string | null; network?: 'broker' };
219
370
  };
220
371
  if (kind === 'edit') args = { action: 'edit', edits: prepareLocalEdits(root, input.edits) };
@@ -222,42 +373,87 @@ export class LocalCoding {
222
373
  const timeout = input.timeout_ms ?? 30000;
223
374
  if (!Number.isInteger(timeout) || Number(timeout) < 100 || Number(timeout) > 120000)
224
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');
225
382
  if (
226
- this.sandbox.kind === 'bwrap' &&
227
- this.sandbox.level !== 'full-access' &&
228
- !this.sandbox.bwrap
383
+ input.outside_sandbox !== undefined &&
384
+ (typeof input.outside_sandbox !== 'string' || !input.outside_sandbox.trim())
229
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)
230
412
  throw new ServiceError(
231
413
  'sandbox_unavailable',
232
414
  'This host has no usable bubblewrap, so it cannot run a sandboxed command; an operator can choose full access explicitly',
233
415
  );
234
- if (
235
- this.sandbox.kind === 'none' &&
236
- this.sandbox.level !== 'full-access' &&
237
- !this.sandbox.allowUnsandboxed
238
- )
416
+ if (sb.kind === 'none' && sb.level !== 'full-access' && !sb.allowUnsandboxed)
239
417
  throw new ServiceError('sandbox_unavailable', UNSANDBOXED_REFUSAL);
240
418
  args = {
241
419
  action: 'command',
242
420
  command: string(input.command, 'command', 8192),
243
- 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 } : {}),
244
425
  // Bound into the approval and the operation digest: the sandbox is part of what is approved.
245
426
  sandbox: {
246
- kind: this.sandbox.kind,
247
- level: this.sandbox.level,
248
- bwrap: this.sandbox.bwrap?.version ?? null,
249
- ...(this.brokerNetwork() ? { network: 'broker' as const } : {}),
427
+ kind: sb.kind,
428
+ level: sb.level,
429
+ bwrap: sb.bwrap?.version ?? null,
430
+ ...(this.brokerNetwork(sb) ? { network: 'broker' as const } : {}),
250
431
  },
251
432
  };
252
433
  } else throw new ServiceError('invalid_field', 'Choose edit or command');
253
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;
254
452
  this.store.transaction(() => {
255
453
  const reviewed =
256
454
  args.action === 'edit'
257
- ? args.edits
258
- .map((e) => `--- ${e.path}\n${e.before ?? '(new file)'}\n+++ ${e.path}\n${e.after}`)
259
- .join('\n\n')
260
- : `Runs as ${this.tools.mode === 'tools-user' ? `tools user ${this.tools.user} (uid ${this.tools.uid})` : 'your local user'}, ${this.sandboxText()}\nDirectory: ${root}\nTimeout: ${args.timeout_ms} ms\nCommand:\n${args.command}`;
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}`;
261
457
  const permission = this.managed.prepareWorkspaceInTransaction(
262
458
  session,
263
459
  epoch,
@@ -290,14 +486,100 @@ export class LocalCoding {
290
486
  action: kind,
291
487
  });
292
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
+ );
293
500
  const policy = permissionPolicy(this.store, session);
294
- if (
295
- args.action === 'edit' &&
296
- matchesEditScope(
297
- policy,
298
- args.edits.map((e) => e.path),
299
- )
300
- ) {
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)) {
301
583
  const approval = this.managed.forOperation(c.operation_id);
302
584
  this.managed.decide(
303
585
  approval.id,
@@ -315,19 +597,27 @@ export class LocalCoding {
315
597
  authorize: () => {
316
598
  this.guard(c);
317
599
  const fresh = permissionPolicy(this.store, session);
318
- if (
319
- permissionDigest(fresh) !== permissionDigest(policy) ||
320
- args.action !== 'edit' ||
321
- !matchesEditScope(
322
- fresh,
323
- args.edits.map((e) => e.path),
324
- )
325
- )
600
+ if (permissionDigest(fresh) !== permissionDigest(policy) || !covered(fresh))
326
601
  throw new ServiceError('permission_changed', 'Scoped edit policy changed');
327
- verifyLocalEdits(root, args.edits);
602
+ if (args.action === 'edit') verifyLocalEdits(root, args.edits);
328
603
  },
329
604
  },
330
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
+ );
331
621
  }
332
622
  return this.view(c.id);
333
623
  }
@@ -396,30 +686,133 @@ export class LocalCoding {
396
686
  else this.managed.workspaceDefiniteFailureInTransaction(c.operation_id);
397
687
  });
398
688
  }
399
- } else this.command(c, String(args.command), Number(args.timeout_ms));
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));
400
691
  return this.view(id);
401
692
  }
402
- private sandboxText(): string {
403
- const { kind, level } = this.sandbox;
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.';
404
722
  if (level === 'full-access')
405
- return 'unsandboxed: the operator chose full access. May access files and network beyond this workspace.';
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.';
406
726
  if (kind === 'none')
407
727
  return 'unsandboxed: this host has no usable command sandbox, so the command may access files and network beyond this workspace.';
408
728
  const name = kind === 'seatbelt' ? 'Seatbelt' : 'bubblewrap';
409
- const net = this.brokerNetwork()
729
+ const net = this.brokerNetwork(sb)
410
730
  ? 'network: through the broker allowlist (hosts set by the stk policy), nothing direct.'
411
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
+ : '';
412
735
  return level === 'read-only'
413
- ? `in a ${name} sandbox (read-only): it can read this workspace but write nothing, and ${net}`
414
- : `in a ${name} sandbox (workspace-write): it can write only inside this workspace, and ${net}`;
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}`;
415
738
  }
416
739
  /** Broker network applies only to a sandboxed command: unsandboxed ones are not confined anyway. */
417
- private brokerNetwork(): { socket: string } | null {
418
- return this.sandbox.network &&
419
- this.sandbox.kind !== 'none' &&
420
- this.sandbox.level !== 'full-access'
421
- ? this.sandbox.network
422
- : null;
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
+ });
423
816
  }
424
817
  /** The process a command runs as. Under the tools user, the host-side shell reports its own
425
818
  * process group (the one a stop kills) before exec-ing bubblewrap, whose PID namespace would
@@ -429,23 +822,24 @@ export class LocalCoding {
429
822
  script: string,
430
823
  tmp?: string,
431
824
  relay?: BrokerRelay | null,
825
+ sb: LocalSandbox = this.sandbox,
826
+ listen = false,
432
827
  ): string[] {
433
- const sandboxed = this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access';
828
+ const sandboxed = this.sandboxed(sb);
434
829
  const inner = (s: string) =>
435
830
  !sandboxed
436
831
  ? ['/bin/sh', '-c', s]
437
- : this.sandbox.kind === 'seatbelt'
438
- ? seatbeltArgv(
439
- this.sandbox.level,
440
- root,
441
- tmp ?? '',
442
- ['/bin/sh', '-c', s],
443
- this.sandbox.seatbelt ?? null,
444
- { network: false, ...(relay?.port ? { brokerPort: relay.port } : {}) },
445
- )
446
- : sandboxArgv(this.sandbox.level, root, ['/bin/sh', '-c', s], this.sandbox.bwrap, {
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, {
447
840
  network: false,
448
841
  ...(relay?.dir ? { broker: { dir: relay.dir, node: process.execPath } } : {}),
842
+ readPaths: sb.readPaths ?? [],
449
843
  });
450
844
  if (this.tools.mode !== 'tools-user') return inner(script);
451
845
  const marker = 'umask 0002\nprintf \'salaam-tool-pgid:%s\\n\' "$$"\n';
@@ -461,11 +855,11 @@ export class LocalCoding {
461
855
  ])
462
856
  : toolArgv(this.tools, ['/usr/bin/setsid', '-w', '/bin/sh', '-c', marker + script]);
463
857
  }
464
- private command(c: LocalCall, command: string, timeout: number): void {
465
- const broker = this.brokerNetwork();
858
+ private command(c: LocalCall, command: string, timeout: number, sb: LocalSandbox): void {
859
+ const broker = this.brokerNetwork(sb);
466
860
  // The relay is Salaam's, outside the sandbox: it forwards to stk and counts what was reached.
467
861
  const relayStarting = broker
468
- ? BrokerRelay.start(broker.socket, this.sandbox.kind === 'seatbelt' ? 'tcp' : 'unix')
862
+ ? BrokerRelay.start(broker.socket, sb.kind === 'seatbelt' ? 'tcp' : 'unix')
469
863
  : Promise.resolve(null);
470
864
  // A stop that arrives while the relay starts still cancels the command once it is spawned.
471
865
  let early = false;
@@ -474,7 +868,7 @@ export class LocalCoding {
474
868
  };
475
869
  // Seatbelt has no tmpfs: each command gets its own private tmp, removed when it ends.
476
870
  const tmp =
477
- this.sandbox.kind === 'seatbelt' && this.sandbox.level !== 'full-access'
871
+ sb.kind === 'seatbelt' && sb.level !== 'full-access'
478
872
  ? realpathSync(mkdtempSync(join(tmpdir(), 'salaam-cmd-')))
479
873
  : undefined;
480
874
  const set = (s: () => void) => {
@@ -482,8 +876,8 @@ export class LocalCoding {
482
876
  if (early) s();
483
877
  };
484
878
  const done = relayStarting.then(
485
- (relay) => this.runCommand(c, command, timeout, tmp, relay, set),
486
- () => this.runCommand(c, command, timeout, tmp, null, set, !!broker),
879
+ (relay) => this.runCommand(c, command, timeout, tmp, relay, set, false, sb),
880
+ () => this.runCommand(c, command, timeout, tmp, null, set, !!broker, sb),
487
881
  );
488
882
  this.jobs.set(c.id, { stop: () => stop(), done });
489
883
  if (tmp) void done.finally(() => rmSync(tmp, { recursive: true, force: true }));
@@ -496,6 +890,7 @@ export class LocalCoding {
496
890
  relay: BrokerRelay | null,
497
891
  setStop: (stop: () => void) => void,
498
892
  relayFailed = false,
893
+ sb: LocalSandbox = this.sandbox,
499
894
  ): Promise<void> {
500
895
  let stop = () => {};
501
896
  return new Promise<void>((resolve) => {
@@ -508,7 +903,14 @@ export class LocalCoding {
508
903
  const tools = this.tools.mode === 'tools-user';
509
904
  const script = 'IFS= read -r salaam_start_gate || exit 125\nexec 0</dev/null\n' + command;
510
905
  // As the tools user, setsid makes the shell its own group leader so it can be killed by group.
511
- const argv = this.commandArgv(c.root, script, tmp, relay);
906
+ const argv = this.commandArgv(
907
+ c.root,
908
+ script,
909
+ tmp,
910
+ relay,
911
+ sb,
912
+ JSON.parse(c.args).listen === true,
913
+ );
512
914
  let toolGroup: number | null = null,
513
915
  marker = tools ? '' : null,
514
916
  spawned = false;
@@ -525,6 +927,7 @@ export class LocalCoding {
525
927
  HOME: process.env.HOME ?? c.root,
526
928
  LANG: 'en_US.UTF-8',
527
929
  TMPDIR: '/tmp',
930
+ ...(this.sandboxed(sb) ? gitIdentityEnv(c.root) : {}),
528
931
  ...(relay ? proxyEnv(relay.port ?? COMMAND_NETWORK_PORT) : {}),
529
932
  },
530
933
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -664,13 +1067,8 @@ export class LocalCoding {
664
1067
  error: error ? 'Command could not start' : null,
665
1068
  isolation:
666
1069
  (tools ? 'tools-user-' : 'local-user-') +
667
- (this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access'
668
- ? this.sandbox.kind + '-' + this.sandbox.level
669
- : 'no-os-sandbox'),
670
- sandbox:
671
- this.sandbox.kind !== 'none' && this.sandbox.level !== 'full-access'
672
- ? this.sandbox.kind
673
- : 'none',
1070
+ (this.sandboxed(sb) ? sb.kind + '-' + sb.level : 'no-os-sandbox'),
1071
+ sandbox: this.sandboxed(sb) ? sb.kind : 'none',
674
1072
  tool_uid: this.tools.uid,
675
1073
  ...(network ? { network } : {}),
676
1074
  };
@@ -691,11 +1089,212 @@ export class LocalCoding {
691
1089
  child.once('close', (code) => finish(code));
692
1090
  });
693
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) };
1290
+ }
694
1291
  async close(): Promise<void> {
695
1292
  this.closed = true;
696
1293
  const jobs = [...this.jobs.values()];
697
1294
  for (const job of jobs) job.stop();
698
- await Promise.all(jobs.map((j) => j.done));
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)]);
699
1298
  this.unregister();
700
1299
  }
701
1300
  }