@superjackfruitlabs/salaam 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +12 -11
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +182 -18
- package/dist/src/acp.js +296 -15
- 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/approval-rules.js +287 -0
- package/dist/src/attach.js +3 -1
- package/dist/src/autonomy.js +80 -9
- package/dist/src/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +61 -6
- package/dist/src/cli.js +140 -4
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +327 -0
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- 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 +284 -6
- package/dist/src/house-extension.js +292 -61
- package/dist/src/local-coding.js +726 -35
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-edits.js +168 -6
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/mentions.js +277 -0
- 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-sessions.js +5 -1
- package/dist/src/pi.js +22 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +9 -3
- 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 +482 -10
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- 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 +70 -4
- package/dist/src/supervisor.js +408 -25
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-context.js +8 -2
- 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/terminal-status.js +88 -0
- package/dist/src/tool-identity.js +69 -0
- package/dist/src/tui-activity.js +99 -0
- package/dist/src/tui-classifier.js +25 -0
- package/dist/src/tui-client.js +186 -5
- package/dist/src/tui-commands.js +23 -5
- package/dist/src/tui-composer.js +113 -0
- package/dist/src/tui-keymap.js +116 -0
- package/dist/src/tui-mentions.js +38 -0
- package/dist/src/tui-modals.js +157 -34
- package/dist/src/tui-notify.js +62 -0
- package/dist/src/tui-review.js +55 -0
- package/dist/src/tui-turn-keys.js +52 -0
- package/dist/src/tui-view.js +243 -69
- package/dist/src/tui.js +875 -118
- package/dist/src/turn-checkpoints.js +221 -0
- package/dist/src/turn-rewind.js +168 -0
- package/dist/src/usage.js +1 -1
- package/dist/src/web-fetch.js +114 -0
- package/dist/src/web-guard.js +163 -0
- package/dist/src/web-search.js +302 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-desk.ts +155 -0
- package/src/acp-outcome.ts +174 -20
- package/src/acp.ts +386 -30
- 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/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +60 -6
- package/src/cli.ts +174 -4
- package/src/command-danger.ts +886 -0
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +393 -0
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/execution-evidence.ts +31 -1
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- 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 +365 -6
- package/src/house-extension.ts +313 -60
- package/src/local-coding.ts +894 -43
- package/src/local-edit-helper.ts +26 -0
- package/src/local-edits.ts +191 -8
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/mentions.ts +304 -0
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi-sessions.ts +9 -1
- package/src/pi.ts +23 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +25 -3
- 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 +709 -10
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- 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 +70 -3
- package/src/supervisor.ts +463 -26
- package/src/task-budget.ts +48 -3
- package/src/task-context.ts +9 -2
- 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/terminal-status.ts +103 -0
- package/src/tool-identity.ts +110 -0
- package/src/tui-activity.ts +133 -0
- package/src/tui-classifier.ts +38 -0
- package/src/tui-client.ts +239 -8
- package/src/tui-commands.ts +49 -10
- package/src/tui-composer.ts +124 -0
- package/src/tui-keymap.ts +152 -0
- package/src/tui-mentions.ts +43 -0
- package/src/tui-modals.ts +173 -43
- package/src/tui-notify.ts +77 -0
- package/src/tui-review.ts +76 -0
- package/src/tui-turn-keys.ts +77 -0
- package/src/tui-view.ts +341 -88
- package/src/tui.ts +1047 -137
- package/src/turn-checkpoints.ts +253 -0
- package/src/turn-rewind.ts +258 -0
- package/src/usage.ts +1 -1
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
package/dist/src/service.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { DangerClassifier, loadDangerClassifierOptions } from './danger-classifier.js';
|
|
3
|
+
import { WebSearch, loadWebSearchConfig } from './web-search.js';
|
|
1
4
|
import { createServer } from 'node:http';
|
|
2
|
-
import {
|
|
5
|
+
import { loadPersona } from './persona.js';
|
|
6
|
+
import { mkdirSync, chmodSync, chownSync, existsSync, unlinkSync, realpathSync, statSync, } from 'node:fs';
|
|
3
7
|
import { join, resolve } from 'node:path';
|
|
4
8
|
import { providerRoute } from './provider-config.js';
|
|
5
9
|
import { TaskContext } from './task-context.js';
|
|
6
10
|
import { ContextAdmission } from './context-admission.js';
|
|
11
|
+
import { ProjectInstructions, userInstructionsPath } from './project-instructions.js';
|
|
7
12
|
import { SLASH_COMMANDS } from './tui-commands.js';
|
|
8
13
|
import { TaskWork } from './task-work.js';
|
|
9
14
|
import { TaskBudget } from './task-budget.js';
|
|
10
15
|
import { AgentPodWorkDecisions } from './agentpod-work-decisions.js';
|
|
16
|
+
import { RunGrants } from './run-grants.js';
|
|
17
|
+
import { TaskCritic } from './task-critic.js';
|
|
11
18
|
import { AgentPodWork } from './agentpod-work.js';
|
|
12
19
|
import { agentpodWorkAdmission } from './agentpod-work-protocol.js';
|
|
13
20
|
import { TaskVerification } from './task-verification.js';
|
|
@@ -17,6 +24,7 @@ import { OperatorInbox } from './operator-inbox.js';
|
|
|
17
24
|
import { RecurringWork } from './recurring-work.js';
|
|
18
25
|
import { JobWork } from './job-work.js';
|
|
19
26
|
import { Store } from './store.js';
|
|
27
|
+
import { pickerSummary } from './session-pick.js';
|
|
20
28
|
import { acquireStateLock } from './state-lock.js';
|
|
21
29
|
import { Artifacts } from './artifacts.js';
|
|
22
30
|
import { Supervisor } from './supervisor.js';
|
|
@@ -39,6 +47,7 @@ import { DockerHarnessBackend } from './harness-backend.js';
|
|
|
39
47
|
import { A2AInbound } from './a2a-inbound.js';
|
|
40
48
|
import { AgentPodAuthority, readAgentPodPins } from './agentpod-authority.js';
|
|
41
49
|
import { ToolkitResources } from './toolkit-resources.js';
|
|
50
|
+
import { OperatorMcp } from './operator-mcp.js';
|
|
42
51
|
import { cancelToolkitCalls, recoverToolkitCalls } from './toolkit-calls.js';
|
|
43
52
|
import { ToolkitOwners } from './toolkit-owners.js';
|
|
44
53
|
import { ToolkitResolutions, resolvedToolkitCallSql } from './toolkit-resolutions.js';
|
|
@@ -49,13 +58,20 @@ import { McpAdmission } from './mcp-admission.js';
|
|
|
49
58
|
import { McpWork } from './mcp-work.js';
|
|
50
59
|
import { ToolkitWork } from './toolkit-work.js';
|
|
51
60
|
import { permissionPolicy, configurePermissions } from './autonomy.js';
|
|
61
|
+
import { expandPrompt, storeImages } from './mentions.js';
|
|
62
|
+
import { ApprovalRules } from './approval-rules.js';
|
|
63
|
+
import { chooseLocalSandbox, resolveReadPaths } from './command-sandbox.js';
|
|
52
64
|
import { LocalCoding } from './local-coding.js';
|
|
65
|
+
import { Hooks } from './hooks.js';
|
|
53
66
|
import { localDiff } from './local-diff.js';
|
|
54
67
|
import { reviewUndo, applyUndo, reconcileUndo } from './local-undo.js';
|
|
55
68
|
import { forkPiSession } from './pi-sessions.js';
|
|
69
|
+
import { TurnCheckpoints } from './turn-rewind.js';
|
|
56
70
|
import { StationWork } from './station-work.js';
|
|
57
71
|
import { readToolkitConfig } from './toolkit-config.js';
|
|
72
|
+
import { probeToolsUser, serviceToolIdentity } from './tool-identity.js';
|
|
58
73
|
export class SalaamService {
|
|
74
|
+
operatorMcpPath;
|
|
59
75
|
agentpod;
|
|
60
76
|
agentpodWork;
|
|
61
77
|
toolkitResources;
|
|
@@ -92,6 +108,7 @@ export class SalaamService {
|
|
|
92
108
|
workspaces;
|
|
93
109
|
coding;
|
|
94
110
|
localCoding;
|
|
111
|
+
toolIdentity;
|
|
95
112
|
codingSocket;
|
|
96
113
|
closed;
|
|
97
114
|
resolveClosed;
|
|
@@ -104,12 +121,36 @@ export class SalaamService {
|
|
|
104
121
|
revocationStops = new Set();
|
|
105
122
|
authorityTimer;
|
|
106
123
|
hostManifestDigest;
|
|
124
|
+
hostReceipt;
|
|
125
|
+
operatorAccess;
|
|
107
126
|
agentpodWorkProgression;
|
|
127
|
+
/** Phase 2b: whether interactive sessions here may choose full access (no OS sandbox). */
|
|
128
|
+
allowFullAccessSessions;
|
|
129
|
+
allowYoloSessions;
|
|
130
|
+
reviewers = new Map();
|
|
131
|
+
reviewerDefault = 'off';
|
|
132
|
+
reviewerPin;
|
|
133
|
+
/** The reviewer a session uses: the host's pin, else its own choice, else the config default. */
|
|
134
|
+
reviewerChoice(session) {
|
|
135
|
+
if (this.reviewerPin)
|
|
136
|
+
return this.reviewerPin;
|
|
137
|
+
const row = this.store.db
|
|
138
|
+
.prepare('SELECT choice FROM danger_classifier_choice WHERE session_id=?')
|
|
139
|
+
.get(session);
|
|
140
|
+
const choice = row?.choice ?? this.reviewerDefault;
|
|
141
|
+
return choice === 'off' || this.reviewers.has(choice) ? choice : 'off';
|
|
142
|
+
}
|
|
108
143
|
constructor(options) {
|
|
144
|
+
this.allowFullAccessSessions = options.allowFullAccessSessions !== false;
|
|
145
|
+
this.allowYoloSessions = options.allowYoloSessions !== false;
|
|
109
146
|
this.agentpodWorkProgression = options.agentpodWorkProgression === true;
|
|
110
147
|
if (this.agentpodWorkProgression && (!options.stationConfig || !options.agentpodConfig))
|
|
111
148
|
throw Error('Strict work requires reviewed AgentPod protected station configuration');
|
|
112
149
|
this.hostManifestDigest = options.hostManifestDigest ?? null;
|
|
150
|
+
this.hostReceipt = options.hostManifestDigest ? (options.hostReceipt ?? null) : null;
|
|
151
|
+
if (options.operatorAccess && !this.agentpodWorkProgression)
|
|
152
|
+
throw Error('Operator group access is only for the strict work launcher profile');
|
|
153
|
+
this.operatorAccess = options.operatorAccess ?? null;
|
|
113
154
|
if (process.platform === 'win32')
|
|
114
155
|
throw new Error('Windows transport is not implemented');
|
|
115
156
|
this.stateDir = resolve(options.stateDir);
|
|
@@ -134,20 +175,33 @@ export class SalaamService {
|
|
|
134
175
|
this.codingSocket = options.codingSocket ? resolve(options.codingSocket) : null;
|
|
135
176
|
mkdirSync(this.stateDir, { recursive: true, mode: 0o700 });
|
|
136
177
|
this.stateDir = realpathSync(this.stateDir);
|
|
178
|
+
this.toolIdentity = options.toolsUser
|
|
179
|
+
? probeToolsUser(options.toolsUser, this.stateDir, options.toolsLauncher)
|
|
180
|
+
: serviceToolIdentity();
|
|
137
181
|
const mcpAdmission = options.managedMcp
|
|
138
182
|
? new McpAdmission(options.managedMcp.config, readAgentPodPins(resolve(options.agentpodConfig), this.stateDir), options.managedMcp.profile_digest)
|
|
139
183
|
: undefined;
|
|
140
184
|
this.socket = join(this.stateDir, 'operator.sock');
|
|
141
185
|
this.bridgeSocket = join(this.stateDir, 'bridge.sock');
|
|
142
186
|
if (Buffer.byteLength(this.bridgeSocket) > 100)
|
|
143
|
-
throw new Error(
|
|
187
|
+
throw new Error(`State path is too long for a Unix socket (${Buffer.byteLength(this.bridgeSocket)} bytes; the limit is 100): ` +
|
|
188
|
+
'use the default (salaam code, no --state), or a shorter --state or SALAAM_STATE_DIR such as /tmp/salaam-state.');
|
|
144
189
|
this.release = acquireStateLock(this.stateDir);
|
|
145
190
|
try {
|
|
146
191
|
this.store = new Store(join(this.stateDir, 'journal.sqlite'));
|
|
192
|
+
this.store.toolExecution = {
|
|
193
|
+
mode: this.toolIdentity.mode,
|
|
194
|
+
user: this.toolIdentity.user,
|
|
195
|
+
uid: this.toolIdentity.uid,
|
|
196
|
+
};
|
|
147
197
|
try {
|
|
148
198
|
this.artifacts = new Artifacts(join(this.stateDir, 'artifacts'), this.store);
|
|
149
199
|
this.tasks = new TaskContext(this.store, this.artifacts);
|
|
150
200
|
this.tasks.admitted = new ContextAdmission(this.store);
|
|
201
|
+
this.tasks.instructions = new ProjectInstructions(this.store, this.artifacts, options.userInstructions === undefined
|
|
202
|
+
? userInstructionsPath()
|
|
203
|
+
: options.userInstructions);
|
|
204
|
+
this.tasks.checkpoints = new TurnCheckpoints(this.store, this.stateDir, this.toolIdentity.mode !== 'tools-user');
|
|
151
205
|
this.taskWork = new TaskWork(this.store, this.tasks);
|
|
152
206
|
this.taskBudget = new TaskBudget(this.store, this.taskWork);
|
|
153
207
|
if (options.admitModelRequest) {
|
|
@@ -183,6 +237,16 @@ export class SalaamService {
|
|
|
183
237
|
]);
|
|
184
238
|
this.coding = new CodingWork(this.store, this.workspaces, this.artifacts, executor, (id, error) => this.supervisor.failControl(id, error));
|
|
185
239
|
this.taskVerification = new TaskVerification(this.store, this.taskWork, this.taskBudget, this.workspaces, this.coding, this.artifacts);
|
|
240
|
+
// Item 8: the completion critic. Its reviewer grant is the narrow built-in one, admitted
|
|
241
|
+
// only while the task's admitted plan still declares a critic.
|
|
242
|
+
this.taskVerification.critic = new TaskCritic(this.store, this.artifacts, {
|
|
243
|
+
admit: (task, request) => this.agents.admitCritic(task.session_id, request, () => {
|
|
244
|
+
if (!this.taskWork.get(task.id).completion_gate?.critic)
|
|
245
|
+
throw new ServiceError('authority_denied', 'The admitted plan declares no critic');
|
|
246
|
+
}),
|
|
247
|
+
spawn: (task, parentAttempt, step, prompt, budget, grant, snapshot) => this.children.spawnCritic(task.session_id, parentAttempt, step, prompt, budget, grant, snapshot),
|
|
248
|
+
workerId: (childId) => this.store.workerBoundary.view(childId).binding.worker_id,
|
|
249
|
+
});
|
|
186
250
|
this.taskCheckpoints = new TaskCheckpoints(this.store, this.taskWork, this.taskVerification, this.workspaces, this.artifacts);
|
|
187
251
|
this.supervisor = new Supervisor(this.store, {
|
|
188
252
|
...options,
|
|
@@ -191,6 +255,16 @@ export class SalaamService {
|
|
|
191
255
|
effectsEnabled: !!options.effectConnector,
|
|
192
256
|
});
|
|
193
257
|
this.supervisor.tasks = this.tasks;
|
|
258
|
+
if (options.personaConfig) {
|
|
259
|
+
const persona = loadPersona(options.personaConfig.config);
|
|
260
|
+
if (persona.digest !== options.personaConfig.digest)
|
|
261
|
+
throw Error('Host persona changed; run host plan again');
|
|
262
|
+
this.supervisor.persona = persona;
|
|
263
|
+
}
|
|
264
|
+
if (options.operatorMcpConfig) {
|
|
265
|
+
this.operatorMcpPath = options.operatorMcpConfig;
|
|
266
|
+
this.supervisor.operatorMcp = OperatorMcp.load(options.operatorMcpConfig, options.operatorMcpEnvironment ?? process.env);
|
|
267
|
+
}
|
|
194
268
|
this.taskWork.providerCheck = (limits, session) => this.supervisor.assertTaskProviderSupported(limits, session ? this.store.localOutputCap(session) : undefined);
|
|
195
269
|
this.supervisor.coding = this.coding;
|
|
196
270
|
this.managed = new ManagedWork(this.store, {
|
|
@@ -205,8 +279,48 @@ export class SalaamService {
|
|
|
205
279
|
: this.supervisor.failControl(id, error),
|
|
206
280
|
});
|
|
207
281
|
this.supervisor.managed = this.managed;
|
|
208
|
-
|
|
282
|
+
// Item 10: Linux runs local commands under bubblewrap and macOS under Seatbelt when
|
|
283
|
+
// usable; a host with neither refuses them unless the operator allowed them unsandboxed.
|
|
284
|
+
const localSandbox = {
|
|
285
|
+
...chooseLocalSandbox(options.localSandbox ?? 'workspace-write'),
|
|
286
|
+
allowUnsandboxed: options.unsandboxedCommands === 'allow',
|
|
287
|
+
...(options.localSandboxReadPaths?.length
|
|
288
|
+
? { readPaths: resolveReadPaths(options.localSandboxReadPaths) }
|
|
289
|
+
: {}),
|
|
290
|
+
...(options.commandNetwork ? { network: { socket: options.commandNetwork.socket } } : {}),
|
|
291
|
+
};
|
|
292
|
+
this.localCoding = new LocalCoding(this.store, this.managed, (id, epoch, attempt) => this.supervisor.assertActive(id, epoch, attempt), this.toolIdentity, localSandbox);
|
|
293
|
+
this.localCoding.rules = new ApprovalRules(this.store, options.permissionsFile);
|
|
294
|
+
this.localCoding.interactive = (id) => this.supervisor.isInteractiveCoding(id);
|
|
295
|
+
this.localCoding.allowOutsideSandbox = this.allowFullAccessSessions;
|
|
209
296
|
this.supervisor.localCoding = this.localCoding;
|
|
297
|
+
if (options.dangerClassifierConfig) {
|
|
298
|
+
const loaded = loadDangerClassifierOptions(JSON.parse(readFileSync(options.dangerClassifierConfig, 'utf8')));
|
|
299
|
+
for (const o of loaded.options)
|
|
300
|
+
this.reviewers.set(o.name, new DangerClassifier(o));
|
|
301
|
+
this.reviewerDefault = loaded.default;
|
|
302
|
+
}
|
|
303
|
+
if (options.dangerClassifierPin !== undefined) {
|
|
304
|
+
if (options.dangerClassifierPin !== 'off' &&
|
|
305
|
+
!this.reviewers.has(options.dangerClassifierPin))
|
|
306
|
+
throw Error(`The pinned command reviewer ${options.dangerClassifierPin} is not configured`);
|
|
307
|
+
this.reviewerPin = options.dangerClassifierPin;
|
|
308
|
+
}
|
|
309
|
+
if (this.reviewers.size) {
|
|
310
|
+
this.store.db.exec('CREATE TABLE IF NOT EXISTS danger_classifier_choice(session_id TEXT PRIMARY KEY, choice TEXT NOT NULL)');
|
|
311
|
+
this.localCoding.classifier = {
|
|
312
|
+
review: (s, command, cwd) => {
|
|
313
|
+
const reviewer = this.reviewers.get(this.reviewerChoice(s));
|
|
314
|
+
return reviewer ? reviewer.review(s, command, cwd) : Promise.resolve(null);
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const local = this.localCoding;
|
|
319
|
+
this.supervisor.hooks = new Hooks(this.store, (session, command, input, timeout) => local.runHook(session, command, input, timeout), options.hooksFile);
|
|
320
|
+
const webConfig = options.webSearchConfig
|
|
321
|
+
? loadWebSearchConfig(options.webSearchConfig)
|
|
322
|
+
: { providers: [] };
|
|
323
|
+
this.supervisor.web = new WebSearch(webConfig);
|
|
210
324
|
this.agents = new AgentProfiles(this.store, this.supervisor.route, async (route, session) => {
|
|
211
325
|
const prepared = await this.supervisor.prepareProviderRoute(route);
|
|
212
326
|
const task = this.taskWork.forSession(session);
|
|
@@ -293,6 +407,17 @@ export class SalaamService {
|
|
|
293
407
|
failed: (session, error) => this.supervisor.failControl(session, error),
|
|
294
408
|
});
|
|
295
409
|
this.supervisor.station = this.station;
|
|
410
|
+
// "Approve for this run": one grant store for decisions and the station.
|
|
411
|
+
this.station.grants = this.runGrants();
|
|
412
|
+
this.station.grantScope = (session) => {
|
|
413
|
+
const row = this.store.db
|
|
414
|
+
.prepare('SELECT task_id FROM agentpod_work WHERE session_id=?')
|
|
415
|
+
.get(session);
|
|
416
|
+
if (!row?.task_id)
|
|
417
|
+
return null;
|
|
418
|
+
const task = this.taskWork.get(row.task_id);
|
|
419
|
+
return { task_id: task.id, plan_revision: task.plan_revision, task_state: task.state };
|
|
420
|
+
};
|
|
296
421
|
this.taskVerification.installStation(this.station);
|
|
297
422
|
this.agentpodWork = new AgentPodWork(this.store, this.agentpod, this.taskWork, this.taskVerification);
|
|
298
423
|
const cancelOwned = this.store.cancelChildren;
|
|
@@ -429,6 +554,7 @@ export class SalaamService {
|
|
|
429
554
|
}
|
|
430
555
|
await service.listen(service.bridgeServer, service.bridgeSocket);
|
|
431
556
|
await service.listen(service.operator, service.socket);
|
|
557
|
+
service.shareOperatorSocket();
|
|
432
558
|
service.taskDriver.start();
|
|
433
559
|
service.jobs.start();
|
|
434
560
|
service.recurring.start();
|
|
@@ -490,6 +616,17 @@ export class SalaamService {
|
|
|
490
616
|
}
|
|
491
617
|
await Promise.allSettled(jobs);
|
|
492
618
|
}
|
|
619
|
+
/** The launcher profile's only widening: group traverse on state and rw on operator.sock.
|
|
620
|
+
* The bridge socket, journal, keys and every file below state stay owner-only. */
|
|
621
|
+
shareOperatorSocket() {
|
|
622
|
+
if (!this.operatorAccess)
|
|
623
|
+
return;
|
|
624
|
+
const { gid } = this.operatorAccess;
|
|
625
|
+
chownSync(this.socket, process.getuid(), gid);
|
|
626
|
+
chmodSync(this.socket, 0o660);
|
|
627
|
+
chownSync(this.stateDir, process.getuid(), gid);
|
|
628
|
+
chmodSync(this.stateDir, 0o710);
|
|
629
|
+
}
|
|
493
630
|
async listen(server, path) {
|
|
494
631
|
// The exclusive state lock is held before removing a stale socket.
|
|
495
632
|
if (existsSync(path))
|
|
@@ -503,6 +640,11 @@ export class SalaamService {
|
|
|
503
640
|
});
|
|
504
641
|
});
|
|
505
642
|
}
|
|
643
|
+
grantStore;
|
|
644
|
+
/** One "Approve for this run" grant store, shared by decisions and the station. */
|
|
645
|
+
runGrants() {
|
|
646
|
+
return (this.grantStore ??= new RunGrants(this.store));
|
|
647
|
+
}
|
|
506
648
|
send(res, status, value) {
|
|
507
649
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
508
650
|
res.end(JSON.stringify(value));
|
|
@@ -602,6 +744,31 @@ export class SalaamService {
|
|
|
602
744
|
});
|
|
603
745
|
return;
|
|
604
746
|
}
|
|
747
|
+
// Item 11b: restore a checkpoint's files, exactly as approved, for an ended run.
|
|
748
|
+
if (parts[2] === 'work-restore') {
|
|
749
|
+
if (!this.agentpodWork)
|
|
750
|
+
throw new ServiceError('work_disabled', 'Strict work is not enabled');
|
|
751
|
+
if (Object.keys(body).sort().join(',') !==
|
|
752
|
+
'approved_by,binding,checkpoint_id,request_id,restore_digest')
|
|
753
|
+
throw new ServiceError('invalid_work_payload', 'Closed restore request required');
|
|
754
|
+
const restoreDigest = string(body.restore_digest, 'restore_digest', 64);
|
|
755
|
+
if (!/^[a-f0-9]{64}$/.test(restoreDigest))
|
|
756
|
+
throw new ServiceError('invalid_work_payload', 'A restore names its exact digest');
|
|
757
|
+
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)));
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
// Item 11: read-only checkpoints of the bound run and one checkpoint's diff.
|
|
761
|
+
if (parts[2] === 'work-checkpoints' || parts[2] === 'work-checkpoint-diff') {
|
|
762
|
+
if (!this.agentpodWork)
|
|
763
|
+
throw new ServiceError('work_disabled', 'Strict work is not enabled');
|
|
764
|
+
const keys = parts[2] === 'work-checkpoints' ? 'binding' : 'binding,checkpoint_id';
|
|
765
|
+
if (Object.keys(body).sort().join(',') !== keys)
|
|
766
|
+
throw new ServiceError('invalid_work_payload', 'Closed checkpoint request required');
|
|
767
|
+
this.send(res, 200, parts[2] === 'work-checkpoints'
|
|
768
|
+
? this.agentpodWork.checkpoints(body.binding)
|
|
769
|
+
: await this.agentpodWork.checkpointDiff(body.binding, string(body.checkpoint_id, 'checkpoint_id', 128)));
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
605
772
|
if (parts[2] === 'work-status' || parts[2] === 'work-progress') {
|
|
606
773
|
if (!this.agentpodWork || !this.agentpodWorkProgression)
|
|
607
774
|
throw new ServiceError('work_disabled', 'Strict progression is not enabled');
|
|
@@ -690,13 +857,21 @@ export class SalaamService {
|
|
|
690
857
|
this.send(res, 200, await this.agentpodWork.completion(body.binding));
|
|
691
858
|
return;
|
|
692
859
|
}
|
|
860
|
+
if (parts[2] === 'work-read') {
|
|
861
|
+
if (!this.agentpodWork)
|
|
862
|
+
throw new ServiceError('work_disabled', 'Strict progression is not enabled');
|
|
863
|
+
if (Object.keys(body).sort().join(',') !== 'answer,binding')
|
|
864
|
+
throw new ServiceError('invalid_board_read', 'Closed board read answer required');
|
|
865
|
+
this.send(res, 200, this.agentpodWork.answerBoardRead(body.binding, body.answer));
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
693
868
|
if (parts[2] === 'work-decisions' || parts[2] === 'work-decision') {
|
|
694
869
|
if (!this.agentpodWork)
|
|
695
870
|
throw new ServiceError('verification_unavailable', 'Reviewed protected station backend required');
|
|
696
871
|
const answering = parts[2] === 'work-decision';
|
|
697
872
|
if (Object.keys(body).sort().join(',') !== (answering ? 'answer,binding' : 'binding'))
|
|
698
873
|
throw new ServiceError('invalid_work_decision', 'Closed decision payload required');
|
|
699
|
-
const decisions = new AgentPodWorkDecisions(this.agentpodWork, this.managed);
|
|
874
|
+
const decisions = new AgentPodWorkDecisions(this.agentpodWork, this.managed, this.runGrants());
|
|
700
875
|
this.send(res, 200, answering ? decisions.answer(body.binding, body.answer) : decisions.list(body.binding));
|
|
701
876
|
return;
|
|
702
877
|
}
|
|
@@ -747,11 +922,13 @@ export class SalaamService {
|
|
|
747
922
|
}
|
|
748
923
|
const request = string(body.request_id, 'request_id', 128);
|
|
749
924
|
if (parts[2] === 'admit') {
|
|
750
|
-
this.send(res, 200, this.agentpod.admit(body.binding, request
|
|
925
|
+
this.send(res, 200, this.agentpod.admit(body.binding, request, body.continue_from === undefined
|
|
926
|
+
? undefined
|
|
927
|
+
: string(body.continue_from, 'continue_from', 128), body.approval_rules));
|
|
751
928
|
return;
|
|
752
929
|
}
|
|
753
930
|
if (parts[2] === 'renew') {
|
|
754
|
-
this.send(res, 200, this.agentpod.renew(body.binding, request, integer(body.lease_sequence, 'lease_sequence')));
|
|
931
|
+
this.send(res, 200, this.agentpod.renew(body.binding, request, integer(body.lease_sequence, 'lease_sequence'), body.approval_rules));
|
|
755
932
|
return;
|
|
756
933
|
}
|
|
757
934
|
if (parts[2] === 'revoke') {
|
|
@@ -1070,11 +1247,22 @@ export class SalaamService {
|
|
|
1070
1247
|
pid: process.pid,
|
|
1071
1248
|
host_manifest_digest: this.hostManifestDigest,
|
|
1072
1249
|
host_supervisor_connected: this.hostManifestDigest ? process.connected === true : null,
|
|
1250
|
+
host_receipt: this.hostReceipt,
|
|
1073
1251
|
runtime_node: realpathSync(process.execPath),
|
|
1074
1252
|
service_version: VERSION,
|
|
1075
1253
|
context_admission: { version: 1, automatic_discovery: false, child_inheritance: false },
|
|
1076
1254
|
agentpod_config: this.agentpod?.configPath ?? null,
|
|
1077
1255
|
agentpod_pins_digest: this.agentpod ? digest(this.agentpod.pins) : null,
|
|
1256
|
+
persona: this.supervisor.persona
|
|
1257
|
+
? { digest: this.supervisor.persona.digest, text: this.supervisor.persona.text }
|
|
1258
|
+
: null,
|
|
1259
|
+
operator_mcp: this.operatorMcpPath
|
|
1260
|
+
? {
|
|
1261
|
+
config: this.operatorMcpPath,
|
|
1262
|
+
digest: this.supervisor.operatorMcp.digest,
|
|
1263
|
+
servers: this.supervisor.operatorMcp.provenance(),
|
|
1264
|
+
}
|
|
1265
|
+
: null,
|
|
1078
1266
|
managed_mcp: this.mcp
|
|
1079
1267
|
? { profile_digest: this.mcp.options.admission.profileDigest }
|
|
1080
1268
|
: null,
|
|
@@ -1086,6 +1274,12 @@ export class SalaamService {
|
|
|
1086
1274
|
? { configuration_digest: this.station.configurationDigest }
|
|
1087
1275
|
: null,
|
|
1088
1276
|
service: 'healthy',
|
|
1277
|
+
tool_execution: {
|
|
1278
|
+
mode: this.toolIdentity.mode,
|
|
1279
|
+
user: this.toolIdentity.user,
|
|
1280
|
+
uid: this.toolIdentity.uid,
|
|
1281
|
+
warning: this.toolIdentity.warning,
|
|
1282
|
+
},
|
|
1089
1283
|
a2a: {
|
|
1090
1284
|
inbound_enabled: !!this.a2aInbound,
|
|
1091
1285
|
listener: this.a2aInbound?.server.address() ?? null,
|
|
@@ -1143,9 +1337,11 @@ export class SalaamService {
|
|
|
1143
1337
|
backend: this.coding.executor?.evidence ?? null,
|
|
1144
1338
|
}
|
|
1145
1339
|
: EXECUTION_STATUS,
|
|
1340
|
+
last_activity_at: new Date(this.store.lastActivityAny()).toISOString(),
|
|
1146
1341
|
sessions: sessions.map((x) => ({
|
|
1147
1342
|
id: x.id,
|
|
1148
1343
|
state: x.state,
|
|
1344
|
+
last_activity_at: iso(this.store.lastActivity(x.id)),
|
|
1149
1345
|
error: x.error,
|
|
1150
1346
|
capabilities: x.capabilities,
|
|
1151
1347
|
pending_approvals: this.store
|
|
@@ -1167,7 +1363,10 @@ export class SalaamService {
|
|
|
1167
1363
|
return;
|
|
1168
1364
|
}
|
|
1169
1365
|
if (url.pathname === '/v1/sessions' && req.method === 'GET') {
|
|
1170
|
-
this.send(res, 200, this.store.sessions().map((x) =>
|
|
1366
|
+
this.send(res, 200, this.store.sessions().map((x) => {
|
|
1367
|
+
const session = this.supervisor.inspect(x.id);
|
|
1368
|
+
return { ...session, ...pickerSummary(session, this.store) };
|
|
1369
|
+
}));
|
|
1171
1370
|
return;
|
|
1172
1371
|
}
|
|
1173
1372
|
if (url.pathname === '/v1/sessions' && req.method === 'POST') {
|
|
@@ -1258,13 +1457,52 @@ export class SalaamService {
|
|
|
1258
1457
|
}
|
|
1259
1458
|
if (parts[0] === 'v1' && parts[1] === 'sessions' && parts[2]) {
|
|
1260
1459
|
const id = parts[2];
|
|
1460
|
+
// Desk-session approval rules (phase 2a): list, add (from a reviewed operation or written out),
|
|
1461
|
+
// and remove. Adding or removing needs the session's control lease, like the permission policy.
|
|
1462
|
+
if (parts[3] === 'approval-rules' && (parts.length === 4 || parts.length === 6)) {
|
|
1463
|
+
const rules = this.localCoding.rules;
|
|
1464
|
+
if (parts.length === 4 && req.method === 'GET') {
|
|
1465
|
+
this.send(res, 200, {
|
|
1466
|
+
applies: rules.applies(id),
|
|
1467
|
+
rules: rules.list(id),
|
|
1468
|
+
problems: rules.problems(id),
|
|
1469
|
+
});
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
const body = object(await readJson(req));
|
|
1473
|
+
this.store.authorizeControl(id, {
|
|
1474
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
1475
|
+
token: string(body.token, 'token', 128),
|
|
1476
|
+
});
|
|
1477
|
+
if (parts.length === 4 && req.method === 'POST') {
|
|
1478
|
+
const scope = string(body.scope, 'scope');
|
|
1479
|
+
if (scope !== 'session' && scope !== 'repo')
|
|
1480
|
+
throw new ServiceError('invalid_field', 'A rule scope is session or repo', 400);
|
|
1481
|
+
const request = string(body.request_id, 'request_id', 128);
|
|
1482
|
+
this.send(res, 200, body.operation_id !== undefined
|
|
1483
|
+
? rules.addFromOperation(id, string(body.operation_id, 'operation_id', 128), scope, request)
|
|
1484
|
+
: rules.add(id, scope, body.rule, request));
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (parts.length === 6 && parts[5] === 'remove' && req.method === 'POST') {
|
|
1488
|
+
rules.remove(id, string(parts[4], 'rule', 128));
|
|
1489
|
+
this.send(res, 200, { removed: parts[4] });
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1261
1493
|
if (parts.length === 4 && parts[3] === 'permissions') {
|
|
1262
1494
|
if (req.method === 'GET') {
|
|
1263
1495
|
this.send(res, 200, {
|
|
1264
1496
|
policy: permissionPolicy(this.store, id),
|
|
1265
1497
|
configurable: this.store.localWorkspace(id),
|
|
1266
1498
|
coding: this.store.localCoding(id),
|
|
1267
|
-
|
|
1499
|
+
// Desk coding sessions: approval rules and auto mode can approve commands (Phase 2a/2b).
|
|
1500
|
+
command_auto_approval: this.store.localCoding(id),
|
|
1501
|
+
full_access_allowed: this.allowFullAccessSessions,
|
|
1502
|
+
yolo_allowed: this.allowYoloSessions && !!this.localCoding.rules?.applies(id),
|
|
1503
|
+
...(this.store.localCoding(id)
|
|
1504
|
+
? { command_sandbox: this.localCoding.commandSandbox(id) }
|
|
1505
|
+
: {}),
|
|
1268
1506
|
});
|
|
1269
1507
|
return;
|
|
1270
1508
|
}
|
|
@@ -1273,16 +1511,128 @@ export class SalaamService {
|
|
|
1273
1511
|
this.send(res, 200, configurePermissions(this.store, id, string(body.request_id, 'request_id', 128), integer(body.expected_revision, 'expected_revision'), body.policy, {
|
|
1274
1512
|
client_id: string(body.client_id, 'client_id', 128),
|
|
1275
1513
|
token: string(body.token, 'token', 128),
|
|
1514
|
+
}, {
|
|
1515
|
+
allowFullAccess: this.allowFullAccessSessions,
|
|
1516
|
+
allowYolo: this.allowYoloSessions,
|
|
1517
|
+
deskSession: !!this.localCoding.rules?.applies(id),
|
|
1276
1518
|
}));
|
|
1277
1519
|
return;
|
|
1278
1520
|
}
|
|
1279
1521
|
}
|
|
1522
|
+
if (parts.length === 4 && parts[3] === 'danger-classifier') {
|
|
1523
|
+
const view = () => {
|
|
1524
|
+
const choice = this.reviewers.size ? this.reviewerChoice(id) : 'off', chosen = this.reviewers.get(choice)?.config;
|
|
1525
|
+
return {
|
|
1526
|
+
configured: this.reviewers.size > 0,
|
|
1527
|
+
enabled: !!chosen,
|
|
1528
|
+
...(chosen
|
|
1529
|
+
? {
|
|
1530
|
+
provider: chosen.provider,
|
|
1531
|
+
model: chosen.model,
|
|
1532
|
+
on_unavailable: chosen.on_unavailable,
|
|
1533
|
+
}
|
|
1534
|
+
: {}),
|
|
1535
|
+
choice,
|
|
1536
|
+
pinned: this.reviewerPin !== undefined,
|
|
1537
|
+
options: [...this.reviewers.values()].map(({ config: o }) => ({
|
|
1538
|
+
name: o.name,
|
|
1539
|
+
provider: o.provider,
|
|
1540
|
+
model: o.model,
|
|
1541
|
+
})),
|
|
1542
|
+
};
|
|
1543
|
+
};
|
|
1544
|
+
if (req.method === 'GET') {
|
|
1545
|
+
this.send(res, 200, view());
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (req.method === 'POST') {
|
|
1549
|
+
const body = object(await readJson(req));
|
|
1550
|
+
this.store.authorizeControl(id, {
|
|
1551
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
1552
|
+
token: string(body.token, 'token', 128),
|
|
1553
|
+
});
|
|
1554
|
+
// `enabled` is the earlier on/off form: on means the config's default.
|
|
1555
|
+
const choice = typeof body.enabled === 'boolean'
|
|
1556
|
+
? body.enabled
|
|
1557
|
+
? this.reviewerDefault === 'off'
|
|
1558
|
+
? [...this.reviewers.keys()][0]
|
|
1559
|
+
: this.reviewerDefault
|
|
1560
|
+
: 'off'
|
|
1561
|
+
: string(body.choice, 'choice', 64);
|
|
1562
|
+
if (!this.reviewers.size && choice !== 'off')
|
|
1563
|
+
throw new ServiceError('classifier_unavailable', 'No command reviewer is configured on this host (--danger-classifier-config)');
|
|
1564
|
+
if (this.reviewerPin !== undefined)
|
|
1565
|
+
throw new ServiceError('classifier_pinned', `This host pinned the command reviewer (${this.reviewerPin}); sessions cannot change it`, 403);
|
|
1566
|
+
if (choice !== 'off' && !this.reviewers.has(choice ?? ''))
|
|
1567
|
+
throw new ServiceError('invalid_field', `Unknown reviewer ${choice}`);
|
|
1568
|
+
if (this.reviewers.size)
|
|
1569
|
+
this.store.transaction(() => {
|
|
1570
|
+
this.store.db
|
|
1571
|
+
.prepare('INSERT INTO danger_classifier_choice(session_id,choice) VALUES(?,?) ON CONFLICT(session_id) DO UPDATE SET choice=excluded.choice')
|
|
1572
|
+
.run(id, choice);
|
|
1573
|
+
this.store.event(id, 'permission.classifier-set', { choice });
|
|
1574
|
+
});
|
|
1575
|
+
this.send(res, 200, view());
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
// Desk-session hooks (Phase 4b): list, trust the reviewed repository file, or revoke it.
|
|
1580
|
+
if (parts[3] === 'hooks' && this.supervisor.hooks) {
|
|
1581
|
+
if (!this.supervisor.isInteractiveCoding(id))
|
|
1582
|
+
throw new ServiceError('hooks_denied', 'Hooks run only in your own desk sessions', 403);
|
|
1583
|
+
const hooks = this.supervisor.hooks;
|
|
1584
|
+
if (parts.length === 4 && req.method === 'GET') {
|
|
1585
|
+
this.send(res, 200, { sources: hooks.sources(id) });
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
if (parts.length === 5 && req.method === 'POST') {
|
|
1589
|
+
const body = object(await readJson(req));
|
|
1590
|
+
this.store.authorizeControl(id, {
|
|
1591
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
1592
|
+
token: string(body.token, 'token', 128),
|
|
1593
|
+
});
|
|
1594
|
+
if (parts[4] === 'trust') {
|
|
1595
|
+
this.send(res, 200, hooks.trust(id, string(body.digest, 'digest', 128)));
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
if (parts[4] === 'revoke') {
|
|
1599
|
+
hooks.revoke(id);
|
|
1600
|
+
this.send(res, 200, { revoked: true });
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
if (parts[3] === 'local-tasks' && this.localCoding) {
|
|
1606
|
+
if (!this.supervisor.isInteractiveCoding(id))
|
|
1607
|
+
throw new ServiceError('background_denied', 'Background commands are not admitted', 403);
|
|
1608
|
+
if (parts.length === 4 && req.method === 'GET') {
|
|
1609
|
+
this.send(res, 200, { tasks: this.localCoding.tasks(id) });
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
if (parts.length === 5 && req.method === 'GET') {
|
|
1613
|
+
this.send(res, 200, this.localCoding.taskOutput(id, parts[4]));
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
if (parts.length === 6 && parts[5] === 'stop' && req.method === 'POST') {
|
|
1617
|
+
this.send(res, 200, this.localCoding.stopTask(id, parts[4]));
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1280
1621
|
if (parts.length === 4 && parts[3] === 'local-diff' && req.method === 'GET') {
|
|
1281
1622
|
if (!this.store.localWorkspace(id))
|
|
1282
1623
|
throw new ServiceError('local_denied', 'Local workspace required');
|
|
1283
1624
|
this.send(res, 200, { text: await localDiff(this.store.session(id).cwd) });
|
|
1284
1625
|
return;
|
|
1285
1626
|
}
|
|
1627
|
+
// An edit request's exact files, before and after, so an editor can show it as a diff.
|
|
1628
|
+
if (parts.length === 4 && parts[3] === 'local-edits' && req.method === 'GET') {
|
|
1629
|
+
if (!this.store.localCoding(id))
|
|
1630
|
+
throw new ServiceError('local_denied', 'Local coding session required');
|
|
1631
|
+
this.send(res, 200, this.localCoding.edits(id, url.searchParams.has('operation_id')
|
|
1632
|
+
? { operation: string(url.searchParams.get('operation_id'), 'operation_id', 128) }
|
|
1633
|
+
: { toolCall: string(url.searchParams.get('tool_call'), 'tool_call', 512) }));
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1286
1636
|
if (parts.length === 4 && parts[3] === 'steer' && req.method === 'POST') {
|
|
1287
1637
|
const body = object(await readJson(req));
|
|
1288
1638
|
this.send(res, 200, await this.supervisor.steer(id, string(body.request_id, 'request_id', 128), string(body.text, 'text', 65536), {
|
|
@@ -1315,7 +1665,84 @@ export class SalaamService {
|
|
|
1315
1665
|
.prepare("SELECT 1 FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running')")
|
|
1316
1666
|
.get(id))
|
|
1317
1667
|
throw new ServiceError('session_busy', 'Wait for queued work before undo');
|
|
1318
|
-
}));
|
|
1668
|
+
}, this.toolIdentity));
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
if ((parts.length === 4 || parts.length === 5) &&
|
|
1673
|
+
(parts[3] === 'checkpoints' || parts[3] === 'rewind' || parts[3] === 'review')) {
|
|
1674
|
+
const checkpoints = this.tasks.checkpoints;
|
|
1675
|
+
// Operator ruling 2026-10-31: background commands may keep writing; say so, do not block.
|
|
1676
|
+
const running = (value) => value && this.localCoding
|
|
1677
|
+
? {
|
|
1678
|
+
...value,
|
|
1679
|
+
background_running: this.localCoding
|
|
1680
|
+
.tasks(id)
|
|
1681
|
+
.filter((t) => t.state === 'running').length,
|
|
1682
|
+
}
|
|
1683
|
+
: value;
|
|
1684
|
+
const controller = (body) => ({
|
|
1685
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
1686
|
+
token: string(body.token, 'token', 128),
|
|
1687
|
+
});
|
|
1688
|
+
const idle = () => {
|
|
1689
|
+
this.supervisor.assertIdle(id);
|
|
1690
|
+
if (this.store.db
|
|
1691
|
+
.prepare("SELECT 1 FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running')")
|
|
1692
|
+
.get(id))
|
|
1693
|
+
throw new ServiceError('session_busy', 'Wait for the current turn to finish');
|
|
1694
|
+
};
|
|
1695
|
+
if (parts[3] === 'checkpoints' && parts.length === 4 && req.method === 'GET') {
|
|
1696
|
+
this.send(res, 200, { checkpoints: checkpoints.list(id) });
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'GET') {
|
|
1700
|
+
this.send(res, 200, running(checkpoints.reviewRewind(id, string(url.searchParams.get('attempt'), 'attempt', 128), string(url.searchParams.get('mode'), 'mode', 16))));
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'POST') {
|
|
1704
|
+
if (this.agentpod)
|
|
1705
|
+
throw new ServiceError('admission_required', 'Managed station sessions cannot rewind');
|
|
1706
|
+
const body = object(await readJson(req));
|
|
1707
|
+
const request = string(body.request_id, 'request_id', 128);
|
|
1708
|
+
this.send(res, 200, checkpoints.rewind(id, request, string(body.attempt_id, 'attempt_id', 128), string(body.mode, 'mode', 16), string(body.review_digest, 'review_digest', 128), controller(body), idle, (leaf, summary) => this.store.forkLocal(id, request + ':fork', this.store.session(id).revision, controller(body), (source, target) => {
|
|
1709
|
+
const dir = join(this.stateDir, 'workers', target, 'sessions');
|
|
1710
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1711
|
+
return forkPiSession(source.pi_session_file, source.cwd, dir, target, {
|
|
1712
|
+
leaf,
|
|
1713
|
+
summary,
|
|
1714
|
+
});
|
|
1715
|
+
}, { leaf })));
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
if (parts[3] === 'review' && parts.length === 4 && req.method === 'GET') {
|
|
1719
|
+
this.send(res, 200, { review: running(checkpoints.review(id)) });
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
if (parts[3] === 'review' && parts[4] === 'hunks' && req.method === 'GET') {
|
|
1723
|
+
const q = (name) => string(url.searchParams.get(name), name, 1024);
|
|
1724
|
+
this.send(res, 200, {
|
|
1725
|
+
hunks: checkpoints.hunks(id, q('path'), q('base'), q('current')),
|
|
1726
|
+
});
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
if (parts[3] === 'review' && parts.length === 4 && req.method === 'POST') {
|
|
1730
|
+
const body = object(await readJson(req));
|
|
1731
|
+
const hunk = body.hunk === undefined
|
|
1732
|
+
? undefined
|
|
1733
|
+
: {
|
|
1734
|
+
text: string(object(body.hunk).text, 'hunk.text', 256 * 1024),
|
|
1735
|
+
patch: string(object(body.hunk).patch, 'hunk.patch', 256 * 1024),
|
|
1736
|
+
};
|
|
1737
|
+
this.send(res, 200, {
|
|
1738
|
+
review: running(checkpoints.act(id, {
|
|
1739
|
+
action: string(body.action, 'action', 16),
|
|
1740
|
+
path: string(body.path, 'path', 1024),
|
|
1741
|
+
...(hunk ? { hunk } : {}),
|
|
1742
|
+
base: string(body.base, 'base', 128),
|
|
1743
|
+
current: string(body.current, 'current', 128),
|
|
1744
|
+
}, controller(body), idle)),
|
|
1745
|
+
});
|
|
1319
1746
|
return;
|
|
1320
1747
|
}
|
|
1321
1748
|
}
|
|
@@ -1714,6 +2141,29 @@ export class SalaamService {
|
|
|
1714
2141
|
this.send(res, 200, this.store.control(id, string(body.request_id, 'request_id', 128), string(body.client_id, 'client_id', 128), action, integer(body.expected_epoch, 'expected_epoch'), body.token === undefined ? undefined : string(body.token, 'token', 128)));
|
|
1715
2142
|
return;
|
|
1716
2143
|
}
|
|
2144
|
+
if (parts[3] === 'route' && req.method === 'POST') {
|
|
2145
|
+
const body = object(await readJson(req));
|
|
2146
|
+
this.store.authorizeControl(id, {
|
|
2147
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
2148
|
+
token: string(body.token, 'token', 128),
|
|
2149
|
+
});
|
|
2150
|
+
if (!this.tasks.instructions.applies(id))
|
|
2151
|
+
throw new ServiceError('route_switch_unavailable', 'Only interactive sessions can switch model mid-session', 409);
|
|
2152
|
+
this.supervisor.assertIdle(id);
|
|
2153
|
+
const input = object(body.route);
|
|
2154
|
+
const requested = providerRoute(string(input.provider, 'provider'), input.model === undefined ? undefined : string(input.model, 'model'), undefined, undefined, undefined, input.reasoning);
|
|
2155
|
+
const prepared = await this.supervisor.prepareProviderRoute(requested).catch(() => {
|
|
2156
|
+
throw new ServiceError('provider_auth_required', `Configure ${requested.provider} with sal auth login ${requested.provider} or sal auth set ${requested.provider}, then retry.`, 400);
|
|
2157
|
+
});
|
|
2158
|
+
if (prepared.output_cap !== (this.store.localOutputCap(id) ?? true))
|
|
2159
|
+
throw new ServiceError('provider_output_cap_changed', 'That route enforces output caps differently; start a new session for it', 409);
|
|
2160
|
+
this.supervisor.assertIdle(id);
|
|
2161
|
+
this.store.switchRoute(id, prepared.route);
|
|
2162
|
+
await this.supervisor.stopSession(id);
|
|
2163
|
+
void this.supervisor.start(id).catch(() => { });
|
|
2164
|
+
this.send(res, 200, this.supervisor.inspect(id));
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
1717
2167
|
if (parts[3] === 'resume' && req.method === 'POST') {
|
|
1718
2168
|
const body = object(await readJson(req));
|
|
1719
2169
|
this.store.authorizeControl(id, {
|
|
@@ -1727,6 +2177,13 @@ export class SalaamService {
|
|
|
1727
2177
|
throw new ServiceError('provider_route_changed', 'Restart the service with this session’s original provider route, or create a new session');
|
|
1728
2178
|
if (session.state !== 'ready' && session.state !== 'starting')
|
|
1729
2179
|
void this.supervisor.start(id).catch(() => { });
|
|
2180
|
+
// Show the project instructions an interactive session will use before its first turn.
|
|
2181
|
+
try {
|
|
2182
|
+
this.tasks.instructions?.refresh(id);
|
|
2183
|
+
}
|
|
2184
|
+
catch {
|
|
2185
|
+
/* reported on the next turn */
|
|
2186
|
+
}
|
|
1730
2187
|
this.send(res, 202, this.supervisor.inspect(id));
|
|
1731
2188
|
return;
|
|
1732
2189
|
}
|
|
@@ -1742,12 +2199,23 @@ export class SalaamService {
|
|
|
1742
2199
|
}
|
|
1743
2200
|
if (parts[3] === 'submissions' && req.method === 'POST') {
|
|
1744
2201
|
const body = object(await readJson(req));
|
|
1745
|
-
const
|
|
2202
|
+
const local = this.store.localCoding(id);
|
|
2203
|
+
let text = string(body.text, 'text', 256 * 1024);
|
|
2204
|
+
let images = [];
|
|
2205
|
+
// Desk prompts attach their @mentions (Phase 3b); board work never reads the tree here.
|
|
2206
|
+
if (local) {
|
|
2207
|
+
if (body.confirm_sensitive !== undefined && typeof body.confirm_sensitive !== 'boolean')
|
|
2208
|
+
throw new ServiceError('invalid_field', 'Invalid confirm_sensitive flag', 400);
|
|
2209
|
+
({ text, images } = expandPrompt(this.store.session(id).cwd, text, body.confirm_sensitive === true));
|
|
2210
|
+
}
|
|
2211
|
+
const submission = this.store.accept(id, string(body.request_id, 'request_id', 128), integer(body.expected_revision, 'expected_revision'), text, {
|
|
1746
2212
|
client_id: string(body.client_id, 'client_id', 128),
|
|
1747
2213
|
token: string(body.token, 'token', 128),
|
|
1748
2214
|
}, body.continues_attempt_id === undefined
|
|
1749
2215
|
? null
|
|
1750
2216
|
: string(body.continues_attempt_id, 'continues_attempt_id', 128));
|
|
2217
|
+
// Same tick as the acceptance, so dispatch cannot claim the submission without them.
|
|
2218
|
+
storeImages(this.store.db, submission.id, images);
|
|
1751
2219
|
this.send(res, 202, submission);
|
|
1752
2220
|
return;
|
|
1753
2221
|
}
|
|
@@ -1886,6 +2354,7 @@ export class SalaamService {
|
|
|
1886
2354
|
await attempt(() => this.toolkitResolutions.close());
|
|
1887
2355
|
await attempt(() => this.children.close());
|
|
1888
2356
|
await attempt(() => this.supervisor.close());
|
|
2357
|
+
await attempt(() => this.supervisor.operatorMcp?.close());
|
|
1889
2358
|
await attempt(() => this.dispatcher.drain());
|
|
1890
2359
|
await attempt(() => this.managed.close());
|
|
1891
2360
|
await attempt(() => Promise.all([this.operator, this.bridgeServer].map((server) => new Promise((resolve, reject) => {
|
|
@@ -1909,3 +2378,6 @@ export class SalaamService {
|
|
|
1909
2378
|
throw this.shutdownFailure;
|
|
1910
2379
|
}
|
|
1911
2380
|
}
|
|
2381
|
+
function iso(ms) {
|
|
2382
|
+
return ms === null ? null : new Date(ms).toISOString();
|
|
2383
|
+
}
|