@superjackfruitlabs/salaam 0.0.3 → 0.0.4

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 (102) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-outcome.js +177 -17
  4. package/dist/src/acp.js +41 -7
  5. package/dist/src/agent-profiles.js +45 -0
  6. package/dist/src/agentpod-authority.js +169 -11
  7. package/dist/src/agentpod-default-config.js +12 -0
  8. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  9. package/dist/src/agentpod-work-decisions.js +108 -4
  10. package/dist/src/agentpod-work.js +1214 -127
  11. package/dist/src/board-reads.js +106 -0
  12. package/dist/src/children.js +83 -0
  13. package/dist/src/cli-help.js +27 -2
  14. package/dist/src/cli.js +87 -3
  15. package/dist/src/command-network.js +200 -0
  16. package/dist/src/command-sandbox.js +217 -0
  17. package/dist/src/execution-evidence.js +33 -1
  18. package/dist/src/host-bind.js +111 -0
  19. package/dist/src/host-breaker.js +88 -0
  20. package/dist/src/host-start.js +41 -1
  21. package/dist/src/host.js +192 -6
  22. package/dist/src/house-extension.js +162 -57
  23. package/dist/src/local-coding.js +214 -18
  24. package/dist/src/local-edit-helper.js +25 -0
  25. package/dist/src/local-undo.js +25 -16
  26. package/dist/src/managed.js +1 -1
  27. package/dist/src/operator-mcp-cli.js +107 -0
  28. package/dist/src/operator-mcp.js +342 -0
  29. package/dist/src/persona.js +31 -0
  30. package/dist/src/pi.js +22 -1
  31. package/dist/src/protocol.js +5 -2
  32. package/dist/src/rate-limit-retries.js +62 -0
  33. package/dist/src/recurring-work.js +1 -0
  34. package/dist/src/repository-snapshots.js +5 -2
  35. package/dist/src/run-grants.js +62 -0
  36. package/dist/src/service.js +141 -6
  37. package/dist/src/station-checkpoints.js +139 -0
  38. package/dist/src/station-config.js +8 -1
  39. package/dist/src/station-files.js +20 -1
  40. package/dist/src/station-task-verification.js +6 -3
  41. package/dist/src/station-work.js +315 -22
  42. package/dist/src/store.js +19 -0
  43. package/dist/src/supervisor.js +177 -5
  44. package/dist/src/task-budget.js +39 -3
  45. package/dist/src/task-critic.js +115 -0
  46. package/dist/src/task-driver.js +46 -2
  47. package/dist/src/task-policy.js +44 -12
  48. package/dist/src/task-verification.js +42 -9
  49. package/dist/src/task-work.js +47 -0
  50. package/dist/src/tool-identity.js +69 -0
  51. package/dist/src/usage.js +1 -1
  52. package/npm-shrinkwrap.json +2 -2
  53. package/package.json +1 -1
  54. package/src/acp-outcome.ts +169 -19
  55. package/src/acp.ts +53 -7
  56. package/src/agent-profiles.ts +48 -0
  57. package/src/agentpod-authority.ts +234 -15
  58. package/src/agentpod-default-config.ts +16 -0
  59. package/src/agentpod-work-decision-protocol.ts +69 -8
  60. package/src/agentpod-work-decisions.ts +123 -5
  61. package/src/agentpod-work.ts +1455 -152
  62. package/src/board-reads.ts +148 -0
  63. package/src/children.ts +112 -0
  64. package/src/cli-help.ts +27 -2
  65. package/src/cli.ts +109 -3
  66. package/src/command-network.ts +215 -0
  67. package/src/command-sandbox.ts +273 -0
  68. package/src/execution-evidence.ts +31 -1
  69. package/src/host-bind.ts +156 -0
  70. package/src/host-breaker.ts +89 -0
  71. package/src/host-start.ts +51 -1
  72. package/src/host.ts +244 -6
  73. package/src/house-extension.ts +174 -56
  74. package/src/local-coding.ts +270 -18
  75. package/src/local-edit-helper.ts +26 -0
  76. package/src/local-undo.ts +29 -14
  77. package/src/managed.ts +2 -2
  78. package/src/operator-mcp-cli.ts +108 -0
  79. package/src/operator-mcp.ts +420 -0
  80. package/src/persona.ts +37 -0
  81. package/src/pi.ts +23 -1
  82. package/src/protocol.ts +6 -1
  83. package/src/rate-limit-retries.ts +69 -0
  84. package/src/recurring-work.ts +1 -0
  85. package/src/repository-snapshots.ts +5 -2
  86. package/src/run-grants.ts +107 -0
  87. package/src/service.ts +222 -6
  88. package/src/station-checkpoints.ts +183 -0
  89. package/src/station-config.ts +10 -1
  90. package/src/station-files.ts +20 -1
  91. package/src/station-task-verification.ts +6 -3
  92. package/src/station-work.ts +369 -20
  93. package/src/store.ts +18 -0
  94. package/src/supervisor.ts +203 -4
  95. package/src/task-budget.ts +48 -3
  96. package/src/task-critic.ts +171 -0
  97. package/src/task-driver.ts +49 -2
  98. package/src/task-policy.ts +65 -13
  99. package/src/task-verification.ts +53 -9
  100. package/src/task-work.ts +75 -0
  101. package/src/tool-identity.ts +110 -0
  102. package/src/usage.ts +1 -1
