@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,7 +1,8 @@
1
1
  import { randomUUID, randomBytes, createHmac, createHash, timingSafeEqual } from 'node:crypto';
2
- import { constants, openSync, fstatSync, closeSync, readFileSync, writeFileSync, fsyncSync, realpathSync, } from 'node:fs';
2
+ import { constants, openSync, fstatSync, closeSync, readFileSync, writeFileSync, fsyncSync, realpathSync, mkdirSync, } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { canonical, digest, ServiceError, object, integer } from './protocol.js';
5
+ import { forkPiSession } from './pi-sessions.js';
5
6
  import { agentpodBinding, AGENTPOD_LEASE_MS, } from './agentpod-protocol.js';
6
7
  function privateBytes(path) {
7
8
  const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
@@ -41,7 +42,35 @@ export function readAgentPodPins(configPath, state) {
41
42
  throw Error('AgentPod private daemon state must be outside the station workspace');
42
43
  return pins;
43
44
  }
44
- /** A local node admission, not an Accounts token issuer or a provider login. */
45
+ const ruleId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
46
+ /** Closed: 1 to 16 rules, exact keys, a known scope, unique ids. Absent means none. */
47
+ export function approvalRules(value) {
48
+ if (value === undefined)
49
+ return [];
50
+ const bad = () => {
51
+ throw new ServiceError('invalid_approval_rules', 'Malformed approval rules', 400);
52
+ };
53
+ if (!Array.isArray(value) || value.length < 1 || value.length > 16)
54
+ bad();
55
+ const rules = value.map((r) => {
56
+ if (!r || typeof r !== 'object' || Array.isArray(r))
57
+ bad();
58
+ const v = r;
59
+ if (Object.keys(v).sort().join(',') !== 'id,scope,set_by' ||
60
+ typeof v.id !== 'string' ||
61
+ !ruleId.test(v.id) ||
62
+ v.scope !== 'workspace-write' ||
63
+ typeof v.set_by !== 'string' ||
64
+ v.set_by.length < 1 ||
65
+ v.set_by.length > 128 ||
66
+ /[\u0000-\u001f\u007f]/.test(v.set_by))
67
+ bad();
68
+ return { id: v.id, scope: 'workspace-write', set_by: v.set_by };
69
+ });
70
+ if (new Set(rules.map((r) => r.id)).size !== rules.length)
71
+ bad();
72
+ return rules.sort((a, b) => a.id.localeCompare(b.id));
73
+ }
45
74
  export class AgentPodAuthority {
46
75
  store;
47
76
  configPath;
@@ -50,12 +79,14 @@ export class AgentPodAuthority {
50
79
  boot = randomUUID();
51
80
  pins;
52
81
  key;
82
+ stateDir;
53
83
  constructor(store, state, configPath, cancel, ownedResourcesStopped = () => true) {
54
84
  this.store = store;
55
85
  this.configPath = configPath;
56
86
  this.cancel = cancel;
57
87
  this.ownedResourcesStopped = ownedResourcesStopped;
58
88
  this.pins = readAgentPodPins(configPath, state);
89
+ this.stateDir = state;
59
90
  const keyPath = join(state, 'agentpod-control-key');
60
91
  try {
61
92
  const fd = openSync(keyPath, 'wx', 0o600);
@@ -81,6 +112,7 @@ export class AgentPodAuthority {
81
112
  this.key = privateBytes(keyPath);
82
113
  if (this.key.length !== 32)
83
114
  throw Error('Invalid AgentPod control key');
115
+ store.db.exec('CREATE TABLE IF NOT EXISTS agentpod_approval_rules(admission_id TEXT NOT NULL,rule_id TEXT NOT NULL,scope TEXT NOT NULL,set_by TEXT NOT NULL,revoked_at TEXT,PRIMARY KEY(admission_id,rule_id))');
84
116
  store.agentpodAuthorityCheck = (session) => this.assert(this.bySession(session));
85
117
  store.requireAgentPodAuthority = true;
86
118
  }
@@ -121,6 +153,17 @@ export class AgentPodAuthority {
121
153
  throw new ServiceError('authority_expired', 'AgentPod authority lease has no remaining native grant window');
122
154
  return remaining;
123
155
  }
156
+ /** The session's pinned approval rules that no renewal has revoked. */
157
+ approvalRules(session) {
158
+ return this.store.db
159
+ .prepare('SELECT r.rule_id AS id,r.scope,r.set_by FROM agentpod_approval_rules r JOIN agentpod_admissions a ON a.id=r.admission_id WHERE a.session_id=? AND r.revoked_at IS NULL ORDER BY r.rule_id')
160
+ .all(session)
161
+ .map((r) => ({
162
+ id: String(r.id),
163
+ scope: 'workspace-write',
164
+ set_by: String(r.set_by),
165
+ }));
166
+ }
124
167
  checked(input) {
125
168
  const b = agentpodBinding(input);
126
169
  for (const k of Object.keys(this.pins))
@@ -191,9 +234,25 @@ export class AgentPodAuthority {
191
234
  this.assert(row);
192
235
  return { session_id: row.session_id, binding: JSON.parse(row.binding) };
193
236
  }
194
- admit(input, request) {
237
+ /**
238
+ * `continueFrom` names an earlier interactive admission (its hub session id) whose conversation
239
+ * this new admission carries on. The earlier execution stays revoked: the new admission gets a
240
+ * NEW session, its own lease and its own authority, seeded with a branch of the old Pi
241
+ * transcript and its entries. Only a revoked, stopped and reconciled interactive admission for
242
+ * the same organization, station, occupant and workspace can be continued, and only once.
243
+ */
244
+ admit(input, request, continueFrom, rulesInput) {
195
245
  const b = this.checked(input);
196
- const admitted = this.store.request('agentpod-node', request, { kind: 'admit', binding: b }, () => {
246
+ const rules = approvalRules(rulesInput);
247
+ if (continueFrom !== undefined &&
248
+ (typeof continueFrom !== 'string' || !continueFrom || continueFrom.length > 128))
249
+ throw new ServiceError('invalid_continuation', 'continue_from must be a hub session id', 400);
250
+ const admitted = this.store.request('agentpod-node', request, {
251
+ kind: 'admit',
252
+ binding: b,
253
+ ...(continueFrom === undefined ? {} : { continue_from: continueFrom }),
254
+ ...(rules.length ? { approval_rules: rules } : {}),
255
+ }, () => {
197
256
  const old = this.store.db
198
257
  .prepare('SELECT id FROM agentpod_admissions WHERE hub_session_id=?')
199
258
  .get(b.hub_session_id);
@@ -226,24 +285,61 @@ export class AgentPodAuthority {
226
285
  this.store.db
227
286
  .prepare('INSERT INTO agentpod_station_epochs VALUES (?,?,?) ON CONFLICT(station_id) DO UPDATE SET occupancy_epoch=excluded.occupancy_epoch,principal_id=excluded.principal_id')
228
287
  .run(b.station_id, b.occupancy_epoch, b.principal_id);
288
+ const prior = continueFrom === undefined ? undefined : this.continuable(b, continueFrom);
229
289
  const session = randomUUID(), id = randomUUID();
230
- this.store.db
231
- .prepare('INSERT INTO sessions(id,cwd,pi_session_id) VALUES (?,?,?)')
232
- .run(session, b.workspace, session);
233
- this.store.event(session, 'session.created', { cwd: b.workspace, authority: 'agentpod-v1' }, request);
290
+ if (prior) {
291
+ const source = this.store.session(prior.session_id);
292
+ const file = source.pi_session_file
293
+ ? (() => {
294
+ const dir = join(this.stateDir, 'workers', session, 'sessions');
295
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
296
+ return forkPiSession(source.pi_session_file, source.cwd, dir, session);
297
+ })()
298
+ : null;
299
+ this.store.db
300
+ .prepare('INSERT INTO sessions(id,cwd,pi_session_id,pi_session_file,pi_cursor,pi_leaf) VALUES (?,?,?,?,?,?)')
301
+ .run(session, b.workspace, session, file, source.pi_cursor, source.pi_leaf);
302
+ this.store.event(session, 'session.created', { cwd: b.workspace, authority: 'agentpod-v1', forked_from: prior.session_id }, request);
303
+ this.store.db
304
+ .prepare('INSERT INTO pi_entries SELECT ?,entry_id,payload FROM pi_entries WHERE session_id=?')
305
+ .run(session, prior.session_id);
306
+ this.store.event(session, 'session.forked', {
307
+ parent: prior.session_id,
308
+ parent_revision: source.revision,
309
+ leaf: source.pi_leaf,
310
+ cursor: source.pi_cursor,
311
+ }, request);
312
+ }
313
+ else {
314
+ this.store.db
315
+ .prepare('INSERT INTO sessions(id,cwd,pi_session_id) VALUES (?,?,?)')
316
+ .run(session, b.workspace, session);
317
+ this.store.event(session, 'session.created', { cwd: b.workspace, authority: 'agentpod-v1' }, request);
318
+ }
234
319
  this.store.db
235
320
  .prepare('INSERT INTO agentpod_admissions(id,session_id,hub_session_id,run_key,binding,digest,pins_digest,expires_at) VALUES (?,?,?,?,?,?,?,?)')
236
321
  .run(id, session, b.hub_session_id, canonical({ org: b.organization_id, run: b.run }), canonical(b), digest(b), digest(this.pins), Date.now() + AGENTPOD_LEASE_MS);
237
- this.store.event(session, 'agentpod.admitted', { admission_id: id, binding_digest: digest(b), binding: b }, request);
322
+ // The agent's standing rules, pinned for this run; a renewal may add or revoke one.
323
+ for (const r of rules)
324
+ this.store.db
325
+ .prepare('INSERT INTO agentpod_approval_rules VALUES (?,?,?,?,NULL)')
326
+ .run(id, r.id, r.scope, r.set_by);
327
+ this.store.event(session, 'agentpod.admitted', {
328
+ admission_id: id,
329
+ binding_digest: digest(b),
330
+ binding: b,
331
+ ...(prior ? { continued_from: continueFrom, continued_admission: prior.id } : {}),
332
+ }, request);
238
333
  return { admission_id: id };
239
334
  });
240
335
  const row = this.row(admitted.admission_id);
241
336
  return { ...this.status(row), token: this.token(row.id) };
242
337
  }
243
- renew(input, request, sequence) {
338
+ renew(input, request, sequence, rulesInput) {
244
339
  const b = this.checked(input);
245
340
  integer(sequence, 'lease_sequence');
246
- return this.store.request('agentpod-node', request, { kind: 'renew', binding: b, sequence }, () => {
341
+ const rules = approvalRules(rulesInput);
342
+ return this.store.request('agentpod-node', request, { kind: 'renew', binding: b, sequence, ...(rules.length ? { approval_rules: rules } : {}) }, () => {
247
343
  const row = this.bound(b);
248
344
  if (row.state === 'revoked' ||
249
345
  this.store.execution.revocation(row.session_id) ||
@@ -258,10 +354,68 @@ export class AgentPodAuthority {
258
354
  this.store.db
259
355
  .prepare("UPDATE agentpod_admissions SET lease_sequence=?,expires_at=?,boot_id=?,monotonic_deadline=?,state='active' WHERE id=?")
260
356
  .run(sequence, Date.now() + AGENTPOD_LEASE_MS, this.boot, performance.now() + AGENTPOD_LEASE_MS, row.id);
357
+ // Revocation is immediate: a pinned rule the hub no longer lists ends now, for good.
358
+ const listed = new Set(rules.map((r) => r.id));
359
+ for (const pinned of this.store.db
360
+ .prepare('SELECT rule_id FROM agentpod_approval_rules WHERE admission_id=? AND revoked_at IS NULL')
361
+ .all(row.id)) {
362
+ if (listed.has(String(pinned.rule_id)))
363
+ continue;
364
+ this.store.db
365
+ .prepare('UPDATE agentpod_approval_rules SET revoked_at=? WHERE admission_id=? AND rule_id=?')
366
+ .run(new Date().toISOString(), row.id, String(pinned.rule_id));
367
+ this.store.event(row.session_id, 'approval_rule.revoked', { admission_id: row.id, rule_id: pinned.rule_id }, request);
368
+ }
369
+ // A rule the hub newly lists joins the run now (operator ruling 2026-10-28: a person who
370
+ // chose "Always allow" mid-run is not asked again). One this run already holds, or held
371
+ // and lost to revocation, is left as it is: revocation stays final for the run.
372
+ for (const r of rules) {
373
+ const known = this.store.db
374
+ .prepare('SELECT 1 FROM agentpod_approval_rules WHERE admission_id=? AND rule_id=?')
375
+ .get(row.id, r.id);
376
+ if (known)
377
+ continue;
378
+ this.store.db
379
+ .prepare('INSERT INTO agentpod_approval_rules VALUES (?,?,?,?,NULL)')
380
+ .run(row.id, r.id, r.scope, r.set_by);
381
+ this.store.event(row.session_id, 'approval_rule.added', { admission_id: row.id, rule_id: r.id, scope: r.scope, set_by: r.set_by }, request);
382
+ }
261
383
  this.store.event(row.session_id, 'agentpod.renewed', { admission_id: row.id, lease_sequence: sequence }, request);
262
384
  return this.status(this.row(row.id));
263
385
  });
264
386
  }
387
+ /** The earlier admission a new interactive admission may continue, or a refusal. */
388
+ continuable(b, continueFrom) {
389
+ if (b.run.source !== 'agentpod')
390
+ throw new ServiceError('continuation_refused', 'Only an interactive admission can continue an earlier conversation');
391
+ const row = this.store.db
392
+ .prepare('SELECT id FROM agentpod_admissions WHERE hub_session_id=?')
393
+ .get(continueFrom);
394
+ if (!row)
395
+ throw new ServiceError('admission_not_found', 'The session to continue has no admission', 404);
396
+ const prior = this.row(String(row.id));
397
+ const before = JSON.parse(prior.binding);
398
+ if (prior.binding !== canonical(before) ||
399
+ prior.digest !== digest(before) ||
400
+ before.run.source !== 'agentpod' ||
401
+ before.organization_id !== b.organization_id ||
402
+ before.station_id !== b.station_id ||
403
+ before.principal_id !== b.principal_id ||
404
+ before.workspace !== b.workspace ||
405
+ before.hub_session_id === b.hub_session_id)
406
+ throw new ServiceError('continuation_refused', 'Only the same station, occupant and workspace can continue this conversation');
407
+ const status = this.status(prior);
408
+ if (prior.state !== 'revoked' ||
409
+ !status.revoked ||
410
+ !status.owned_execution_stopped ||
411
+ status.unresolved_operations.length)
412
+ throw new ServiceError('execution_unconfirmed', 'The conversation to continue must be revoked, stopped and reconciled first');
413
+ if (this.store.db
414
+ .prepare("SELECT 1 FROM events WHERE kind='agentpod.admitted' AND json_extract(payload,'$.continued_admission')=? LIMIT 1")
415
+ .get(prior.id))
416
+ throw new ServiceError('already_continued', 'This conversation was already continued');
417
+ return prior;
418
+ }
265
419
  revoke(input, request, reason) {
266
420
  const b = this.checked(input);
267
421
  this.store.request('agentpod-node', request, { kind: 'revoke', binding: b, reason }, () => {
@@ -329,9 +483,13 @@ export class AgentPodAuthority {
329
483
  authorized = true;
330
484
  }
331
485
  catch { }
486
+ const continued = this.store.db
487
+ .prepare("SELECT json_extract(payload,'$.continued_from') AS source FROM events WHERE session_id=? AND kind='agentpod.admitted' LIMIT 1")
488
+ .get(row.session_id);
332
489
  return {
333
490
  admission_id: row.id,
334
491
  session_id: row.session_id,
492
+ continued_from: continued?.source ?? null,
335
493
  binding: JSON.parse(row.binding),
336
494
  binding_digest: row.digest,
337
495
  lease_sequence: row.lease_sequence,
@@ -0,0 +1,12 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * The AgentPod pins a node operator drops for this user, used when
5
+ * `service start` names no --agentpod-config. Without it a daemon an AgentPod
6
+ * node expects to bind starts unbound and the node never detects a station.
7
+ */
8
+ export function defaultAgentPodConfig(env, exists) {
9
+ const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config');
10
+ const path = join(base, 'salaam', 'agentpod-pins.json');
11
+ return exists(path) ? path : undefined;
12
+ }
@@ -31,19 +31,31 @@ export function workDecision(value) {
31
31
  'expires_at',
32
32
  'question',
33
33
  'options',
34
+ ...('detail' in v ? ['detail'] : []),
34
35
  ]);
36
+ if ('detail' in v)
37
+ workDecisionDetail(v.detail);
35
38
  if (v.version !== 1 ||
36
39
  !['request_id', 'work_request_id', 'task_id', 'operation_id', 'approval_id'].every((k) => uuid(v[k])) ||
37
40
  !['binding_digest', 'operation_digest'].every((k) => typeof v[k] === 'string' && /^[a-f0-9]{64}$/.test(v[k])) ||
38
41
  !['plan_revision', 'approval_revision', 'expires_at'].every((k) => Number.isSafeInteger(v[k]) && Number(v[k]) > 0) ||
39
42
  !text(v.question, 8192) ||
40
43
  !Array.isArray(v.options) ||
41
- v.options.length !== 2)
44
+ ![2, 3, 4].includes(v.options.length))
42
45
  fail();
43
- for (let i = 0; i < 2; i++) {
44
- const opt = object(v.options[i]);
45
- keys(opt, ['id', 'title']);
46
- if (opt.id !== ['approve', 'deny'][i] || !text(opt.title, 256))
46
+ // approve, [approve-run], [always], deny. Nothing else, in that order.
47
+ const ids = v.options.map((o) => String(object(o).id));
48
+ if (![
49
+ 'approve,deny',
50
+ 'approve,approve-run,deny',
51
+ 'approve,always,deny',
52
+ 'approve,approve-run,always,deny',
53
+ ].includes(ids.join(',')))
54
+ fail();
55
+ for (const value of v.options) {
56
+ const opt = object(value);
57
+ keys(opt, opt.id === 'always' ? ['id', 'title', 'requires'] : ['id', 'title']);
58
+ if (!text(opt.title, 256) || (opt.id === 'always' && opt.requires !== 'workspace-admin'))
47
59
  fail();
48
60
  }
49
61
  return v;
@@ -67,7 +79,7 @@ export function workDecisionAnswer(value) {
67
79
  !/^[a-f0-9]{64}$/.test(v.decision_digest) ||
68
80
  !text(v.elicitation_id, 128) ||
69
81
  !/^elc_[A-Za-z0-9]{6,}$/.test(v.elicitation_id) ||
70
- !['approve', 'deny'].includes(String(v.option_id)) ||
82
+ !['approve', 'approve-run', 'always', 'deny'].includes(String(v.option_id)) ||
71
83
  !text(v.answered_by, 128) ||
72
84
  typeof v.answered_at !== 'string' ||
73
85
  !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z$/.test(v.answered_at) ||
@@ -76,3 +88,25 @@ export function workDecisionAnswer(value) {
76
88
  fail();
77
89
  return v;
78
90
  }
91
+ function workDecisionDetail(value) {
92
+ const d = object(value);
93
+ keys(d, ['version', 'summary', 'reason', 'files', 'digest']);
94
+ if (d.version !== 1 ||
95
+ !text(d.summary, 200) ||
96
+ (d.reason !== null && !text(d.reason, 300)) ||
97
+ typeof d.digest !== 'string' ||
98
+ !/^[a-f0-9]{64}$/.test(d.digest) ||
99
+ !Array.isArray(d.files) ||
100
+ d.files.length > 16)
101
+ fail();
102
+ for (const f of d.files) {
103
+ const file = object(f);
104
+ keys(file, ['path', 'change', 'bytes', 'preview']);
105
+ if (!text(file.path, 512) ||
106
+ !['create', 'edit', 'delete'].includes(String(file.change)) ||
107
+ (file.bytes !== null && (!Number.isSafeInteger(file.bytes) || Number(file.bytes) < 0)) ||
108
+ (file.preview !== null && (typeof file.preview !== 'string' || file.preview.length > 1200)))
109
+ fail();
110
+ }
111
+ return d;
112
+ }
@@ -1,14 +1,44 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { canonical, digest, ServiceError } from './protocol.js';
3
- import { workDecisionAnswer } from './agentpod-work-decision-protocol.js';
3
+ import { runGrantCovers, runGrantPrefix } from './run-grants.js';
4
+ import { workDecisionAnswer, } from './agentpod-work-decision-protocol.js';
4
5
  /** Private authenticated Hub control only. Answers choose an existing approval;
5
6
  * they never execute an operation or supply a second effect grant. */
6
7
  export class AgentPodWorkDecisions {
7
8
  work;
8
9
  managed;
9
- constructor(work, managed) {
10
+ grants;
11
+ constructor(work, managed, grants) {
10
12
  this.work = work;
11
13
  this.managed = managed;
14
+ this.grants = grants;
15
+ }
16
+ /** The run directory a grant for this operation would cover, when it is a covered write. */
17
+ grantable(binding, op) {
18
+ if (!this.grants || op.kind !== 'workspace')
19
+ return null;
20
+ const run = binding?.run;
21
+ const prefix = runGrantPrefix(typeof run?.id === 'string' ? run.id : '');
22
+ let args = {};
23
+ try {
24
+ args = JSON.parse(op.value);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ return prefix && runGrantCovers(prefix, args) ? prefix : null;
30
+ }
31
+ /** "Always allow" covers exactly what the standing workspace-write rule answers: a workspace
32
+ * write anywhere in the workspace, never a delete or a hidden path. */
33
+ alwaysAllowable(op) {
34
+ if (op.kind !== 'workspace')
35
+ return false;
36
+ try {
37
+ return runGrantCovers('', JSON.parse(op.value));
38
+ }
39
+ catch {
40
+ return false;
41
+ }
12
42
  }
13
43
  owner(binding) {
14
44
  const root = this.work.authority.inspect(binding);
@@ -48,9 +78,22 @@ export class AgentPodWorkDecisions {
48
78
  version: 1,
49
79
  request_id: randomUUID(),
50
80
  ...identity,
51
- question: 'Approve this exact operation? ' + op.kind + ' ' + op.action_key,
81
+ question: approvalDetail(op).summary + '?',
82
+ detail: approvalDetail(op),
52
83
  options: [
53
84
  { id: 'approve', title: 'Approve once' },
85
+ ...(this.grantable(root.binding, op)
86
+ ? [{ id: 'approve-run', title: 'Approve for this run' }]
87
+ : []),
88
+ ...(this.alwaysAllowable(op)
89
+ ? [
90
+ {
91
+ id: 'always',
92
+ title: 'Always allow this agent to write in its workspace',
93
+ requires: 'workspace-admin',
94
+ },
95
+ ]
96
+ : []),
54
97
  { id: 'deny', title: 'Deny' },
55
98
  ],
56
99
  }));
@@ -81,7 +124,30 @@ export class AgentPodWorkDecisions {
81
124
  approval.operation_id !== offered.operation_id ||
82
125
  approval.expires_at !== offered.expires_at)
83
126
  throw new ServiceError('approval_mismatch', 'Approval ownership changed');
84
- this.managed.decide(offered.approval_id, answer.request_id, offered.approval_revision, offered.operation_digest, answer.option_id);
127
+ // "Approve for this run" must have been offered, and still be grantable, to be chosen.
128
+ const prefix = answer.option_id === 'approve-run'
129
+ ? this.grantable(root.binding, this.managed.operation(offered.operation_id))
130
+ : null;
131
+ if (answer.option_id === 'approve-run' &&
132
+ (!prefix || !offered.options.some((o) => o.id === 'approve-run')))
133
+ throw new ServiceError('approval_mismatch', 'This approval cannot cover the run');
134
+ // "Always allow" approves this write; the standing rule is the hub's, set before it forwards.
135
+ if (answer.option_id === 'always' &&
136
+ (!offered.options.some((o) => o.id === 'always') ||
137
+ !this.alwaysAllowable(this.managed.operation(offered.operation_id))))
138
+ throw new ServiceError('approval_mismatch', 'Always allow was not offered for this write');
139
+ this.managed.decide(offered.approval_id, answer.request_id, offered.approval_revision, offered.operation_digest, answer.option_id === 'deny' ? 'deny' : 'approve');
140
+ const grant = answer.option_id === 'approve-run'
141
+ ? this.grants.createInTransaction({
142
+ session_id: root.session_id,
143
+ task_id: task.id,
144
+ plan_revision: offered.plan_revision,
145
+ binding_digest: offered.binding_digest,
146
+ path_prefix: prefix,
147
+ approval_id: offered.approval_id,
148
+ granted_by: answer.answered_by,
149
+ })
150
+ : null;
85
151
  this.work.store.execution.assert(root.session_id);
86
152
  return {
87
153
  version: 1,
@@ -89,7 +155,45 @@ export class AgentPodWorkDecisions {
89
155
  decision_digest: answer.decision_digest,
90
156
  approval_id: offered.approval_id,
91
157
  decision: answer.option_id,
158
+ ...(grant ? { grant_id: grant.id } : {}),
92
159
  };
93
160
  });
94
161
  }
95
162
  }
163
+ /** Readable approvals: what a person approves, from the operation's own canonical arguments only.
164
+ * A workspace write names its file, the change, its size, a short preview and the agent's one-line
165
+ * reason; anything else names its kind and target. The digest still binds the exact operation. */
166
+ export function approvalDetail(op) {
167
+ const base = { version: 1, reason: null, files: [], digest: op.operation_digest };
168
+ if (op.kind === 'review')
169
+ return { ...base, summary: 'Accept this step for review' };
170
+ if (op.kind !== 'workspace')
171
+ return { ...base, summary: ('Run ' + op.kind + ' ' + op.target).slice(0, 200) };
172
+ let args = {};
173
+ try {
174
+ args = JSON.parse(op.value);
175
+ }
176
+ catch {
177
+ /* not a patch: the generic form below */
178
+ }
179
+ const path = typeof args.path === 'string' ? args.path : op.target.replace(/^station:/, '');
180
+ const content = typeof args.content === 'string' ? args.content : null;
181
+ const change = args.content === null ? 'delete' : args.expected_hash === null ? 'create' : 'edit';
182
+ const verb = { create: 'Create', edit: 'Edit', delete: 'Delete' }[change];
183
+ const reason = typeof args.reason === 'string' && args.reason.trim()
184
+ ? args.reason.replace(/\s+/g, ' ').trim().slice(0, 300)
185
+ : null;
186
+ return {
187
+ ...base,
188
+ summary: (verb + ' ' + path + ' in the workspace').slice(0, 200),
189
+ reason,
190
+ files: [
191
+ {
192
+ path: path.slice(0, 512),
193
+ change,
194
+ bytes: content === null ? null : Buffer.byteLength(content),
195
+ preview: content === null ? null : content.slice(0, 1200),
196
+ },
197
+ ],
198
+ };
199
+ }