@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.
- package/CHANGELOG.md +45 -0
- package/README.md +12 -11
- package/dist/src/acp-outcome.js +177 -17
- package/dist/src/acp.js +41 -7
- package/dist/src/agent-profiles.js +45 -0
- package/dist/src/agentpod-authority.js +169 -11
- package/dist/src/agentpod-default-config.js +12 -0
- package/dist/src/agentpod-work-decision-protocol.js +40 -6
- package/dist/src/agentpod-work-decisions.js +108 -4
- package/dist/src/agentpod-work.js +1214 -127
- package/dist/src/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +27 -2
- package/dist/src/cli.js +87 -3
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +217 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/host-bind.js +111 -0
- package/dist/src/host-breaker.js +88 -0
- package/dist/src/host-start.js +41 -1
- package/dist/src/host.js +192 -6
- package/dist/src/house-extension.js +162 -57
- package/dist/src/local-coding.js +214 -18
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/operator-mcp-cli.js +107 -0
- package/dist/src/operator-mcp.js +342 -0
- package/dist/src/persona.js +31 -0
- package/dist/src/pi.js +22 -1
- package/dist/src/protocol.js +5 -2
- package/dist/src/rate-limit-retries.js +62 -0
- package/dist/src/recurring-work.js +1 -0
- package/dist/src/repository-snapshots.js +5 -2
- package/dist/src/run-grants.js +62 -0
- package/dist/src/service.js +141 -6
- package/dist/src/station-checkpoints.js +139 -0
- package/dist/src/station-config.js +8 -1
- package/dist/src/station-files.js +20 -1
- package/dist/src/station-task-verification.js +6 -3
- package/dist/src/station-work.js +315 -22
- package/dist/src/store.js +19 -0
- package/dist/src/supervisor.js +177 -5
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-critic.js +115 -0
- package/dist/src/task-driver.js +46 -2
- package/dist/src/task-policy.js +44 -12
- package/dist/src/task-verification.js +42 -9
- package/dist/src/task-work.js +47 -0
- package/dist/src/tool-identity.js +69 -0
- package/dist/src/usage.js +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-outcome.ts +169 -19
- package/src/acp.ts +53 -7
- package/src/agent-profiles.ts +48 -0
- package/src/agentpod-authority.ts +234 -15
- package/src/agentpod-default-config.ts +16 -0
- package/src/agentpod-work-decision-protocol.ts +69 -8
- package/src/agentpod-work-decisions.ts +123 -5
- package/src/agentpod-work.ts +1455 -152
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +27 -2
- package/src/cli.ts +109 -3
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +273 -0
- package/src/execution-evidence.ts +31 -1
- package/src/host-bind.ts +156 -0
- package/src/host-breaker.ts +89 -0
- package/src/host-start.ts +51 -1
- package/src/host.ts +244 -6
- package/src/house-extension.ts +174 -56
- package/src/local-coding.ts +270 -18
- package/src/local-edit-helper.ts +26 -0
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi.ts +23 -1
- package/src/protocol.ts +6 -1
- package/src/rate-limit-retries.ts +69 -0
- package/src/recurring-work.ts +1 -0
- package/src/repository-snapshots.ts +5 -2
- package/src/run-grants.ts +107 -0
- package/src/service.ts +222 -6
- package/src/station-checkpoints.ts +183 -0
- package/src/station-config.ts +10 -1
- package/src/station-files.ts +20 -1
- package/src/station-task-verification.ts +6 -3
- package/src/station-work.ts +369 -20
- package/src/store.ts +18 -0
- package/src/supervisor.ts +203 -4
- package/src/task-budget.ts +48 -3
- package/src/task-critic.ts +171 -0
- package/src/task-driver.ts +49 -2
- package/src/task-policy.ts +65 -13
- package/src/task-verification.ts +53 -9
- package/src/task-work.ts +75 -0
- package/src/tool-identity.ts +110 -0
- package/src/usage.ts +1 -1
package/dist/src/service.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(() => {
|