@@ -0,0 +1,107 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { Store } from './store.js';
3
+
4
+ /**
5
+ * "Approve for this run" (approval scopes, item B). A person answering one workspace-write approval
6
+ * may cover the rest of the run's writes inside the run's own directory. Each later write is still
7
+ * prepared, digest-bound and journaled as its own operation; the grant only answers its approval.
8
+ *
9
+ * A grant covers a station patch only when it is a write (never a delete: every delete asks), its
10
+ * path lies under `runs/<runId>/`, and no segment is empty, `.`, `..` or hidden (a leading dot).
11
+ * It is bound to the session, task, plan revision and binding digest it was given under, and ends
12
+ * when any of them changes or the task settles. Anything outside it asks as before.
13
+ */
14
+ export interface RunGrant {
15
+ id: string;
16
+ session_id: string;
17
+ task_id: string;
18
+ plan_revision: number;
19
+ binding_digest: string;
20
+ path_prefix: string;
21
+ approval_id: string;
22
+ granted_by: string;
23
+ created_at: string;
24
+ revoked_at: string | null;
25
+ }
26
+
27
+ export const runGrantPrefix = (runId: string): string | null =>
28
+ /^run_[A-Za-z0-9]{6,}$/.test(runId) ? `runs/${runId}/` : null;
29
+
30
+ /** Whether a station patch's arguments are a write a grant for this prefix may answer. */
31
+ export function runGrantCovers(prefix: string, args: Record<string, unknown>): boolean {
32
+ if (args.action !== 'patch' || typeof args.content !== 'string') return false;
33
+ const path = args.path;
34
+ if (typeof path !== 'string' || !path.startsWith(prefix) || path.length === prefix.length)
35
+ return false;
36
+ return path.split('/').every((s) => s !== '' && s !== '.' && s !== '..' && !s.startsWith('.'));
37
+ }
38
+
39
+ export class RunGrants {
40
+ constructor(private readonly store: Store) {
41
+ store.db.exec(`CREATE TABLE IF NOT EXISTS run_grants(
42
+ id TEXT PRIMARY KEY NOT NULL,
43
+ session_id TEXT NOT NULL,
44
+ task_id TEXT NOT NULL,
45
+ plan_revision INTEGER NOT NULL,
46
+ binding_digest TEXT NOT NULL,
47
+ path_prefix TEXT NOT NULL,
48
+ approval_id TEXT NOT NULL UNIQUE,
49
+ granted_by TEXT NOT NULL,
50
+ created_at TEXT NOT NULL,
51
+ revoked_at TEXT)`);
52
+ }
53
+ /** Within the caller's transaction, next to the decision that granted it. Idempotent per approval. */
54
+ createInTransaction(input: Omit<RunGrant, 'id' | 'created_at' | 'revoked_at'>): RunGrant {
55
+ if (!this.store.db.isTransaction) throw Error('A run grant is created with its decision');
56
+ const saved = this.store.db
57
+ .prepare('SELECT * FROM run_grants WHERE approval_id=?')
58
+ .get(input.approval_id) as RunGrant | undefined;
59
+ if (saved) return saved;
60
+ const grant: RunGrant = {
61
+ ...input,
62
+ id: randomUUID(),
63
+ created_at: new Date().toISOString(),
64
+ revoked_at: null,
65
+ };
66
+ this.store.db
67
+ .prepare('INSERT INTO run_grants VALUES (?,?,?,?,?,?,?,?,?,?)')
68
+ .run(
69
+ grant.id,
70
+ grant.session_id,
71
+ grant.task_id,
72
+ grant.plan_revision,
73
+ grant.binding_digest,
74
+ grant.path_prefix,
75
+ grant.approval_id,
76
+ grant.granted_by,
77
+ grant.created_at,
78
+ null,
79
+ );
80
+ this.store.event(grant.session_id, 'run_grant.created', {
81
+ grant_id: grant.id,
82
+ approval_id: grant.approval_id,
83
+ task_id: grant.task_id,
84
+ path_prefix: grant.path_prefix,
85
+ });
86
+ return grant;
87
+ }
88
+ /** The live grant for exactly this session, task, plan revision and binding, if any. */
89
+ active(scope: {
90
+ session_id: string;
91
+ task_id: string;
92
+ plan_revision: number;
93
+ binding_digest: string;
94
+ task_state: string;
95
+ }): RunGrant | null {
96
+ if (['cancelled', 'succeeded', 'succeeded_with_exception'].includes(scope.task_state))
97
+ return null;
98
+ return (
99
+ (this.store.db
100
+ .prepare(
101
+ 'SELECT * FROM run_grants WHERE session_id=? AND task_id=? AND plan_revision=? AND binding_digest=? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1',
102
+ )
103
+ .get(scope.session_id, scope.task_id, scope.plan_revision, scope.binding_digest) as
104
+ RunGrant | undefined) ?? null
105
+ );
106
+ }
107
+ }
package/src/service.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  import { createServer, type Server, type ServerResponse, type IncomingMessage } from 'node:http';
2
- import { mkdirSync, chmodSync, existsSync, unlinkSync, realpathSync, statSync } from 'node:fs';
2
+ import { loadPersona } from './persona.js';
3
+ import {
4
+ mkdirSync,
5
+ chmodSync,
6
+ chownSync,
7
+ existsSync,
8
+ unlinkSync,
9
+ realpathSync,
10
+ statSync,
11
+ } from 'node:fs';
3
12
  import { join, resolve } from 'node:path';
