@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
@@ -1,5 +1,6 @@
1
1
  import { createServer } from 'node:http';
2
- import { mkdirSync, chmodSync, existsSync, unlinkSync, realpathSync, statSync } from 'node:fs';
2
+ import { loadPersona } from './persona.js';
3
+ import { mkdirSync, chmodSync, chownSync, existsSync, unlinkSync, realpathSync, statSync, } from 'node:fs';
3
4
  import { join, resolve } from 'node:path';
4
5
  import { providerRoute } from './provider-config.js';
5
6
  import { TaskContext } from './task-context.js';
@@ -8,6 +9,8 @@ import { SLASH_COMMANDS } from './tui-commands.js';
8
9
  import { TaskWork } from './task-work.js';
9
10
  import { TaskBudget } from './task-budget.js';
10
11
  import { AgentPodWorkDecisions } from './agentpod-work-decisions.js';
12
+ import { RunGrants } from './run-grants.js';
13
+ import { TaskCritic } from './task-critic.js';
11
14
  import { AgentPodWork } from './agentpod-work.js';
12
15
  import { agentpodWorkAdmission } from './agentpod-work-protocol.js';
13
16
  import { TaskVerification } from './task-verification.js';
@@ -39,6 +42,7 @@ import { DockerHarnessBackend } from './harness-backend.js';
39
42
  import { A2AInbound } from './a2a-inbound.js';
40
43
  import { AgentPodAuthority, readAgentPodPins } from './agentpod-authority.js';
41
44
  import { ToolkitResources } from './toolkit-resources.js';
45
+ import { OperatorMcp } from './operator-mcp.js';
42
46
  import { cancelToolkitCalls, recoverToolkitCalls } from './toolkit-calls.js';
43
47
  import { ToolkitOwners } from './toolkit-owners.js';
44
48
  import { ToolkitResolutions, resolvedToolkitCallSql } from './toolkit-resolutions.js';
@@ -49,13 +53,16 @@ import { McpAdmission } from './mcp-admission.js';
49
53
  import { McpWork } from './mcp-work.js';
50
54
  import { ToolkitWork } from './toolkit-work.js';
51
55
  import { permissionPolicy, configurePermissions } from './autonomy.js';
56
+ import { chooseLocalSandbox } from './command-sandbox.js';
52
57
  import { LocalCoding } from './local-coding.js';
53
58
  import { localDiff } from './local-diff.js';
54
59
  import { reviewUndo, applyUndo, reconcileUndo } from './local-undo.js';
55
60
  import { forkPiSession } from './pi-sessions.js';
56
61
  import { StationWork } from './station-work.js';
57
62
  import { readToolkitConfig } from './toolkit-config.js';
