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