4
13
  import { providerRoute } from './provider-config.js';
5
14
  import { TaskContext } from './task-context.js';
@@ -8,6 +17,8 @@ import { SLASH_COMMANDS } from './tui-commands.js';
8
17
  import { TaskWork } from './task-work.js';
9
18
  import { TaskBudget } from './task-budget.js';
10
19
  import { AgentPodWorkDecisions } from './agentpod-work-decisions.js';
20
+ import { RunGrants } from './run-grants.js';
21
+ import { TaskCritic } from './task-critic.js';
11
22
  import { AgentPodWork } from './agentpod-work.js';
12
23
  import { agentpodWorkAdmission } from './agentpod-work-protocol.js';
13
24
  import { TaskVerification } from './task-verification.js';
@@ -41,6 +52,7 @@ import { DockerHarnessBackend } from './harness-backend.js';
41
52
  import { A2AInbound } from './a2a-inbound.js';
42
53
  import { AgentPodAuthority, readAgentPodPins } from './agentpod-authority.js';
43
54
  import { ToolkitResources } from './toolkit-resources.js';
55
+ import { OperatorMcp } from './operator-mcp.js';
44
56
  import { cancelToolkitCalls, recoverToolkitCalls } from './toolkit-calls.js';
45
57
  import { ToolkitOwners } from './toolkit-owners.js';
46
58
  import { ToolkitResolutions, resolvedToolkitCallSql } from './toolkit-resolutions.js';
@@ -51,16 +63,28 @@ import { McpAdmission } from './mcp-admission.js';
51
63
  import { McpWork } from './mcp-work.js';
52
64
  import { ToolkitWork, type ToolkitWorkConfig } from './toolkit-work.js';
53
65
  import { permissionPolicy, configurePermissions } from './autonomy.js';
54
- import { LocalCoding } from './local-coding.js';
66
+ import { chooseLocalSandbox, type SandboxLevel } from './command-sandbox.js';
67
+ import { LocalCoding, type LocalSandbox } from './local-coding.js';
55
68
  import { localDiff } from './local-diff.js';