63
+ import { probeToolsUser, serviceToolIdentity } from './tool-identity.js';
58
64
  export class SalaamService {
65
+ operatorMcpPath;
59
66
  agentpod;
60
67
  agentpodWork;
61
68
  toolkitResources;
@@ -92,6 +99,7 @@ export class SalaamService {
92
99
  workspaces;
93
100
  coding;
94
101
  localCoding;
102
+ toolIdentity;
95
103
  codingSocket;
96
104
  closed;
97
105
  resolveClosed;
@@ -104,12 +112,18 @@ export class SalaamService {
104
112
  revocationStops = new Set();
105
113
  authorityTimer;
106
114
  hostManifestDigest;
115
+ hostReceipt;
116
+ operatorAccess;
107
117
  agentpodWorkProgression;
108
118
  constructor(options) {
109
119
  this.agentpodWorkProgression = options.agentpodWorkProgression === true;
110
120
  if (this.agentpodWorkProgression && (!options.stationConfig || !options.agentpodConfig))
111
121
  throw Error('Strict work requires reviewed AgentPod protected station configuration');
112
122
  this.hostManifestDigest = options.hostManifestDigest ?? null;
123
+ this.hostReceipt = options.hostManifestDigest ? (options.hostReceipt ?? null) : null;
124
+ if (options.operatorAccess && !this.agentpodWorkProgression)
125
+ throw Error('Operator group access is only for the strict work launcher profile');
126
+ this.operatorAccess = options.operatorAccess ?? null;
113
127
  if (process.platform === 'win32')
114
128
  throw new Error('Windows transport is not implemented');
115
129
  this.stateDir = resolve(options.stateDir);
@@ -134,6 +148,9 @@ export class SalaamService {
134
148
  this.codingSocket = options.codingSocket ? resolve(options.codingSocket) : null;
135
149
  mkdirSync(this.stateDir, { recursive: true, mode: 0o700 });
136
150
  this.stateDir = realpathSync(this.stateDir);
151
+ this.toolIdentity = options.toolsUser
152
+ ? probeToolsUser(options.toolsUser, this.stateDir, options.toolsLauncher)
153
+ : serviceToolIdentity();
137
154
  const mcpAdmission = options.managedMcp
138
155
  ? new McpAdmission(options.managedMcp.config, readAgentPodPins(resolve(options.agentpodConfig), this.stateDir), options.managedMcp.profile_digest)
139
156
  : undefined;
@@ -144,6 +161,11 @@ export class SalaamService {
144
161
  this.release = acquireStateLock(this.stateDir);
145
162
  try {
146
163
  this.store = new Store(join(this.stateDir, 'journal.sqlite'));
164
+ this.store.toolExecution = {
165
+ mode: this.toolIdentity.mode,
166
+ user: this.toolIdentity.user,
167
+ uid: this.toolIdentity.uid,
168
+ };
147
169
  try {
148
170
  this.artifacts = new Artifacts(join(this.stateDir, 'artifacts'), this.store);
149
171
  this.tasks = new TaskContext(this.store, this.artifacts);
@@ -183,6 +205,16 @@ export class SalaamService {
183
205
  ]);
184
206
  this.coding = new CodingWork(this.store, this.workspaces, this.artifacts, executor, (id, error) => this.supervisor.failControl(id, error));
185
207
  this.taskVerification = new TaskVerification(this.store, this.taskWork, this.taskBudget, this.workspaces, this.coding, this.artifacts);
208
+ // Item 8: the completion critic. Its reviewer grant is the narrow built-in one, admitted
209
+ // only while the task's admitted plan still declares a critic.
210
+ this.taskVerification.critic = new TaskCritic(this.store, this.artifacts, {
211
+ admit: (task, request) => this.agents.admitCritic(task.session_id, request, () => {
212
+ if (!this.taskWork.get(task.id).completion_gate?.critic)
213
+ throw new ServiceError('authority_denied', 'The admitted plan declares no critic');
214
+ }),
215
+ spawn: (task, parentAttempt, step, prompt, budget, grant, snapshot) => this.children.spawnCritic(task.session_id, parentAttempt, step, prompt, budget, grant, snapshot),
216
+ workerId: (childId) => this.store.workerBoundary.view(childId).binding.worker_id,
217
+ });
186
218
  this.taskCheckpoints = new TaskCheckpoints(this.store, this.taskWork, this.taskVerification, this.workspaces, this.artifacts);
187
219
  this.supervisor = new Supervisor(this.store, {
188
220
  ...options,
@@ -191,6 +223,16 @@ export class SalaamService {
191
223
  effectsEnabled: !!options.effectConnector,
192
224
  });
193
225
  this.supervisor.tasks = this.tasks;
226
+ if (options.personaConfig) {
227
+ const persona = loadPersona(options.personaConfig.config);
228
+ if (persona.digest !== options.personaConfig.digest)
229
+ throw Error('Host persona changed; run host plan again');
230
+ this.supervisor.persona = persona;
231
+ }
232
+ if (options.operatorMcpConfig) {
233
+ this.operatorMcpPath = options.operatorMcpConfig;
234
+ this.supervisor.operatorMcp = OperatorMcp.load(options.operatorMcpConfig, options.operatorMcpEnvironment ?? process.env);
235
+ }
194
236
  this.taskWork.providerCheck = (limits, session) => this.supervisor.assertTaskProviderSupported(limits, session ? this.store.localOutputCap(session) : undefined);
195
237
  this.supervisor.coding = this.coding;
196
238
  this.managed = new ManagedWork(this.store, {
@@ -205,7 +247,14 @@ export class SalaamService {
205
247
  : this.supervisor.failControl(id, error),
206
248
  });
207
249
  this.supervisor.managed = this.managed;
208
- this.localCoding = new LocalCoding(this.store, this.managed, (id, epoch, attempt) => this.supervisor.assertActive(id, epoch, attempt));
250
+ // Item 10: Linux runs local commands under bubblewrap and macOS under Seatbelt when
251
+ // usable; a host with neither refuses them unless the operator allowed them unsandboxed.
252
+ const localSandbox = {
253
+ ...chooseLocalSandbox(options.localSandbox ?? 'workspace-write'),
254
+ allowUnsandboxed: options.unsandboxedCommands === 'allow',
255
+ ...(options.commandNetwork ? { network: { socket: options.commandNetwork.socket } } : {}),
256
+ };
257
+ this.localCoding = new LocalCoding(this.store, this.managed, (id, epoch, attempt) => this.supervisor.assertActive(id, epoch, attempt), this.toolIdentity, localSandbox);
209
258
  this.supervisor.localCoding = this.localCoding;
210
259
  this.agents = new AgentProfiles(this.store, this.supervisor.route, async (route, session) => {
211
260
  const prepared = await this.supervisor.prepareProviderRoute(route);
@@ -293,6 +342,17 @@ export class SalaamService {
293
342
  failed: (session, error) => this.supervisor.failControl(session, error),
294
343
  });
295
344
  this.supervisor.station = this.station;
345
+ // "Approve for this run": one grant store for decisions and the station.
346
+ this.station.grants = this.runGrants();
347
+ this.station.grantScope = (session) => {
348
+ const row = this.store.db
349
+ .prepare('SELECT task_id FROM agentpod_work WHERE session_id=?')
350
+ .get(session);
351
+ if (!row?.task_id)
352
+ return null;
353
+ const task = this.taskWork.get(row.task_id);
354
+ return { task_id: task.id, plan_revision: task.plan_revision, task_state: task.state };
355
+ };
296
356
  this.taskVerification.installStation(this.station);
297
357
  this.agentpodWork = new AgentPodWork(this.store, this.agentpod, this.taskWork, this.taskVerification);
298
358
  const cancelOwned = this.store.cancelChildren;
@@ -429,6 +489,7 @@ export class SalaamService {
429
489
  }
430
490
  await service.listen(service.bridgeServer, service.bridgeSocket);
431
491
  await service.listen(service.operator, service.socket);
492
+ service.shareOperatorSocket();
432
493
  service.taskDriver.start();
433
494
  service.jobs.start();
434
495
  service.recurring.start();
@@ -490,6 +551,17 @@ export class SalaamService {
490
551
  }
491
552
  await Promise.allSettled(jobs);
492
553
  }
554
+ /** The launcher profile's only widening: group traverse on state and rw on operator.sock.
555
+ * The bridge socket, journal, keys and every file below state stay owner-only. */
556
+ shareOperatorSocket() {
557
+ if (!this.operatorAccess)
558
+ return;
559
+ const { gid } = this.operatorAccess;
560
+ chownSync(this.socket, process.getuid(), gid);
561
+ chmodSync(this.socket, 0o660);
562
+ chownSync(this.stateDir, process.getuid(), gid);
563
+ chmodSync(this.stateDir, 0o710);
564
+ }
493
565
  async listen(server, path) {
494
566
  // The exclusive state lock is held before removing a stale socket.
495
567
  if (existsSync(path))
@@ -503,6 +575,11 @@ export class SalaamService {
503
575
  });
504
576
  });
505
577
  }
578
+ grantStore;
579
+ /** One "Approve for this run" grant store, shared by decisions and the station. */
580
+ runGrants() {
581
+ return (this.grantStore ??= new RunGrants(this.store));
582
+ }
506
583
  send(res, status, value) {
507
584
  res.writeHead(status, { 'content-type': 'application/json' });
508
585
  res.end(JSON.stringify(value));
@@ -602,6 +679,31 @@ export class SalaamService {
602
679
  });
603
680
  return;
604
681
  }
682
+ // Item 11b: restore a checkpoint's files, exactly as approved, for an ended run.
683
+ if (parts[2] === 'work-restore') {
684
+ if (!this.agentpodWork)
685
+ throw new ServiceError('work_disabled', 'Strict work is not enabled');
686
+ if (Object.keys(body).sort().join(',') !==
687
+ 'approved_by,binding,checkpoint_id,request_id,restore_digest')
688
+ throw new ServiceError('invalid_work_payload', 'Closed restore request required');
689
+ const restoreDigest = string(body.restore_digest, 'restore_digest', 64);
690
+ if (!/^[a-f0-9]{64}$/.test(restoreDigest))
691
+ throw new ServiceError('invalid_work_payload', 'A restore names its exact digest');
692
+ this.send(res, 200, await this.agentpodWork.restore(body.binding, string(body.request_id, 'request_id', 128), string(body.checkpoint_id, 'checkpoint_id', 128), restoreDigest, string(body.approved_by, 'approved_by', 128)));
693
+ return;
694
+ }
695
+ // Item 11: read-only checkpoints of the bound run and one checkpoint's diff.
696
+ if (parts[2] === 'work-checkpoints' || parts[2] === 'work-checkpoint-diff') {
697
+ if (!this.agentpodWork)
698
+ throw new ServiceError('work_disabled', 'Strict work is not enabled');
699
+ const keys = parts[2] === 'work-checkpoints' ? 'binding' : 'binding,checkpoint_id';
700
+ if (Object.keys(body).sort().join(',') !== keys)
701
+ throw new ServiceError('invalid_work_payload', 'Closed checkpoint request required');
702
+ this.send(res, 200, parts[2] === 'work-checkpoints'
703
+ ? this.agentpodWork.checkpoints(body.binding)
704
+ : await this.agentpodWork.checkpointDiff(body.binding, string(body.checkpoint_id, 'checkpoint_id', 128)));
705
+ return;
706
+ }
605
707
  if (parts[2] === 'work-status' || parts[2] === 'work-progress') {
606
708
  if (!this.agentpodWork || !this.agentpodWorkProgression)
607
709
  throw new ServiceError('work_disabled', 'Strict progression is not enabled');
@@ -690,13 +792,21 @@ export class SalaamService {
690
792
  this.send(res, 200, await this.agentpodWork.completion(body.binding));
691
793
  return;
692
794
  }
795
+ if (parts[2] === 'work-read') {
796
+ if (!this.agentpodWork)
797
+ throw new ServiceError('work_disabled', 'Strict progression is not enabled');
798
+ if (Object.keys(body).sort().join(',') !== 'answer,binding')
799
+ throw new ServiceError('invalid_board_read', 'Closed board read answer required');
800
+ this.send(res, 200, this.agentpodWork.answerBoardRead(body.binding, body.answer));
801
+ return;
802
+ }
693
803
  if (parts[2] === 'work-decisions' || parts[2] === 'work-decision') {
694
804
  if (!this.agentpodWork)
695
805
  throw new ServiceError('verification_unavailable', 'Reviewed protected station backend required');
696
806
  const answering = parts[2] === 'work-decision';
697
807
  if (Object.keys(body).sort().join(',') !== (answering ? 'answer,binding' : 'binding'))
698
808
  throw new ServiceError('invalid_work_decision', 'Closed decision payload required');
699
- const decisions = new AgentPodWorkDecisions(this.agentpodWork, this.managed);
809
+ const decisions = new AgentPodWorkDecisions(this.agentpodWork, this.managed, this.runGrants());
700
810
  this.send(res, 200, answering ? decisions.answer(body.binding, body.answer) : decisions.list(body.binding));
701
811
  return;
702
812
  }
@@ -747,11 +857,13 @@ export class SalaamService {
747
857
  }
748
858
  const request = string(body.request_id, 'request_id', 128);
749
859
  if (parts[2] === 'admit') {
750
- this.send(res, 200, this.agentpod.admit(body.binding, request));
860
+ this.send(res, 200, this.agentpod.admit(body.binding, request, body.continue_from === undefined
861
+ ? undefined
862
+ : string(body.continue_from, 'continue_from', 128), body.approval_rules));
751
863
  return;
752
864
  }
753
865
  if (parts[2] === 'renew') {
754
- this.send(res, 200, this.agentpod.renew(body.binding, request, integer(body.lease_sequence, 'lease_sequence')));
866
+ this.send(res, 200, this.agentpod.renew(body.binding, request, integer(body.lease_sequence, 'lease_sequence'), body.approval_rules));
755
867
  return;
756
868
  }
757
869
  if (parts[2] === 'revoke') {
@@ -1070,11 +1182,22 @@ export class SalaamService {
1070
1182
  pid: process.pid,
1071
1183
  host_manifest_digest: this.hostManifestDigest,
1072
1184
  host_supervisor_connected: this.hostManifestDigest ? process.connected === true : null,
1185
+ host_receipt: this.hostReceipt,
1073
1186
  runtime_node: realpathSync(process.execPath),
1074
1187
  service_version: VERSION,
1075
1188
  context_admission: { version: 1, automatic_discovery: false, child_inheritance: false },
1076
1189
  agentpod_config: this.agentpod?.configPath ?? null,
1077
1190
  agentpod_pins_digest: this.agentpod ? digest(this.agentpod.pins) : null,
1191
+ persona: this.supervisor.persona
1192
+ ? { digest: this.supervisor.persona.digest, text: this.supervisor.persona.text }
1193
+ : null,
1194
+ operator_mcp: this.operatorMcpPath
1195
+ ? {
1196
+ config: this.operatorMcpPath,
1197
+ digest: this.supervisor.operatorMcp.digest,
1198
+ servers: this.supervisor.operatorMcp.provenance(),
1199
+ }
1200
+ : null,
1078
1201
  managed_mcp: this.mcp
1079
1202
  ? { profile_digest: this.mcp.options.admission.profileDigest }
1080
1203
  : null,
@@ -1086,6 +1209,12 @@ export class SalaamService {
1086
1209
  ? { configuration_digest: this.station.configurationDigest }
1087
1210
  : null,
1088
1211
  service: 'healthy',
1212
+ tool_execution: {
1213
+ mode: this.toolIdentity.mode,
1214
+ user: this.toolIdentity.user,
1215
+ uid: this.toolIdentity.uid,
1216
+ warning: this.toolIdentity.warning,
1217
+ },
1089
1218
  a2a: {
1090
1219
  inbound_enabled: !!this.a2aInbound,
1091
1220
  listener: this.a2aInbound?.server.address() ?? null,
@@ -1143,9 +1272,11 @@ export class SalaamService {
1143
1272
  backend: this.coding.executor?.evidence ?? null,
1144
1273
  }
1145
1274
  : EXECUTION_STATUS,
1275
+ last_activity_at: new Date(this.store.lastActivityAny()).toISOString(),
1146
1276
  sessions: sessions.map((x) => ({
1147
1277
  id: x.id,
1148
1278
  state: x.state,
1279
+ last_activity_at: iso(this.store.lastActivity(x.id)),
1149
1280
  error: x.error,
1150
1281
  capabilities: x.capabilities,
1151
1282
  pending_approvals: this.store
@@ -1315,7 +1446,7 @@ export class SalaamService {
1315
1446
  .prepare("SELECT 1 FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running')")
1316
1447
  .get(id))
1317
1448
  throw new ServiceError('session_busy', 'Wait for queued work before undo');
1318
- }));
1449
+ }, this.toolIdentity));
1319
1450
  return;
1320
1451
  }
1321
1452
  }
@@ -1886,6 +2017,7 @@ export class SalaamService {
1886
2017
  await attempt(() => this.toolkitResolutions.close());
1887
2018
  await attempt(() => this.children.close());
1888
2019
  await attempt(() => this.supervisor.close());
2020
+ await attempt(() => this.supervisor.operatorMcp?.close());
1889
2021
  await attempt(() => this.dispatcher.drain());
1890
2022
  await attempt(() => this.managed.close());
1891
2023
  await attempt(() => Promise.all([this.operator, this.bridgeServer].map((server) => new Promise((resolve, reject) => {
@@ -1909,3 +2041,6 @@ export class SalaamService {
1909
2041
  throw this.shutdownFailure;
1910
2042
  }
1911
2043
  }
2044
+ function iso(ms) {
2045
+ return ms === null ? null : new Date(ms).toISOString();
2046
+ }
@@ -0,0 +1,139 @@
1
+ import { digest, ServiceError } from './protocol.js';
2
+ export class StationCheckpoints {
3
+ store;
4
+ constructor(store) {
5
+ this.store = store;
6
+ }
7
+ patches(session) {
8
+ return this.store.db
9
+ .prepare("SELECT rowid,args_json,result_json,attempt_id FROM station_calls WHERE session_id=? AND kind='patch' AND state='returned' ORDER BY rowid")
10
+ .all(session)
11
+ .map((r) => {
12
+ const args = JSON.parse(String(r.args_json)), result = JSON.parse(String(r.result_json));
13
+ return {
14
+ rowid: Number(r.rowid),
15
+ path: String(args.path),
16
+ content: typeof args.content === 'string' ? args.content : null,
17
+ expected_hash: typeof args.expected_hash === 'string' ? args.expected_hash : null,
18
+ before_tree: String(result.before_tree_digest),
19
+ tree: String(result.tree_digest),
20
+ sha256: typeof result.sha256 === 'string' ? result.sha256 : null,
21
+ attempt: String(r.attempt_id),
22
+ };
23
+ });
24
+ }
25
+ list(task) {
26
+ const patches = this.patches(task.session_id);
27
+ const attemptsOf = (step) => new Set(this.store.db
28
+ .prepare('SELECT attempt_id FROM task_step_attempts WHERE step_id=?')
29
+ .all(step)
30
+ .map((r) => String(r.attempt_id)));
31
+ const out = [];
32
+ let at = 0, tree = patches[0]?.before_tree ?? '';
33
+ for (const step of task.steps.filter((s) => s.state === 'completed')) {
34
+ const mine = patches.filter((p) => attemptsOf(step.id).has(p.attempt));
35
+ if (mine.length) {
36
+ at = Math.max(at, ...mine.map((p) => p.rowid));
37
+ tree = patches.find((p) => p.rowid === at).tree;
38
+ }
39
+ out.push({
40
+ id: digest({ task: task.id, plan_revision: task.plan_revision, step: step.id, at, tree }),
41
+ step_id: step.id,
42
+ step_key: step.key,
43
+ tree_digest: tree,
44
+ at,
45
+ files_changed: [...new Set(mine.map((p) => p.path))].sort(),
46
+ });
47
+ }
48
+ return out;
49
+ }
50
+ get(task, id) {
51
+ const found = this.list(task).find((c) => c.id === id);
52
+ if (!found)
53
+ throw new ServiceError('not_found', 'No such checkpoint for this task', 404);
54
+ return found;
55
+ }
56
+ /** A path's content at a checkpoint, from the run's own patches only. */
57
+ fileAt(task, at, path) {
58
+ const patches = this.patches(task.session_id).filter((p) => p.path === path);
59
+ const before = patches.filter((p) => p.rowid <= at).at(-1);
60
+ if (before)
61
+ return before.content === null
62
+ ? { state: 'absent' }
63
+ : { state: 'content', content: before.content };
64
+ const first = patches.find((p) => p.rowid > at);
65
+ return first && first.expected_hash === null ? { state: 'absent' } : { state: 'unknown' };
66
+ }
67
+ /**
68
+ * What restoring this checkpoint would do, against the LIVE workspace (never the run's own
69
+ * receipts alone): every path this run wrote is read as it is now, so a change made outside the
70
+ * run since (an edit, a deletion, a file put back) shows as what the restore would undo. The
71
+ * restore digest binds the live tree, so a restore refuses a workspace that moved after review.
72
+ * Files this run never wrote are left as they are; `drift` says when the tree moved outside it.
73
+ */
74
+ async diff(task, id, live) {
75
+ const checkpoint = this.get(task, id);
76
+ const patches = this.patches(task.session_id);
77
+ const recorded = patches.at(-1)?.tree ?? checkpoint.tree_digest;
78
+ const paths = [...new Set(patches.map((p) => p.path))].sort();
79
+ const files = [];
80
+ for (const path of paths) {
81
+ const now = await live.file(path);
82
+ const then = this.fileAt(task, checkpoint.at, path);
83
+ const change = now.state === 'unreadable' || then.state === 'unknown'
84
+ ? 'not_restorable'
85
+ : then.state === 'absent'
86
+ ? now.state === 'absent'
87
+ ? 'unchanged'
88
+ : 'delete'
89
+ : now.state === 'absent'
90
+ ? 'bring_back'
91
+ : now.content === then.content
92
+ ? 'unchanged'
93
+ : 'revert';
94
+ files.push({
95
+ path,
96
+ current: now.state === 'content' ? now.content : null,
97
+ current_state: now.state,
98
+ at_checkpoint: then,
99
+ change,
100
+ expected_hash: now.state === 'content' ? now.sha256 : null,
101
+ });
102
+ }
103
+ const shown = files.filter((f) => f.change !== 'unchanged');
104
+ const restore_digest = digest({
105
+ task: task.id,
106
+ plan_revision: task.plan_revision,
107
+ checkpoint: checkpoint.id,
108
+ checkpoint_tree: checkpoint.tree_digest,
109
+ recorded_tree: recorded,
110
+ current_tree: live.tree,
111
+ files: shown.map((f) => ({
112
+ path: f.path,
113
+ change: f.change,
114
+ target: f.at_checkpoint,
115
+ expected: f.expected_hash,
116
+ })),
117
+ });
118
+ return {
119
+ checkpoint,
120
+ current_tree: live.tree,
121
+ recorded_tree: recorded,
122
+ drift: live.tree !== recorded,
123
+ restore_digest,
124
+ files: shown.map(({ expected_hash: _, ...f }) => f),
125
+ writes: shown
126
+ .filter((f) => ['revert', 'delete', 'bring_back'].includes(f.change))
127
+ .map((f) => ({
128
+ path: f.path,
129
+ content: f.at_checkpoint.state === 'content' ? f.at_checkpoint.content : null,
130
+ expected_hash: f.expected_hash,
131
+ })),
132
+ };
133
+ }
134
+ /** Item 11b: exactly what a restore of this checkpoint writes, and the digest that binds it. */
135
+ async restorePlan(task, id, live) {
136
+ const { restore_digest, current_tree, writes, checkpoint } = await this.diff(task, id, live);
137
+ return { restore_digest, current_tree, writes, checkpoint };
138
+ }
139
+ }
@@ -106,8 +106,14 @@ export function stationRuntimeFingerprint(root) {
106
106
  export function readStationConfig(path, workspace, expected) {
107
107
  try {
108
108
  const v = object(JSON.parse(stationPrivateFile(path, 16384).toString('utf8')));
109
- if (Object.keys(v).sort().join(',') !==
109
+ const keys = Object.keys(v)
110
+ .filter((k) => k !== 'tools_user')
111
+ .sort()
112
+ .join(',');
113
+ if (keys !==
110
114
  'files,kind,runtime_fingerprint,runtime_image_digest,runtime_root,systemctl,systemd_run,version,workspace,workspace_identity' ||
115
+ (Object.hasOwn(v, 'tools_user') &&
116
+ (typeof v.tools_user !== 'string' || !/^[a-z_][a-z0-9_-]{0,31}$/.test(v.tools_user))) ||
111
117
  v.version !== 1 ||
112
118
  v.kind !== 'linux-systemd-station-v1' ||
113
119
  v.workspace !== workspace ||
@@ -129,6 +135,7 @@ export function readStationConfig(path, workspace, expected) {
129
135
  runtime_root: v.runtime_root,
130
136
  runtime_fingerprint: v.runtime_fingerprint,
131
137
  runtime_image_digest: v.runtime_image_digest,
138
+ ...(Object.hasOwn(v, 'tools_user') ? { tools_user: v.tools_user } : {}),
132
139
  };
133
140
  if (workspace
134
141
  .split(sep)
@@ -18,8 +18,12 @@ export function stationFileResult(request, value) {
18
18
  const fields = keys[String(request.action)];
19
19
  if (!fields)
20
20
  bad();
21
+ // A new file's missing parent directories, created beneath the root, are part of its effect.
22
+ const extra = request.action === 'patch' && 'created_directories' in v ? ['created_directories'] : [];
21
23
  if (Object.keys(v).sort().join(',') !==
22
- ['version', 'ok', 'tree_digest', 'files', 'root_identity', ...fields].sort().join(',') ||
24
+ ['version', 'ok', 'tree_digest', 'files', 'root_identity', ...fields, ...extra]
25
+ .sort()
26
+ .join(',') ||
23
27
  v.version !== 1 ||
24
28
  v.ok !== true ||
25
29
  v.root_identity !== request.root_identity ||
@@ -39,6 +43,8 @@ export function stationFileResult(request, value) {
39
43
  v.sha256 !== stationHash(Buffer.from(v.content)))
40
44
  bad();
41
45
  }
46
+ if (extra.length && !createdParents(String(request.path), v.created_directories, request.content))
47
+ bad();
42
48
  if (request.action === 'patch') {
43
49
  if (v.path !== request.path ||
44
50
  v.before_tree_digest !== request.expected_tree ||
@@ -82,3 +88,16 @@ export function stationFileResult(request, value) {
82
88
  }
83
89
  return v;
84
90
  }
91
+ /** The created directories must be a non-empty, in-order run of the path's own
92
+ * ancestors ending at its parent: each one beneath the last, nothing else. Only a
93
+ * new file creates them. */
94
+ function createdParents(path, dirs, content) {
95
+ if (typeof content !== 'string' || !Array.isArray(dirs) || dirs.length === 0)
96
+ return false;
97
+ const parts = path.split('/');
98
+ if (parts.length < 2 || parts.some((p) => p === '' || p === '.' || p === '..'))
99
+ return false;
100
+ const ancestors = parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join('/'));
101
+ const tail = ancestors.slice(ancestors.length - dirs.length);
102
+ return dirs.length <= ancestors.length && dirs.every((d, i) => d === tail[i]);
103
+ }
@@ -1,3 +1,4 @@
1
+ import { requiredChecks } from './task-policy.js';
1
2
  import { canonical, digest, ServiceError } from './protocol.js';
2
3
  /** Protected station backend for the existing task_checks/completion controller.
3
4
  * It never changes task success. The controller must await withFresh and commit
@@ -122,7 +123,7 @@ export class StationTaskVerification {
122
123
  }
123
124
  async dispatchOwned(id) {
124
125
  const receipt = this.verification.get(id), task = this.tasks.get(receipt.task_id);
125
- const configured = task.acceptance.find((c) => c.key === receipt.check_key);
126
+ const configured = requiredChecks(task).find((c) => c.key === receipt.check_key);
126
127
  if (!configured || configured.cwd !== '.')
127
128
  this.fail();
128
129
  const binding = this.station.ready(task.session_id);
@@ -212,6 +213,7 @@ export class StationTaskVerification {
212
213
  objective: task.objective,
213
214
  constraints: task.constraints,
214
215
  acceptance: task.acceptance,
216
+ ...(task.completion_gate ? { completion_gate: task.completion_gate } : {}),
215
217
  deliverables: task.deliverables,
216
218
  limits: task.limits,
217
219
  steps: task.steps.map(({ key, title, instruction, depends_on, checks }) => ({
@@ -247,7 +249,7 @@ export class StationTaskVerification {
247
249
  });
248
250
  }
249
251
  matches(check, task, call) {
250
- const configured = task.acceptance.find((c) => c.key === check.check_key);
252
+ const configured = requiredChecks(task).find((c) => c.key === check.check_key);
251
253
  const args = JSON.parse(call.args_json);
252
254
  if (check.task_id !== task.id ||
253
255
  check.plan_revision !== task.plan_revision ||
@@ -432,7 +434,8 @@ export class StationTaskVerification {
432
434
  result.tree_digest !== tree ||
433
435
  result.root_identity !== this.station.config.workspace_identity)
434
436
  this.fail();
435
- deliverables.push({ path, sha256: result.sha256 });
437
+ // The verified bytes travel with the digest; completion keeps them as an artifact.
438
+ deliverables.push({ path, sha256: result.sha256, content: result.content });
436
439
  }
437
440
  const final = await this.observe(evidence[0].call_id, this.tasks.get(prepared[0].bound.task_id).session_id, validate);
438
441
  return this.store.transaction(() => {