56
69
  import { reviewUndo, applyUndo, reconcileUndo } from './local-undo.js';
57
70
  import { forkPiSession } from './pi-sessions.js';
58
71
  import { StationWork } from './station-work.js';
59
72
  import { readToolkitConfig } from './toolkit-config.js';
73
+ import { probeToolsUser, serviceToolIdentity, type ToolIdentity } from './tool-identity.js';
60
74
 
61
75
  export interface ServiceOptions extends Omit<SupervisorOptions, 'stateDir' | 'bridgeSocket'> {
62
76
  hostManifestDigest?: string;
77
+ /** Receipt fields for the launcher profile; reported only by a manifest-served host. */
78
+ hostReceipt?: Record<string, unknown>;
79
+ /** Launcher profile: one group may traverse state and use the operator socket. */
80
+ operatorAccess?: { group: string; gid: number };
63
81
  agentpodConfig?: string;
82
+ /** Operator-declared MCP file (never a workspace path); absent means none declared. */
83
+ operatorMcpConfig?: string;
84
+ /** Item 9: the host's pinned persona file and the digest host plan reviewed. */
85
+ personaConfig?: { config: string; digest: string };
86
+ /** Environment that ${VAR} references resolve from; defaults to process.env. */
87
+ operatorMcpEnvironment?: NodeJS.ProcessEnv;
64
88
  managedMcp?: { config: string; profile_digest: string };
65
89
  /** Internal trusted host seam; never accepted from ACP or a worker. */
66
90
  managedToolkit?: ToolkitWorkConfig;
@@ -83,11 +107,25 @@ export interface ServiceOptions extends Omit<SupervisorOptions, 'stateDir' | 'br
83
107
  effectConnector?: EffectConnector;
84
108
  approvalTtlMs?: number;
85
109
  codingRoot?: string;
110
+ /** Unprivileged OS user for agent-initiated local commands and edits (Linux, sudo -n -u). */
111
+ toolsUser?: string;
112
+ /** Item 10: the sandbox level for agent-initiated local commands (default workspace-write).
113
+ * full-access is unsandboxed and must be chosen explicitly. */
114
+ localSandbox?: SandboxLevel;
115
+ /** Host config `unsandboxed_commands: "allow"`: run local commands unsandboxed on a host with no
116
+ * usable sandbox. Absent, they are refused (operator ruling 2026-10-30, option A). */
117
+ unsandboxedCommands?: 'allow';
118
+ /** Host config `command_network: "broker"`: sandboxed local commands reach the network only
119
+ * through this stk local-commands socket (operator rulings 2026-10-30). */
120
+ commandNetwork?: { socket: string };
121
+ /** Internal test seam: argv prefix replacing `sudo -n -u <toolsUser> --`. */
122
+ toolsLauncher?: string[];
86
123
  codingSocket?: string;
87
124
  codingBackend?: ExecutionBackend;
88
125
  expectedExecution?: { profile_digest: string; engine_id: string };
89
126
  }
90
127
  export class SalaamService {
128
+ operatorMcpPath?: string;
91
129
  readonly agentpod?: AgentPodAuthority;
92
130
  readonly agentpodWork?: AgentPodWork;
93
131
  readonly toolkitResources: ToolkitResources;
@@ -124,6 +162,7 @@ export class SalaamService {
124
162
  readonly workspaces: Workspaces;
125
163
  readonly coding: CodingWork;
126
164
  readonly localCoding: LocalCoding;
165
+ readonly toolIdentity: ToolIdentity;
127
166
  readonly codingSocket: string | null;
128
167
  readonly closed: Promise<void>;
129
168
  private resolveClosed!: () => void;
@@ -136,12 +175,18 @@ export class SalaamService {
136
175
  private revocationStops = new Set<Promise<void>>();
137
176
  private authorityTimer?: NodeJS.Timeout;
138
177
  private readonly hostManifestDigest: string | null;
178
+ private readonly hostReceipt: Record<string, unknown> | null;
179
+ private readonly operatorAccess: { group: string; gid: number } | null;
139
180
  private readonly agentpodWorkProgression: boolean;
140
181
  private constructor(options: ServiceOptions) {
141
182
  this.agentpodWorkProgression = options.agentpodWorkProgression === true;
142
183
  if (this.agentpodWorkProgression && (!options.stationConfig || !options.agentpodConfig))
143
184
  throw Error('Strict work requires reviewed AgentPod protected station configuration');
144
185
  this.hostManifestDigest = options.hostManifestDigest ?? null;
186
+ this.hostReceipt = options.hostManifestDigest ? (options.hostReceipt ?? null) : null;
187
+ if (options.operatorAccess && !this.agentpodWorkProgression)
188
+ throw Error('Operator group access is only for the strict work launcher profile');
189
+ this.operatorAccess = options.operatorAccess ?? null;
145
190
  if (process.platform === 'win32') throw new Error('Windows transport is not implemented');
146
191
  this.stateDir = resolve(options.stateDir);
147
192
  if (options.managedMcp && !options.agentpodConfig)
@@ -169,6 +214,9 @@ export class SalaamService {
169
214
  this.codingSocket = options.codingSocket ? resolve(options.codingSocket) : null;
170
215
  mkdirSync(this.stateDir, { recursive: true, mode: 0o700 });
171
216
  this.stateDir = realpathSync(this.stateDir);
217
+ this.toolIdentity = options.toolsUser
218
+ ? probeToolsUser(options.toolsUser, this.stateDir, options.toolsLauncher)
219
+ : serviceToolIdentity();
172
220
  const mcpAdmission = options.managedMcp
173
221
  ? new McpAdmission(
174
222
  options.managedMcp.config,
@@ -183,6 +231,11 @@ export class SalaamService {
183
231
  this.release = acquireStateLock(this.stateDir);
184
232
  try {
185
233
  this.store = new Store(join(this.stateDir, 'journal.sqlite'));
234
+ this.store.toolExecution = {
235
+ mode: this.toolIdentity.mode,
236
+ user: this.toolIdentity.user,
237
+ uid: this.toolIdentity.uid,
238
+ };
186
239
  try {
187
240
  this.artifacts = new Artifacts(join(this.stateDir, 'artifacts'), this.store);
188
241
  this.tasks = new TaskContext(this.store, this.artifacts);
@@ -248,6 +301,26 @@ export class SalaamService {
248
301
  this.coding,
249
302
  this.artifacts,
250
303
  );
304
+ // Item 8: the completion critic. Its reviewer grant is the narrow built-in one, admitted
305
+ // only while the task's admitted plan still declares a critic.
306
+ this.taskVerification.critic = new TaskCritic(this.store, this.artifacts, {
307
+ admit: (task, request) =>
308
+ this.agents.admitCritic(task.session_id, request, () => {
309
+ if (!this.taskWork.get(task.id).completion_gate?.critic)
310
+ throw new ServiceError('authority_denied', 'The admitted plan declares no critic');
311
+ }),
312
+ spawn: (task, parentAttempt, step, prompt, budget, grant, snapshot) =>
313
+ this.children.spawnCritic(
314
+ task.session_id,
315
+ parentAttempt,
316
+ step,
317
+ prompt,
318
+ budget,
319
+ grant,
320
+ snapshot,
321
+ ),
322
+ workerId: (childId) => this.store.workerBoundary.view(childId).binding.worker_id,
323
+ });
251
324
  this.taskCheckpoints = new TaskCheckpoints(
252
325
  this.store,
253
326
  this.taskWork,
@@ -262,6 +335,19 @@ export class SalaamService {
262
335
  effectsEnabled: !!options.effectConnector,
263
336
  });
264
337
  this.supervisor.tasks = this.tasks;
338
+ if (options.personaConfig) {
339
+ const persona = loadPersona(options.personaConfig.config);
340
+ if (persona.digest !== options.personaConfig.digest)
341
+ throw Error('Host persona changed; run host plan again');
342
+ this.supervisor.persona = persona;
343
+ }
344
+ if (options.operatorMcpConfig) {
345
+ this.operatorMcpPath = options.operatorMcpConfig;
346
+ this.supervisor.operatorMcp = OperatorMcp.load(
347
+ options.operatorMcpConfig,
348
+ options.operatorMcpEnvironment ?? process.env,
349
+ );
350
+ }
265
351
  this.taskWork.providerCheck = (limits, session) =>
266
352
  this.supervisor.assertTaskProviderSupported(
267
353
  limits,
@@ -283,8 +369,19 @@ export class SalaamService {
283
369
  : this.supervisor.failControl(id, error),
284
370
  });
285
371
  this.supervisor.managed = this.managed;
286
- this.localCoding = new LocalCoding(this.store, this.managed, (id, epoch, attempt) =>
287
- this.supervisor.assertActive(id, epoch, attempt),
372
+ // Item 10: Linux runs local commands under bubblewrap and macOS under Seatbelt when
373
+ // usable; a host with neither refuses them unless the operator allowed them unsandboxed.
374
+ const localSandbox: LocalSandbox = {
375
+ ...chooseLocalSandbox(options.localSandbox ?? 'workspace-write'),
376
+ allowUnsandboxed: options.unsandboxedCommands === 'allow',
377
+ ...(options.commandNetwork ? { network: { socket: options.commandNetwork.socket } } : {}),
378
+ };
379
+ this.localCoding = new LocalCoding(
380
+ this.store,
381
+ this.managed,
382
+ (id, epoch, attempt) => this.supervisor.assertActive(id, epoch, attempt),
383
+ this.toolIdentity,
384
+ localSandbox,
288
385
  );
289
386
  this.supervisor.localCoding = this.localCoding;
290
387
  this.agents = new AgentProfiles(
@@ -459,6 +556,16 @@ export class SalaamService {
459
556
  },
460
557
  );
461
558
  this.supervisor.station = this.station;
559
+ // "Approve for this run": one grant store for decisions and the station.
560
+ this.station.grants = this.runGrants();
561
+ this.station.grantScope = (session) => {
562
+ const row = this.store.db
563
+ .prepare('SELECT task_id FROM agentpod_work WHERE session_id=?')
564
+ .get(session) as { task_id?: string } | undefined;
565
+ if (!row?.task_id) return null;
566
+ const task = this.taskWork.get(row.task_id);
567
+ return { task_id: task.id, plan_revision: task.plan_revision, task_state: task.state };
568
+ };
462
569
  this.taskVerification.installStation(this.station);
463
570
  this.agentpodWork = new AgentPodWork(
464
571
  this.store,
@@ -613,6 +720,7 @@ export class SalaamService {
613
720
  }
614
721
  await service.listen(service.bridgeServer, service.bridgeSocket);
615
722
  await service.listen(service.operator, service.socket);
723
+ service.shareOperatorSocket();
616
724
  service.taskDriver.start();
617
725
  service.jobs.start();
618
726
  service.recurring.start();
@@ -685,6 +793,16 @@ export class SalaamService {
685
793
  }
686
794
  await Promise.allSettled(jobs);
687
795
  }
796
+ /** The launcher profile's only widening: group traverse on state and rw on operator.sock.
797
+ * The bridge socket, journal, keys and every file below state stay owner-only. */
798
+ private shareOperatorSocket(): void {
799
+ if (!this.operatorAccess) return;
800
+ const { gid } = this.operatorAccess;
801
+ chownSync(this.socket, process.getuid!(), gid);
802
+ chmodSync(this.socket, 0o660);
803
+ chownSync(this.stateDir, process.getuid!(), gid);
804
+ chmodSync(this.stateDir, 0o710);
805
+ }
688
806
  private async listen(server: Server, path: string): Promise<void> {
689
807
  // The exclusive state lock is held before removing a stale socket.
690
808
  if (existsSync(path)) unlinkSync(path);
@@ -697,6 +815,11 @@ export class SalaamService {
697
815
  });
698
816
  });
699
817
  }
818
+ private grantStore?: RunGrants;
819
+ /** One "Approve for this run" grant store, shared by decisions and the station. */
820
+ private runGrants(): RunGrants {
821
+ return (this.grantStore ??= new RunGrants(this.store));
822
+ }
700
823
  private send(res: ServerResponse, status: number, value: unknown): void {
701
824
  res.writeHead(status, { 'content-type': 'application/json' });
702
825
  res.end(JSON.stringify(value));
@@ -816,6 +939,50 @@ export class SalaamService {
816
939
  });
817
940
  return;
818
941
  }
942
+ // Item 11b: restore a checkpoint's files, exactly as approved, for an ended run.
943
+ if (parts[2] === 'work-restore') {
944
+ if (!this.agentpodWork)
945
+ throw new ServiceError('work_disabled', 'Strict work is not enabled');
946
+ if (
947
+ Object.keys(body).sort().join(',') !==
948
+ 'approved_by,binding,checkpoint_id,request_id,restore_digest'
949
+ )
950
+ throw new ServiceError('invalid_work_payload', 'Closed restore request required');
951
+ const restoreDigest = string(body.restore_digest, 'restore_digest', 64);
952
+ if (!/^[a-f0-9]{64}$/.test(restoreDigest))
953
+ throw new ServiceError('invalid_work_payload', 'A restore names its exact digest');
954
+ this.send(
955
+ res,
956
+ 200,
957
+ await this.agentpodWork.restore(
958
+ body.binding,
959
+ string(body.request_id, 'request_id', 128),
960
+ string(body.checkpoint_id, 'checkpoint_id', 128),
961
+ restoreDigest,
962
+ string(body.approved_by, 'approved_by', 128),
963
+ ),
964
+ );
965
+ return;
966
+ }
967
+ // Item 11: read-only checkpoints of the bound run and one checkpoint's diff.
968
+ if (parts[2] === 'work-checkpoints' || parts[2] === 'work-checkpoint-diff') {
969
+ if (!this.agentpodWork)
970
+ throw new ServiceError('work_disabled', 'Strict work is not enabled');
971
+ const keys = parts[2] === 'work-checkpoints' ? 'binding' : 'binding,checkpoint_id';
972
+ if (Object.keys(body).sort().join(',') !== keys)
973
+ throw new ServiceError('invalid_work_payload', 'Closed checkpoint request required');
974
+ this.send(
975
+ res,
976
+ 200,
977
+ parts[2] === 'work-checkpoints'
978
+ ? this.agentpodWork.checkpoints(body.binding)
979
+ : await this.agentpodWork.checkpointDiff(
980
+ body.binding,
981
+ string(body.checkpoint_id, 'checkpoint_id', 128),
982
+ ),
983
+ );
984
+ return;
985
+ }
819
986
  if (parts[2] === 'work-status' || parts[2] === 'work-progress') {
820
987
  if (!this.agentpodWork || !this.agentpodWorkProgression)
821
988
  throw new ServiceError('work_disabled', 'Strict progression is not enabled');
@@ -1002,6 +1169,14 @@ export class SalaamService {
1002
1169
  this.send(res, 200, await this.agentpodWork.completion(body.binding));
1003
1170
  return;
1004
1171
  }
1172
+ if (parts[2] === 'work-read') {
1173
+ if (!this.agentpodWork)
1174
+ throw new ServiceError('work_disabled', 'Strict progression is not enabled');
1175
+ if (Object.keys(body).sort().join(',') !== 'answer,binding')
1176
+ throw new ServiceError('invalid_board_read', 'Closed board read answer required');
1177
+ this.send(res, 200, this.agentpodWork.answerBoardRead(body.binding, body.answer));
1178
+ return;
1179
+ }
1005
1180
  if (parts[2] === 'work-decisions' || parts[2] === 'work-decision') {
1006
1181
  if (!this.agentpodWork)
1007
1182
  throw new ServiceError(
@@ -1011,7 +1186,11 @@ export class SalaamService {
1011
1186
  const answering = parts[2] === 'work-decision';
1012
1187
  if (Object.keys(body).sort().join(',') !== (answering ? 'answer,binding' : 'binding'))
1013
1188
  throw new ServiceError('invalid_work_decision', 'Closed decision payload required');
1014
- const decisions = new AgentPodWorkDecisions(this.agentpodWork, this.managed);
1189
+ const decisions = new AgentPodWorkDecisions(
1190
+ this.agentpodWork,
1191
+ this.managed,
1192
+ this.runGrants(),
1193
+ );
1015
1194
  this.send(
1016
1195
  res,
1017
1196
  200,
@@ -1090,7 +1269,18 @@ export class SalaamService {
1090
1269
  }
1091
1270
  const request = string(body.request_id, 'request_id', 128);
1092
1271
  if (parts[2] === 'admit') {
1093
- this.send(res, 200, this.agentpod.admit(body.binding, request));
1272
+ this.send(
1273
+ res,
1274
+ 200,
1275
+ this.agentpod.admit(
1276
+ body.binding,
1277
+ request,
1278
+ body.continue_from === undefined
1279
+ ? undefined
1280
+ : string(body.continue_from, 'continue_from', 128),
1281
+ body.approval_rules,
1282
+ ),
1283
+ );
1094
1284
  return;
1095
1285
  }
1096
1286
  if (parts[2] === 'renew') {
@@ -1101,6 +1291,7 @@ export class SalaamService {
1101
1291
  body.binding,
1102
1292
  request,
1103
1293
  integer(body.lease_sequence, 'lease_sequence'),
1294
+ body.approval_rules,
1104
1295
  ),
1105
1296
  );
1106
1297
  return;
@@ -1664,11 +1855,22 @@ export class SalaamService {
1664
1855
  pid: process.pid,
1665
1856
  host_manifest_digest: this.hostManifestDigest,
1666
1857
  host_supervisor_connected: this.hostManifestDigest ? process.connected === true : null,
1858
+ host_receipt: this.hostReceipt,
1667
1859
  runtime_node: realpathSync(process.execPath),
1668
1860
  service_version: VERSION,
1669
1861
  context_admission: { version: 1, automatic_discovery: false, child_inheritance: false },
1670
1862
  agentpod_config: this.agentpod?.configPath ?? null,
1671
1863
  agentpod_pins_digest: this.agentpod ? digest(this.agentpod.pins) : null,
1864
+ persona: this.supervisor.persona
1865
+ ? { digest: this.supervisor.persona.digest, text: this.supervisor.persona.text }
1866
+ : null,
1867
+ operator_mcp: this.operatorMcpPath
1868
+ ? {
1869
+ config: this.operatorMcpPath,
1870
+ digest: this.supervisor.operatorMcp!.digest,
1871
+ servers: this.supervisor.operatorMcp!.provenance(),
1872
+ }
1873
+ : null,
1672
1874
  managed_mcp: this.mcp
1673
1875
  ? { profile_digest: this.mcp.options.admission.profileDigest }
1674
1876
  : null,
@@ -1680,6 +1882,12 @@ export class SalaamService {
1680
1882
  ? { configuration_digest: this.station.configurationDigest }
1681
1883
  : null,
1682
1884
  service: 'healthy',
1885
+ tool_execution: {
1886
+ mode: this.toolIdentity.mode,
1887
+ user: this.toolIdentity.user,
1888
+ uid: this.toolIdentity.uid,
1889
+ warning: this.toolIdentity.warning,
1890
+ },
1683
1891
  a2a: {
1684
1892
  inbound_enabled: !!this.a2aInbound,
1685
1893
  listener: this.a2aInbound?.server.address() ?? null,
@@ -1741,9 +1949,11 @@ export class SalaamService {
1741
1949
  backend: this.coding.executor?.evidence ?? null,
1742
1950
  }
1743
1951
  : EXECUTION_STATUS,
1952
+ last_activity_at: new Date(this.store.lastActivityAny()).toISOString(),
1744
1953
  sessions: sessions.map((x) => ({
1745
1954
  id: x.id,
1746
1955
  state: x.state,
1956
+ last_activity_at: iso(this.store.lastActivity(x.id)),
1747
1957
  error: x.error,
1748
1958
  capabilities: x.capabilities,
1749
1959
  pending_approvals: this.store
@@ -2011,6 +2221,7 @@ export class SalaamService {
2011
2221
  )
2012
2222
  throw new ServiceError('session_busy', 'Wait for queued work before undo');
2013
2223
  },
2224
+ this.toolIdentity,
2014
2225
  ),
2015
2226
  );
2016
2227
  return;
@@ -2795,6 +3006,7 @@ export class SalaamService {
2795
3006
  await attempt(() => this.toolkitResolutions.close());
2796
3007
  await attempt(() => this.children.close());
2797
3008
  await attempt(() => this.supervisor.close());
3009
+ await attempt(() => this.supervisor.operatorMcp?.close());
2798
3010
  await attempt(() => this.dispatcher.drain());
2799
3011
  await attempt(() => this.managed.close());
2800
3012
  await attempt(() =>
@@ -2826,3 +3038,7 @@ export class SalaamService {
2826
3038
  if (this.shutdownFailure) throw this.shutdownFailure;
2827
3039
  }
2828
3040
  }
3041
+
3042
+ function iso(ms: number | null): string | null {
3043
+ return ms === null ? null : new Date(ms).toISOString();
3044
+ }