@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/src/service.ts
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
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, type Server, type ServerResponse, type IncomingMessage } from 'node:http';
|
|
2
|
-
import {
|
|
5
|
+
import { loadPersona } from './persona.js';
|
|
6
|
+
import {
|
|
7
|
+
mkdirSync,
|
|
8
|
+
chmodSync,
|
|
9
|
+
chownSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
realpathSync,
|
|
13
|
+
statSync,
|
|
14
|
+
} from 'node:fs';
|
|
3
15
|
import { join, resolve } from 'node:path';
|
|
4
16
|
import { providerRoute } from './provider-config.js';
|
|
5
17
|
import { TaskContext } from './task-context.js';
|
|
6
18
|
import { ContextAdmission } from './context-admission.js';
|
|
19
|
+
import { ProjectInstructions, userInstructionsPath } from './project-instructions.js';
|
|
7
20
|
import { SLASH_COMMANDS } from './tui-commands.js';
|
|
8
21
|
import { TaskWork } from './task-work.js';
|
|
9
22
|
import { TaskBudget } from './task-budget.js';
|
|
10
23
|
import { AgentPodWorkDecisions } from './agentpod-work-decisions.js';
|
|
24
|
+
import { RunGrants } from './run-grants.js';
|
|
25
|
+
import { TaskCritic } from './task-critic.js';
|
|
11
26
|
import { AgentPodWork } from './agentpod-work.js';
|
|
12
27
|
import { agentpodWorkAdmission } from './agentpod-work-protocol.js';
|
|
13
28
|
import { TaskVerification } from './task-verification.js';
|
|
@@ -17,6 +32,7 @@ import { OperatorInbox } from './operator-inbox.js';
|
|
|
17
32
|
import { RecurringWork } from './recurring-work.js';
|
|
18
33
|
import { JobWork } from './job-work.js';
|
|
19
34
|
import { Store } from './store.js';
|
|
35
|
+
import { pickerSummary } from './session-pick.js';
|
|
20
36
|
import { acquireStateLock } from './state-lock.js';
|
|
21
37
|
import { Artifacts } from './artifacts.js';
|
|
22
38
|
import { Supervisor, type SupervisorOptions } from './supervisor.js';
|
|
@@ -41,6 +57,7 @@ import { DockerHarnessBackend } from './harness-backend.js';
|
|
|
41
57
|
import { A2AInbound } from './a2a-inbound.js';
|
|
42
58
|
import { AgentPodAuthority, readAgentPodPins } from './agentpod-authority.js';
|
|
43
59
|
import { ToolkitResources } from './toolkit-resources.js';
|
|
60
|
+
import { OperatorMcp } from './operator-mcp.js';
|
|
44
61
|
import { cancelToolkitCalls, recoverToolkitCalls } from './toolkit-calls.js';
|
|
45
62
|
import { ToolkitOwners } from './toolkit-owners.js';
|
|
46
63
|
import { ToolkitResolutions, resolvedToolkitCallSql } from './toolkit-resolutions.js';
|
|
@@ -51,16 +68,35 @@ import { McpAdmission } from './mcp-admission.js';
|
|
|
51
68
|
import { McpWork } from './mcp-work.js';
|
|
52
69
|
import { ToolkitWork, type ToolkitWorkConfig } from './toolkit-work.js';
|
|
53
70
|
import { permissionPolicy, configurePermissions } from './autonomy.js';
|
|
54
|
-
import {
|
|
71
|
+
import { expandPrompt, storeImages, type PromptImage } from './mentions.js';
|
|
72
|
+
import { ApprovalRules } from './approval-rules.js';
|
|
73
|
+
import { chooseLocalSandbox, type SandboxLevel, resolveReadPaths } from './command-sandbox.js';
|
|
74
|
+
import { LocalCoding, type LocalSandbox } from './local-coding.js';
|
|
75
|
+
import { Hooks } from './hooks.js';
|
|
55
76
|
import { localDiff } from './local-diff.js';
|
|
56
77
|
import { reviewUndo, applyUndo, reconcileUndo } from './local-undo.js';
|
|
57
78
|
import { forkPiSession } from './pi-sessions.js';
|
|
79
|
+
import { TurnCheckpoints, type RewindMode } from './turn-rewind.js';
|
|
58
80
|
import { StationWork } from './station-work.js';
|
|
59
81
|
import { readToolkitConfig } from './toolkit-config.js';
|
|
82
|
+
import { probeToolsUser, serviceToolIdentity, type ToolIdentity } from './tool-identity.js';
|
|
60
83
|
|
|
61
84
|
export interface ServiceOptions extends Omit<SupervisorOptions, 'stateDir' | 'bridgeSocket'> {
|
|
85
|
+
/** The user-level AGENTS.md for interactive sessions; null turns it off. Default:
|
|
86
|
+
* /salaam/AGENTS.md or ~/.config/salaam/AGENTS.md. */
|
|
87
|
+
userInstructions?: string | null;
|
|
62
88
|
hostManifestDigest?: string;
|
|
89
|
+
/** Receipt fields for the launcher profile; reported only by a manifest-served host. */
|
|
90
|
+
hostReceipt?: Record<string, unknown>;
|
|
91
|
+
/** Launcher profile: one group may traverse state and use the operator socket. */
|
|
92
|
+
operatorAccess?: { group: string; gid: number };
|
|
63
93
|
agentpodConfig?: string;
|
|
94
|
+
/** Operator-declared MCP file (never a workspace path); absent means none declared. */
|
|
95
|
+
operatorMcpConfig?: string;
|
|
96
|
+
/** Item 9: the host's pinned persona file and the digest host plan reviewed. */
|
|
97
|
+
personaConfig?: { config: string; digest: string };
|
|
98
|
+
/** Environment that ${VAR} references resolve from; defaults to process.env. */
|
|
99
|
+
operatorMcpEnvironment?: NodeJS.ProcessEnv;
|
|
64
100
|
managedMcp?: { config: string; profile_digest: string };
|
|
65
101
|
/** Internal trusted host seam; never accepted from ACP or a worker. */
|
|
66
102
|
managedToolkit?: ToolkitWorkConfig;
|
|
@@ -83,11 +119,45 @@ export interface ServiceOptions extends Omit<SupervisorOptions, 'stateDir' | 'br
|
|
|
83
119
|
effectConnector?: EffectConnector;
|
|
84
120
|
approvalTtlMs?: number;
|
|
85
121
|
codingRoot?: string;
|
|
122
|
+
/** Unprivileged OS user for agent-initiated local commands and edits (Linux, sudo -n -u). */
|
|
123
|
+
toolsUser?: string;
|
|
124
|
+
/** Item 10: the sandbox level for agent-initiated local commands (default workspace-write).
|
|
125
|
+
* full-access is unsandboxed and must be chosen explicitly. */
|
|
126
|
+
localSandbox?: SandboxLevel;
|
|
127
|
+
/** Host config `unsandboxed_commands: "allow"`: run local commands unsandboxed on a host with no
|
|
128
|
+
* usable sandbox. Absent, they are refused (operator ruling 2026-10-30, option A). */
|
|
129
|
+
unsandboxedCommands?: 'allow';
|
|
130
|
+
/** Read-only paths outside the workspace local commands may read (resolved, `toolchain` expanded). */
|
|
131
|
+
localSandboxReadPaths?: string[];
|
|
132
|
+
/** Web search config file: optional providers (searxng, brave, tavily, exa) and their key
|
|
133
|
+
* references. Keyless official APIs answer without one (operator ruling 2026-10-31). */
|
|
134
|
+
webSearchConfig?: string;
|
|
135
|
+
/** Phase 4c: the optional small-model command reviewer (a JSON file); absent means off. */
|
|
136
|
+
dangerClassifierConfig?: string;
|
|
137
|
+
/** A host's reviewed pin: one option's name (sessions cannot switch or turn it off), or "off"
|
|
138
|
+
* (the reviewer is forbidden). Absent: sessions choose from the config's options. */
|
|
139
|
+
dangerClassifierPin?: string;
|
|
140
|
+
/** The user's approval rules file (default ~/.config/salaam/permissions.json). */
|
|
141
|
+
permissionsFile?: string;
|
|
142
|
+
/** Your hooks file (Phase 4b); defaults to ~/.config/salaam/hooks.json or $SALAAM_HOOKS_FILE. */
|
|
143
|
+
hooksFile?: string;
|
|
144
|
+
/** Host config `command_network: "broker"`: sandboxed local commands reach the network only
|
|
145
|
+
* through this stk local-commands socket (operator rulings 2026-10-30). */
|
|
146
|
+
commandNetwork?: { socket: string };
|
|
147
|
+
/** Phase 2b: may an interactive session choose full access? Default true for local desk use;
|
|
148
|
+
* a host started from a manifest passes its reviewed `full_access_sessions` setting. */
|
|
149
|
+
allowFullAccessSessions?: boolean;
|
|
150
|
+
/** Whether an interactive session may choose YOLO (no sandbox, no approvals). Desk default allows
|
|
151
|
+
* it; a host started from a manifest passes its reviewed `yolo_sessions` setting. */
|
|
152
|
+
allowYoloSessions?: boolean;
|
|
153
|
+
/** Internal test seam: argv prefix replacing `sudo -n -u <toolsUser> --`. */
|
|
154
|
+
toolsLauncher?: string[];
|
|
86
155
|
codingSocket?: string;
|
|
87
156
|
codingBackend?: ExecutionBackend;
|
|
88
157
|
expectedExecution?: { profile_digest: string; engine_id: string };
|
|
89
158
|
}
|
|
90
159
|
export class SalaamService {
|
|
160
|
+
operatorMcpPath?: string;
|
|
91
161
|
readonly agentpod?: AgentPodAuthority;
|
|
92
162
|
readonly agentpodWork?: AgentPodWork;
|
|
93
163
|
readonly toolkitResources: ToolkitResources;
|
|
@@ -124,6 +194,7 @@ export class SalaamService {
|
|
|
124
194
|
readonly workspaces: Workspaces;
|
|
125
195
|
readonly coding: CodingWork;
|
|
126
196
|
readonly localCoding: LocalCoding;
|
|
197
|
+
readonly toolIdentity: ToolIdentity;
|
|
127
198
|
readonly codingSocket: string | null;
|
|
128
199
|
readonly closed: Promise<void>;
|
|
129
200
|
private resolveClosed!: () => void;
|
|
@@ -136,12 +207,35 @@ export class SalaamService {
|
|
|
136
207
|
private revocationStops = new Set<Promise<void>>();
|
|
137
208
|
private authorityTimer?: NodeJS.Timeout;
|
|
138
209
|
private readonly hostManifestDigest: string | null;
|
|
210
|
+
private readonly hostReceipt: Record<string, unknown> | null;
|
|
211
|
+
private readonly operatorAccess: { group: string; gid: number } | null;
|
|
139
212
|
private readonly agentpodWorkProgression: boolean;
|
|
213
|
+
/** Phase 2b: whether interactive sessions here may choose full access (no OS sandbox). */
|
|
214
|
+
private readonly allowFullAccessSessions: boolean;
|
|
215
|
+
private readonly allowYoloSessions: boolean;
|
|
216
|
+
private readonly reviewers = new Map<string, DangerClassifier>();
|
|
217
|
+
private reviewerDefault = 'off';
|
|
218
|
+
private reviewerPin?: string;
|
|
219
|
+
/** The reviewer a session uses: the host's pin, else its own choice, else the config default. */
|
|
220
|
+
private reviewerChoice(session: string): string {
|
|
221
|
+
if (this.reviewerPin) return this.reviewerPin;
|
|
222
|
+
const row = this.store.db
|
|
223
|
+
.prepare('SELECT choice FROM danger_classifier_choice WHERE session_id=?')
|
|
224
|
+
.get(session) as { choice: string } | undefined;
|
|
225
|
+
const choice = row?.choice ?? this.reviewerDefault;
|
|
226
|
+
return choice === 'off' || this.reviewers.has(choice) ? choice : 'off';
|
|
227
|
+
}
|
|
140
228
|
private constructor(options: ServiceOptions) {
|
|
229
|
+
this.allowFullAccessSessions = options.allowFullAccessSessions !== false;
|
|
230
|
+
this.allowYoloSessions = options.allowYoloSessions !== false;
|
|
141
231
|
this.agentpodWorkProgression = options.agentpodWorkProgression === true;
|
|
142
232
|
if (this.agentpodWorkProgression && (!options.stationConfig || !options.agentpodConfig))
|
|
143
233
|
throw Error('Strict work requires reviewed AgentPod protected station configuration');
|
|
144
234
|
this.hostManifestDigest = options.hostManifestDigest ?? null;
|
|
235
|
+
this.hostReceipt = options.hostManifestDigest ? (options.hostReceipt ?? null) : null;
|
|
236
|
+
if (options.operatorAccess && !this.agentpodWorkProgression)
|
|
237
|
+
throw Error('Operator group access is only for the strict work launcher profile');
|
|
238
|
+
this.operatorAccess = options.operatorAccess ?? null;
|
|
145
239
|
if (process.platform === 'win32') throw new Error('Windows transport is not implemented');
|
|
146
240
|
this.stateDir = resolve(options.stateDir);
|
|
147
241
|
if (options.managedMcp && !options.agentpodConfig)
|
|
@@ -169,6 +263,9 @@ export class SalaamService {
|
|
|
169
263
|
this.codingSocket = options.codingSocket ? resolve(options.codingSocket) : null;
|
|
170
264
|
mkdirSync(this.stateDir, { recursive: true, mode: 0o700 });
|
|
171
265
|
this.stateDir = realpathSync(this.stateDir);
|
|
266
|
+
this.toolIdentity = options.toolsUser
|
|
267
|
+
? probeToolsUser(options.toolsUser, this.stateDir, options.toolsLauncher)
|
|
268
|
+
: serviceToolIdentity();
|
|
172
269
|
const mcpAdmission = options.managedMcp
|
|
173
270
|
? new McpAdmission(
|
|
174
271
|
options.managedMcp.config,
|
|
@@ -179,14 +276,34 @@ export class SalaamService {
|
|
|
179
276
|
this.socket = join(this.stateDir, 'operator.sock');
|
|
180
277
|
this.bridgeSocket = join(this.stateDir, 'bridge.sock');
|
|
181
278
|
if (Buffer.byteLength(this.bridgeSocket) > 100)
|
|
182
|
-
throw new Error(
|
|
279
|
+
throw new Error(
|
|
280
|
+
`State path is too long for a Unix socket (${Buffer.byteLength(this.bridgeSocket)} bytes; the limit is 100): ` +
|
|
281
|
+
'use the default (salaam code, no --state), or a shorter --state or SALAAM_STATE_DIR such as /tmp/salaam-state.',
|
|
282
|
+
);
|
|
183
283
|
this.release = acquireStateLock(this.stateDir);
|
|
184
284
|
try {
|
|
185
285
|
this.store = new Store(join(this.stateDir, 'journal.sqlite'));
|
|
286
|
+
this.store.toolExecution = {
|
|
287
|
+
mode: this.toolIdentity.mode,
|
|
288
|
+
user: this.toolIdentity.user,
|
|
289
|
+
uid: this.toolIdentity.uid,
|
|
290
|
+
};
|
|
186
291
|
try {
|
|
187
292
|
this.artifacts = new Artifacts(join(this.stateDir, 'artifacts'), this.store);
|
|
188
293
|
this.tasks = new TaskContext(this.store, this.artifacts);
|
|
189
294
|
this.tasks.admitted = new ContextAdmission(this.store);
|
|
295
|
+
this.tasks.instructions = new ProjectInstructions(
|
|
296
|
+
this.store,
|
|
297
|
+
this.artifacts,
|
|
298
|
+
options.userInstructions === undefined
|
|
299
|
+
? userInstructionsPath()
|
|
300
|
+
: options.userInstructions,
|
|
301
|
+
);
|
|
302
|
+
this.tasks.checkpoints = new TurnCheckpoints(
|
|
303
|
+
this.store,
|
|
304
|
+
this.stateDir,
|
|
305
|
+
this.toolIdentity.mode !== 'tools-user',
|
|
306
|
+
);
|
|
190
307
|
this.taskWork = new TaskWork(this.store, this.tasks);
|
|
191
308
|
this.taskBudget = new TaskBudget(this.store, this.taskWork);
|
|
192
309
|
if (options.admitModelRequest) {
|
|
@@ -248,6 +365,26 @@ export class SalaamService {
|
|
|
248
365
|
this.coding,
|
|
249
366
|
this.artifacts,
|
|
250
367
|
);
|
|
368
|
+
// Item 8: the completion critic. Its reviewer grant is the narrow built-in one, admitted
|
|
369
|
+
// only while the task's admitted plan still declares a critic.
|
|
370
|
+
this.taskVerification.critic = new TaskCritic(this.store, this.artifacts, {
|
|
371
|
+
admit: (task, request) =>
|
|
372
|
+
this.agents.admitCritic(task.session_id, request, () => {
|
|
373
|
+
if (!this.taskWork.get(task.id).completion_gate?.critic)
|
|
374
|
+
throw new ServiceError('authority_denied', 'The admitted plan declares no critic');
|
|
375
|
+
}),
|
|
376
|
+
spawn: (task, parentAttempt, step, prompt, budget, grant, snapshot) =>
|
|
377
|
+
this.children.spawnCritic(
|
|
378
|
+
task.session_id,
|
|
379
|
+
parentAttempt,
|
|
380
|
+
step,
|
|
381
|
+
prompt,
|
|
382
|
+
budget,
|
|
383
|
+
grant,
|
|
384
|
+
snapshot,
|
|
385
|
+
),
|
|
386
|
+
workerId: (childId) => this.store.workerBoundary.view(childId).binding.worker_id,
|
|
387
|
+
});
|
|
251
388
|
this.taskCheckpoints = new TaskCheckpoints(
|
|
252
389
|
this.store,
|
|
253
390
|
this.taskWork,
|
|
@@ -262,6 +399,19 @@ export class SalaamService {
|
|
|
262
399
|
effectsEnabled: !!options.effectConnector,
|
|
263
400
|
});
|
|
264
401
|
this.supervisor.tasks = this.tasks;
|
|
402
|
+
if (options.personaConfig) {
|
|
403
|
+
const persona = loadPersona(options.personaConfig.config);
|
|
404
|
+
if (persona.digest !== options.personaConfig.digest)
|
|
405
|
+
throw Error('Host persona changed; run host plan again');
|
|
406
|
+
this.supervisor.persona = persona;
|
|
407
|
+
}
|
|
408
|
+
if (options.operatorMcpConfig) {
|
|
409
|
+
this.operatorMcpPath = options.operatorMcpConfig;
|
|
410
|
+
this.supervisor.operatorMcp = OperatorMcp.load(
|
|
411
|
+
options.operatorMcpConfig,
|
|
412
|
+
options.operatorMcpEnvironment ?? process.env,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
265
415
|
this.taskWork.providerCheck = (limits, session) =>
|
|
266
416
|
this.supervisor.assertTaskProviderSupported(
|
|
267
417
|
limits,
|
|
@@ -283,10 +433,65 @@ export class SalaamService {
|
|
|
283
433
|
: this.supervisor.failControl(id, error),
|
|
284
434
|
});
|
|
285
435
|
this.supervisor.managed = this.managed;
|
|
286
|
-
|
|
287
|
-
|
|
436
|
+
// Item 10: Linux runs local commands under bubblewrap and macOS under Seatbelt when
|
|
437
|
+
// usable; a host with neither refuses them unless the operator allowed them unsandboxed.
|
|
438
|
+
const localSandbox: LocalSandbox = {
|
|
439
|
+
...chooseLocalSandbox(options.localSandbox ?? 'workspace-write'),
|
|
440
|
+
allowUnsandboxed: options.unsandboxedCommands === 'allow',
|
|
441
|
+
...(options.localSandboxReadPaths?.length
|
|
442
|
+
? { readPaths: resolveReadPaths(options.localSandboxReadPaths) }
|
|
443
|
+
: {}),
|
|
444
|
+
...(options.commandNetwork ? { network: { socket: options.commandNetwork.socket } } : {}),
|
|
445
|
+
};
|
|
446
|
+
this.localCoding = new LocalCoding(
|
|
447
|
+
this.store,
|
|
448
|
+
this.managed,
|
|
449
|
+
(id, epoch, attempt) => this.supervisor.assertActive(id, epoch, attempt),
|
|
450
|
+
this.toolIdentity,
|
|
451
|
+
localSandbox,
|
|
288
452
|
);
|
|
453
|
+
this.localCoding.rules = new ApprovalRules(this.store, options.permissionsFile);
|
|
454
|
+
this.localCoding.interactive = (id) => this.supervisor.isInteractiveCoding(id);
|
|
455
|
+
this.localCoding.allowOutsideSandbox = this.allowFullAccessSessions;
|
|
289
456
|
this.supervisor.localCoding = this.localCoding;
|
|
457
|
+
if (options.dangerClassifierConfig) {
|
|
458
|
+
const loaded = loadDangerClassifierOptions(
|
|
459
|
+
JSON.parse(readFileSync(options.dangerClassifierConfig, 'utf8')),
|
|
460
|
+
);
|
|
461
|
+
for (const o of loaded.options) this.reviewers.set(o.name, new DangerClassifier(o));
|
|
462
|
+
this.reviewerDefault = loaded.default;
|
|
463
|
+
}
|
|
464
|
+
if (options.dangerClassifierPin !== undefined) {
|
|
465
|
+
if (
|
|
466
|
+
options.dangerClassifierPin !== 'off' &&
|
|
467
|
+
!this.reviewers.has(options.dangerClassifierPin)
|
|
468
|
+
)
|
|
469
|
+
throw Error(
|
|
470
|
+
`The pinned command reviewer ${options.dangerClassifierPin} is not configured`,
|
|
471
|
+
);
|
|
472
|
+
this.reviewerPin = options.dangerClassifierPin;
|
|
473
|
+
}
|
|
474
|
+
if (this.reviewers.size) {
|
|
475
|
+
this.store.db.exec(
|
|
476
|
+
'CREATE TABLE IF NOT EXISTS danger_classifier_choice(session_id TEXT PRIMARY KEY, choice TEXT NOT NULL)',
|
|
477
|
+
);
|
|
478
|
+
this.localCoding.classifier = {
|
|
479
|
+
review: (s, command, cwd) => {
|
|
480
|
+
const reviewer = this.reviewers.get(this.reviewerChoice(s));
|
|
481
|
+
return reviewer ? reviewer.review(s, command, cwd) : Promise.resolve(null);
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
const local = this.localCoding;
|
|
486
|
+
this.supervisor.hooks = new Hooks(
|
|
487
|
+
this.store,
|
|
488
|
+
(session, command, input, timeout) => local.runHook(session, command, input, timeout),
|
|
489
|
+
options.hooksFile,
|
|
490
|
+
);
|
|
491
|
+
const webConfig = options.webSearchConfig
|
|
492
|
+
? loadWebSearchConfig(options.webSearchConfig)
|
|
493
|
+
: { providers: [] };
|
|
494
|
+
this.supervisor.web = new WebSearch(webConfig);
|
|
290
495
|
this.agents = new AgentProfiles(
|
|
291
496
|
this.store,
|
|
292
497
|
this.supervisor.route,
|
|
@@ -459,6 +664,16 @@ export class SalaamService {
|
|
|
459
664
|
},
|
|
460
665
|
);
|
|
461
666
|
this.supervisor.station = this.station;
|
|
667
|
+
// "Approve for this run": one grant store for decisions and the station.
|
|
668
|
+
this.station.grants = this.runGrants();
|
|
669
|
+
this.station.grantScope = (session) => {
|
|
670
|
+
const row = this.store.db
|
|
671
|
+
.prepare('SELECT task_id FROM agentpod_work WHERE session_id=?')
|
|
672
|
+
.get(session) as { task_id?: string } | undefined;
|
|
673
|
+
if (!row?.task_id) return null;
|
|
674
|
+
const task = this.taskWork.get(row.task_id);
|
|
675
|
+
return { task_id: task.id, plan_revision: task.plan_revision, task_state: task.state };
|
|
676
|
+
};
|
|
462
677
|
this.taskVerification.installStation(this.station);
|
|
463
678
|
this.agentpodWork = new AgentPodWork(
|
|
464
679
|
this.store,
|
|
@@ -613,6 +828,7 @@ export class SalaamService {
|
|
|
613
828
|
}
|
|
614
829
|
await service.listen(service.bridgeServer, service.bridgeSocket);
|
|
615
830
|
await service.listen(service.operator, service.socket);
|
|
831
|
+
service.shareOperatorSocket();
|
|
616
832
|
service.taskDriver.start();
|
|
617
833
|
service.jobs.start();
|
|
618
834
|
service.recurring.start();
|
|
@@ -685,6 +901,16 @@ export class SalaamService {
|
|
|
685
901
|
}
|
|
686
902
|
await Promise.allSettled(jobs);
|
|
687
903
|
}
|
|
904
|
+
/** The launcher profile's only widening: group traverse on state and rw on operator.sock.
|
|
905
|
+
* The bridge socket, journal, keys and every file below state stay owner-only. */
|
|
906
|
+
private shareOperatorSocket(): void {
|
|
907
|
+
if (!this.operatorAccess) return;
|
|
908
|
+
const { gid } = this.operatorAccess;
|
|
909
|
+
chownSync(this.socket, process.getuid!(), gid);
|
|
910
|
+
chmodSync(this.socket, 0o660);
|
|
911
|
+
chownSync(this.stateDir, process.getuid!(), gid);
|
|
912
|
+
chmodSync(this.stateDir, 0o710);
|
|
913
|
+
}
|
|
688
914
|
private async listen(server: Server, path: string): Promise<void> {
|
|
689
915
|
// The exclusive state lock is held before removing a stale socket.
|
|
690
916
|
if (existsSync(path)) unlinkSync(path);
|
|
@@ -697,6 +923,11 @@ export class SalaamService {
|
|
|
697
923
|
});
|
|
698
924
|
});
|
|
699
925
|
}
|
|
926
|
+
private grantStore?: RunGrants;
|
|
927
|
+
/** One "Approve for this run" grant store, shared by decisions and the station. */
|
|
928
|
+
private runGrants(): RunGrants {
|
|
929
|
+
return (this.grantStore ??= new RunGrants(this.store));
|
|
930
|
+
}
|
|
700
931
|
private send(res: ServerResponse, status: number, value: unknown): void {
|
|
701
932
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
702
933
|
res.end(JSON.stringify(value));
|
|
@@ -816,6 +1047,50 @@ export class SalaamService {
|
|
|
816
1047
|
});
|
|
817
1048
|
return;
|
|
818
1049
|
}
|
|
1050
|
+
// Item 11b: restore a checkpoint's files, exactly as approved, for an ended run.
|
|
1051
|
+
if (parts[2] === 'work-restore') {
|
|
1052
|
+
if (!this.agentpodWork)
|
|
1053
|
+
throw new ServiceError('work_disabled', 'Strict work is not enabled');
|
|
1054
|
+
if (
|
|
1055
|
+
Object.keys(body).sort().join(',') !==
|
|
1056
|
+
'approved_by,binding,checkpoint_id,request_id,restore_digest'
|
|
1057
|
+
)
|
|
1058
|
+
throw new ServiceError('invalid_work_payload', 'Closed restore request required');
|
|
1059
|
+
const restoreDigest = string(body.restore_digest, 'restore_digest', 64);
|
|
1060
|
+
if (!/^[a-f0-9]{64}$/.test(restoreDigest))
|
|
1061
|
+
throw new ServiceError('invalid_work_payload', 'A restore names its exact digest');
|
|
1062
|
+
this.send(
|
|
1063
|
+
res,
|
|
1064
|
+
200,
|
|
1065
|
+
await this.agentpodWork.restore(
|
|
1066
|
+
body.binding,
|
|
1067
|
+
string(body.request_id, 'request_id', 128),
|
|
1068
|
+
string(body.checkpoint_id, 'checkpoint_id', 128),
|
|
1069
|
+
restoreDigest,
|
|
1070
|
+
string(body.approved_by, 'approved_by', 128),
|
|
1071
|
+
),
|
|
1072
|
+
);
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
// Item 11: read-only checkpoints of the bound run and one checkpoint's diff.
|
|
1076
|
+
if (parts[2] === 'work-checkpoints' || parts[2] === 'work-checkpoint-diff') {
|
|
1077
|
+
if (!this.agentpodWork)
|
|
1078
|
+
throw new ServiceError('work_disabled', 'Strict work is not enabled');
|
|
1079
|
+
const keys = parts[2] === 'work-checkpoints' ? 'binding' : 'binding,checkpoint_id';
|
|
1080
|
+
if (Object.keys(body).sort().join(',') !== keys)
|
|
1081
|
+
throw new ServiceError('invalid_work_payload', 'Closed checkpoint request required');
|
|
1082
|
+
this.send(
|
|
1083
|
+
res,
|
|
1084
|
+
200,
|
|
1085
|
+
parts[2] === 'work-checkpoints'
|
|
1086
|
+
? this.agentpodWork.checkpoints(body.binding)
|
|
1087
|
+
: await this.agentpodWork.checkpointDiff(
|
|
1088
|
+
body.binding,
|
|
1089
|
+
string(body.checkpoint_id, 'checkpoint_id', 128),
|
|
1090
|
+
),
|
|
1091
|
+
);
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
819
1094
|
if (parts[2] === 'work-status' || parts[2] === 'work-progress') {
|
|
820
1095
|
if (!this.agentpodWork || !this.agentpodWorkProgression)
|
|
821
1096
|
throw new ServiceError('work_disabled', 'Strict progression is not enabled');
|
|
@@ -1002,6 +1277,14 @@ export class SalaamService {
|
|
|
1002
1277
|
this.send(res, 200, await this.agentpodWork.completion(body.binding));
|
|
1003
1278
|
return;
|
|
1004
1279
|
}
|
|
1280
|
+
if (parts[2] === 'work-read') {
|
|
1281
|
+
if (!this.agentpodWork)
|
|
1282
|
+
throw new ServiceError('work_disabled', 'Strict progression is not enabled');
|
|
1283
|
+
if (Object.keys(body).sort().join(',') !== 'answer,binding')
|
|
1284
|
+
throw new ServiceError('invalid_board_read', 'Closed board read answer required');
|
|
1285
|
+
this.send(res, 200, this.agentpodWork.answerBoardRead(body.binding, body.answer));
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1005
1288
|
if (parts[2] === 'work-decisions' || parts[2] === 'work-decision') {
|
|
1006
1289
|
if (!this.agentpodWork)
|
|
1007
1290
|
throw new ServiceError(
|
|
@@ -1011,7 +1294,11 @@ export class SalaamService {
|
|
|
1011
1294
|
const answering = parts[2] === 'work-decision';
|
|
1012
1295
|
if (Object.keys(body).sort().join(',') !== (answering ? 'answer,binding' : 'binding'))
|
|
1013
1296
|
throw new ServiceError('invalid_work_decision', 'Closed decision payload required');
|
|
1014
|
-
const decisions = new AgentPodWorkDecisions(
|
|
1297
|
+
const decisions = new AgentPodWorkDecisions(
|
|
1298
|
+
this.agentpodWork,
|
|
1299
|
+
this.managed,
|
|
1300
|
+
this.runGrants(),
|
|
1301
|
+
);
|
|
1015
1302
|
this.send(
|
|
1016
1303
|
res,
|
|
1017
1304
|
200,
|
|
@@ -1090,7 +1377,18 @@ export class SalaamService {
|
|
|
1090
1377
|
}
|
|
1091
1378
|
const request = string(body.request_id, 'request_id', 128);
|
|
1092
1379
|
if (parts[2] === 'admit') {
|
|
1093
|
-
this.send(
|
|
1380
|
+
this.send(
|
|
1381
|
+
res,
|
|
1382
|
+
200,
|
|
1383
|
+
this.agentpod.admit(
|
|
1384
|
+
body.binding,
|
|
1385
|
+
request,
|
|
1386
|
+
body.continue_from === undefined
|
|
1387
|
+
? undefined
|
|
1388
|
+
: string(body.continue_from, 'continue_from', 128),
|
|
1389
|
+
body.approval_rules,
|
|
1390
|
+
),
|
|
1391
|
+
);
|
|
1094
1392
|
return;
|
|
1095
1393
|
}
|
|
1096
1394
|
if (parts[2] === 'renew') {
|
|
@@ -1101,6 +1399,7 @@ export class SalaamService {
|
|
|
1101
1399
|
body.binding,
|
|
1102
1400
|
request,
|
|
1103
1401
|
integer(body.lease_sequence, 'lease_sequence'),
|
|
1402
|
+
body.approval_rules,
|
|
1104
1403
|
),
|
|
1105
1404
|
);
|
|
1106
1405
|
return;
|
|
@@ -1664,11 +1963,22 @@ export class SalaamService {
|
|
|
1664
1963
|
pid: process.pid,
|
|
1665
1964
|
host_manifest_digest: this.hostManifestDigest,
|
|
1666
1965
|
host_supervisor_connected: this.hostManifestDigest ? process.connected === true : null,
|
|
1966
|
+
host_receipt: this.hostReceipt,
|
|
1667
1967
|
runtime_node: realpathSync(process.execPath),
|
|
1668
1968
|
service_version: VERSION,
|
|
1669
1969
|
context_admission: { version: 1, automatic_discovery: false, child_inheritance: false },
|
|
1670
1970
|
agentpod_config: this.agentpod?.configPath ?? null,
|
|
1671
1971
|
agentpod_pins_digest: this.agentpod ? digest(this.agentpod.pins) : null,
|
|
1972
|
+
persona: this.supervisor.persona
|
|
1973
|
+
? { digest: this.supervisor.persona.digest, text: this.supervisor.persona.text }
|
|
1974
|
+
: null,
|
|
1975
|
+
operator_mcp: this.operatorMcpPath
|
|
1976
|
+
? {
|
|
1977
|
+
config: this.operatorMcpPath,
|
|
1978
|
+
digest: this.supervisor.operatorMcp!.digest,
|
|
1979
|
+
servers: this.supervisor.operatorMcp!.provenance(),
|
|
1980
|
+
}
|
|
1981
|
+
: null,
|
|
1672
1982
|
managed_mcp: this.mcp
|
|
1673
1983
|
? { profile_digest: this.mcp.options.admission.profileDigest }
|
|
1674
1984
|
: null,
|
|
@@ -1680,6 +1990,12 @@ export class SalaamService {
|
|
|
1680
1990
|
? { configuration_digest: this.station.configurationDigest }
|
|
1681
1991
|
: null,
|
|
1682
1992
|
service: 'healthy',
|
|
1993
|
+
tool_execution: {
|
|
1994
|
+
mode: this.toolIdentity.mode,
|
|
1995
|
+
user: this.toolIdentity.user,
|
|
1996
|
+
uid: this.toolIdentity.uid,
|
|
1997
|
+
warning: this.toolIdentity.warning,
|
|
1998
|
+
},
|
|
1683
1999
|
a2a: {
|
|
1684
2000
|
inbound_enabled: !!this.a2aInbound,
|
|
1685
2001
|
listener: this.a2aInbound?.server.address() ?? null,
|
|
@@ -1741,9 +2057,11 @@ export class SalaamService {
|
|
|
1741
2057
|
backend: this.coding.executor?.evidence ?? null,
|
|
1742
2058
|
}
|
|
1743
2059
|
: EXECUTION_STATUS,
|
|
2060
|
+
last_activity_at: new Date(this.store.lastActivityAny()).toISOString(),
|
|
1744
2061
|
sessions: sessions.map((x) => ({
|
|
1745
2062
|
id: x.id,
|
|
1746
2063
|
state: x.state,
|
|
2064
|
+
last_activity_at: iso(this.store.lastActivity(x.id)),
|
|
1747
2065
|
error: x.error,
|
|
1748
2066
|
capabilities: x.capabilities,
|
|
1749
2067
|
pending_approvals: this.store
|
|
@@ -1768,7 +2086,10 @@ export class SalaamService {
|
|
|
1768
2086
|
this.send(
|
|
1769
2087
|
res,
|
|
1770
2088
|
200,
|
|
1771
|
-
this.store.sessions().map((x) =>
|
|
2089
|
+
this.store.sessions().map((x) => {
|
|
2090
|
+
const session = this.supervisor.inspect(x.id);
|
|
2091
|
+
return { ...session, ...pickerSummary(session, this.store) };
|
|
2092
|
+
}),
|
|
1772
2093
|
);
|
|
1773
2094
|
return;
|
|
1774
2095
|
}
|
|
@@ -1911,13 +2232,61 @@ export class SalaamService {
|
|
|
1911
2232
|
}
|
|
1912
2233
|
if (parts[0] === 'v1' && parts[1] === 'sessions' && parts[2]) {
|
|
1913
2234
|
const id = parts[2];
|
|
2235
|
+
// Desk-session approval rules (phase 2a): list, add (from a reviewed operation or written out),
|
|
2236
|
+
// and remove. Adding or removing needs the session's control lease, like the permission policy.
|
|
2237
|
+
if (parts[3] === 'approval-rules' && (parts.length === 4 || parts.length === 6)) {
|
|
2238
|
+
const rules = this.localCoding.rules!;
|
|
2239
|
+
if (parts.length === 4 && req.method === 'GET') {
|
|
2240
|
+
this.send(res, 200, {
|
|
2241
|
+
applies: rules.applies(id),
|
|
2242
|
+
rules: rules.list(id),
|
|
2243
|
+
problems: rules.problems(id),
|
|
2244
|
+
});
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
const body = object(await readJson(req));
|
|
2248
|
+
this.store.authorizeControl(id, {
|
|
2249
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
2250
|
+
token: string(body.token, 'token', 128),
|
|
2251
|
+
});
|
|
2252
|
+
if (parts.length === 4 && req.method === 'POST') {
|
|
2253
|
+
const scope = string(body.scope, 'scope');
|
|
2254
|
+
if (scope !== 'session' && scope !== 'repo')
|
|
2255
|
+
throw new ServiceError('invalid_field', 'A rule scope is session or repo', 400);
|
|
2256
|
+
const request = string(body.request_id, 'request_id', 128);
|
|
2257
|
+
this.send(
|
|
2258
|
+
res,
|
|
2259
|
+
200,
|
|
2260
|
+
body.operation_id !== undefined
|
|
2261
|
+
? rules.addFromOperation(
|
|
2262
|
+
id,
|
|
2263
|
+
string(body.operation_id, 'operation_id', 128),
|
|
2264
|
+
scope,
|
|
2265
|
+
request,
|
|
2266
|
+
)
|
|
2267
|
+
: rules.add(id, scope, body.rule, request),
|
|
2268
|
+
);
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
if (parts.length === 6 && parts[5] === 'remove' && req.method === 'POST') {
|
|
2272
|
+
rules.remove(id, string(parts[4], 'rule', 128));
|
|
2273
|
+
this.send(res, 200, { removed: parts[4] });
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
1914
2277
|
if (parts.length === 4 && parts[3] === 'permissions') {
|
|
1915
2278
|
if (req.method === 'GET') {
|
|
1916
2279
|
this.send(res, 200, {
|
|
1917
2280
|
policy: permissionPolicy(this.store, id),
|
|
1918
2281
|
configurable: this.store.localWorkspace(id),
|
|
1919
2282
|
coding: this.store.localCoding(id),
|
|
1920
|
-
|
|
2283
|
+
// Desk coding sessions: approval rules and auto mode can approve commands (Phase 2a/2b).
|
|
2284
|
+
command_auto_approval: this.store.localCoding(id),
|
|
2285
|
+
full_access_allowed: this.allowFullAccessSessions,
|
|
2286
|
+
yolo_allowed: this.allowYoloSessions && !!this.localCoding.rules?.applies(id),
|
|
2287
|
+
...(this.store.localCoding(id)
|
|
2288
|
+
? { command_sandbox: this.localCoding.commandSandbox(id) }
|
|
2289
|
+
: {}),
|
|
1921
2290
|
});
|
|
1922
2291
|
return;
|
|
1923
2292
|
}
|
|
@@ -1936,17 +2305,152 @@ export class SalaamService {
|
|
|
1936
2305
|
client_id: string(body.client_id, 'client_id', 128),
|
|
1937
2306
|
token: string(body.token, 'token', 128),
|
|
1938
2307
|
},
|
|
2308
|
+
{
|
|
2309
|
+
allowFullAccess: this.allowFullAccessSessions,
|
|
2310
|
+
allowYolo: this.allowYoloSessions,
|
|
2311
|
+
deskSession: !!this.localCoding.rules?.applies(id),
|
|
2312
|
+
},
|
|
1939
2313
|
),
|
|
1940
2314
|
);
|
|
1941
2315
|
return;
|
|
1942
2316
|
}
|
|
1943
2317
|
}
|
|
2318
|
+
if (parts.length === 4 && parts[3] === 'danger-classifier') {
|
|
2319
|
+
const view = () => {
|
|
2320
|
+
const choice = this.reviewers.size ? this.reviewerChoice(id) : 'off',
|
|
2321
|
+
chosen = this.reviewers.get(choice)?.config;
|
|
2322
|
+
return {
|
|
2323
|
+
configured: this.reviewers.size > 0,
|
|
2324
|
+
enabled: !!chosen,
|
|
2325
|
+
...(chosen
|
|
2326
|
+
? {
|
|
2327
|
+
provider: chosen.provider,
|
|
2328
|
+
model: chosen.model,
|
|
2329
|
+
on_unavailable: chosen.on_unavailable,
|
|
2330
|
+
}
|
|
2331
|
+
: {}),
|
|
2332
|
+
choice,
|
|
2333
|
+
pinned: this.reviewerPin !== undefined,
|
|
2334
|
+
options: [...this.reviewers.values()].map(({ config: o }) => ({
|
|
2335
|
+
name: o.name,
|
|
2336
|
+
provider: o.provider,
|
|
2337
|
+
model: o.model,
|
|
2338
|
+
})),
|
|
2339
|
+
};
|
|
2340
|
+
};
|
|
2341
|
+
if (req.method === 'GET') {
|
|
2342
|
+
this.send(res, 200, view());
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
if (req.method === 'POST') {
|
|
2346
|
+
const body = object(await readJson(req));
|
|
2347
|
+
this.store.authorizeControl(id, {
|
|
2348
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
2349
|
+
token: string(body.token, 'token', 128),
|
|
2350
|
+
});
|
|
2351
|
+
// `enabled` is the earlier on/off form: on means the config's default.
|
|
2352
|
+
const choice =
|
|
2353
|
+
typeof body.enabled === 'boolean'
|
|
2354
|
+
? body.enabled
|
|
2355
|
+
? this.reviewerDefault === 'off'
|
|
2356
|
+
? [...this.reviewers.keys()][0]
|
|
2357
|
+
: this.reviewerDefault
|
|
2358
|
+
: 'off'
|
|
2359
|
+
: string(body.choice, 'choice', 64);
|
|
2360
|
+
if (!this.reviewers.size && choice !== 'off')
|
|
2361
|
+
throw new ServiceError(
|
|
2362
|
+
'classifier_unavailable',
|
|
2363
|
+
'No command reviewer is configured on this host (--danger-classifier-config)',
|
|
2364
|
+
);
|
|
2365
|
+
if (this.reviewerPin !== undefined)
|
|
2366
|
+
throw new ServiceError(
|
|
2367
|
+
'classifier_pinned',
|
|
2368
|
+
`This host pinned the command reviewer (${this.reviewerPin}); sessions cannot change it`,
|
|
2369
|
+
403,
|
|
2370
|
+
);
|
|
2371
|
+
if (choice !== 'off' && !this.reviewers.has(choice ?? ''))
|
|
2372
|
+
throw new ServiceError('invalid_field', `Unknown reviewer ${choice}`);
|
|
2373
|
+
if (this.reviewers.size)
|
|
2374
|
+
this.store.transaction(() => {
|
|
2375
|
+
this.store.db
|
|
2376
|
+
.prepare(
|
|
2377
|
+
'INSERT INTO danger_classifier_choice(session_id,choice) VALUES(?,?) ON CONFLICT(session_id) DO UPDATE SET choice=excluded.choice',
|
|
2378
|
+
)
|
|
2379
|
+
.run(id, choice!);
|
|
2380
|
+
this.store.event(id, 'permission.classifier-set', { choice });
|
|
2381
|
+
});
|
|
2382
|
+
this.send(res, 200, view());
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
// Desk-session hooks (Phase 4b): list, trust the reviewed repository file, or revoke it.
|
|
2387
|
+
if (parts[3] === 'hooks' && this.supervisor.hooks) {
|
|
2388
|
+
if (!this.supervisor.isInteractiveCoding(id))
|
|
2389
|
+
throw new ServiceError('hooks_denied', 'Hooks run only in your own desk sessions', 403);
|
|
2390
|
+
const hooks = this.supervisor.hooks;
|
|
2391
|
+
if (parts.length === 4 && req.method === 'GET') {
|
|
2392
|
+
this.send(res, 200, { sources: hooks.sources(id) });
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
if (parts.length === 5 && req.method === 'POST') {
|
|
2396
|
+
const body = object(await readJson(req));
|
|
2397
|
+
this.store.authorizeControl(id, {
|
|
2398
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
2399
|
+
token: string(body.token, 'token', 128),
|
|
2400
|
+
});
|
|
2401
|
+
if (parts[4] === 'trust') {
|
|
2402
|
+
this.send(res, 200, hooks.trust(id, string(body.digest, 'digest', 128)));
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
if (parts[4] === 'revoke') {
|
|
2406
|
+
hooks.revoke(id);
|
|
2407
|
+
this.send(res, 200, { revoked: true });
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
if (parts[3] === 'local-tasks' && this.localCoding) {
|
|
2413
|
+
if (!this.supervisor.isInteractiveCoding(id))
|
|
2414
|
+
throw new ServiceError(
|
|
2415
|
+
'background_denied',
|
|
2416
|
+
'Background commands are not admitted',
|
|
2417
|
+
403,
|
|
2418
|
+
);
|
|
2419
|
+
if (parts.length === 4 && req.method === 'GET') {
|
|
2420
|
+
this.send(res, 200, { tasks: this.localCoding.tasks(id) });
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
if (parts.length === 5 && req.method === 'GET') {
|
|
2424
|
+
this.send(res, 200, this.localCoding.taskOutput(id, parts[4]!));
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
if (parts.length === 6 && parts[5] === 'stop' && req.method === 'POST') {
|
|
2428
|
+
this.send(res, 200, this.localCoding.stopTask(id, parts[4]!));
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
1944
2432
|
if (parts.length === 4 && parts[3] === 'local-diff' && req.method === 'GET') {
|
|
1945
2433
|
if (!this.store.localWorkspace(id))
|
|
1946
2434
|
throw new ServiceError('local_denied', 'Local workspace required');
|
|
1947
2435
|
this.send(res, 200, { text: await localDiff(this.store.session(id).cwd) });
|
|
1948
2436
|
return;
|
|
1949
2437
|
}
|
|
2438
|
+
// An edit request's exact files, before and after, so an editor can show it as a diff.
|
|
2439
|
+
if (parts.length === 4 && parts[3] === 'local-edits' && req.method === 'GET') {
|
|
2440
|
+
if (!this.store.localCoding(id))
|
|
2441
|
+
throw new ServiceError('local_denied', 'Local coding session required');
|
|
2442
|
+
this.send(
|
|
2443
|
+
res,
|
|
2444
|
+
200,
|
|
2445
|
+
this.localCoding.edits(
|
|
2446
|
+
id,
|
|
2447
|
+
url.searchParams.has('operation_id')
|
|
2448
|
+
? { operation: string(url.searchParams.get('operation_id'), 'operation_id', 128) }
|
|
2449
|
+
: { toolCall: string(url.searchParams.get('tool_call'), 'tool_call', 512) },
|
|
2450
|
+
),
|
|
2451
|
+
);
|
|
2452
|
+
return;
|
|
2453
|
+
}
|
|
1950
2454
|
if (parts.length === 4 && parts[3] === 'steer' && req.method === 'POST') {
|
|
1951
2455
|
const body = object(await readJson(req));
|
|
1952
2456
|
this.send(
|
|
@@ -2011,11 +2515,138 @@ export class SalaamService {
|
|
|
2011
2515
|
)
|
|
2012
2516
|
throw new ServiceError('session_busy', 'Wait for queued work before undo');
|
|
2013
2517
|
},
|
|
2518
|
+
this.toolIdentity,
|
|
2014
2519
|
),
|
|
2015
2520
|
);
|
|
2016
2521
|
return;
|
|
2017
2522
|
}
|
|
2018
2523
|
}
|
|
2524
|
+
if (
|
|
2525
|
+
(parts.length === 4 || parts.length === 5) &&
|
|
2526
|
+
(parts[3] === 'checkpoints' || parts[3] === 'rewind' || parts[3] === 'review')
|
|
2527
|
+
) {
|
|
2528
|
+
const checkpoints = this.tasks.checkpoints!;
|
|
2529
|
+
// Operator ruling 2026-10-31: background commands may keep writing; say so, do not block.
|
|
2530
|
+
const running = <T extends object | null>(value: T): T =>
|
|
2531
|
+
value && this.localCoding
|
|
2532
|
+
? {
|
|
2533
|
+
...value,
|
|
2534
|
+
background_running: this.localCoding
|
|
2535
|
+
.tasks(id)
|
|
2536
|
+
.filter((t) => t.state === 'running').length,
|
|
2537
|
+
}
|
|
2538
|
+
: value;
|
|
2539
|
+
const controller = (body: Record<string, unknown>) => ({
|
|
2540
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
2541
|
+
token: string(body.token, 'token', 128),
|
|
2542
|
+
});
|
|
2543
|
+
const idle = () => {
|
|
2544
|
+
this.supervisor.assertIdle(id);
|
|
2545
|
+
if (
|
|
2546
|
+
this.store.db
|
|
2547
|
+
.prepare(
|
|
2548
|
+
"SELECT 1 FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running')",
|
|
2549
|
+
)
|
|
2550
|
+
.get(id)
|
|
2551
|
+
)
|
|
2552
|
+
throw new ServiceError('session_busy', 'Wait for the current turn to finish');
|
|
2553
|
+
};
|
|
2554
|
+
if (parts[3] === 'checkpoints' && parts.length === 4 && req.method === 'GET') {
|
|
2555
|
+
this.send(res, 200, { checkpoints: checkpoints.list(id) });
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'GET') {
|
|
2559
|
+
this.send(
|
|
2560
|
+
res,
|
|
2561
|
+
200,
|
|
2562
|
+
running(
|
|
2563
|
+
checkpoints.reviewRewind(
|
|
2564
|
+
id,
|
|
2565
|
+
string(url.searchParams.get('attempt'), 'attempt', 128),
|
|
2566
|
+
string(url.searchParams.get('mode'), 'mode', 16) as RewindMode,
|
|
2567
|
+
),
|
|
2568
|
+
),
|
|
2569
|
+
);
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'POST') {
|
|
2573
|
+
if (this.agentpod)
|
|
2574
|
+
throw new ServiceError(
|
|
2575
|
+
'admission_required',
|
|
2576
|
+
'Managed station sessions cannot rewind',
|
|
2577
|
+
);
|
|
2578
|
+
const body = object(await readJson(req));
|
|
2579
|
+
const request = string(body.request_id, 'request_id', 128);
|
|
2580
|
+
this.send(
|
|
2581
|
+
res,
|
|
2582
|
+
200,
|
|
2583
|
+
checkpoints.rewind(
|
|
2584
|
+
id,
|
|
2585
|
+
request,
|
|
2586
|
+
string(body.attempt_id, 'attempt_id', 128),
|
|
2587
|
+
string(body.mode, 'mode', 16) as RewindMode,
|
|
2588
|
+
string(body.review_digest, 'review_digest', 128),
|
|
2589
|
+
controller(body),
|
|
2590
|
+
idle,
|
|
2591
|
+
(leaf, summary) =>
|
|
2592
|
+
this.store.forkLocal(
|
|
2593
|
+
id,
|
|
2594
|
+
request + ':fork',
|
|
2595
|
+
this.store.session(id).revision,
|
|
2596
|
+
controller(body),
|
|
2597
|
+
(source, target) => {
|
|
2598
|
+
const dir = join(this.stateDir, 'workers', target, 'sessions');
|
|
2599
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
2600
|
+
return forkPiSession(source.pi_session_file!, source.cwd, dir, target, {
|
|
2601
|
+
leaf,
|
|
2602
|
+
summary,
|
|
2603
|
+
});
|
|
2604
|
+
},
|
|
2605
|
+
{ leaf },
|
|
2606
|
+
),
|
|
2607
|
+
),
|
|
2608
|
+
);
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
if (parts[3] === 'review' && parts.length === 4 && req.method === 'GET') {
|
|
2612
|
+
this.send(res, 200, { review: running(checkpoints.review(id)) });
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
2615
|
+
if (parts[3] === 'review' && parts[4] === 'hunks' && req.method === 'GET') {
|
|
2616
|
+
const q = (name: string) => string(url.searchParams.get(name), name, 1024);
|
|
2617
|
+
this.send(res, 200, {
|
|
2618
|
+
hunks: checkpoints.hunks(id, q('path'), q('base'), q('current')),
|
|
2619
|
+
});
|
|
2620
|
+
return;
|
|
2621
|
+
}
|
|
2622
|
+
if (parts[3] === 'review' && parts.length === 4 && req.method === 'POST') {
|
|
2623
|
+
const body = object(await readJson(req));
|
|
2624
|
+
const hunk =
|
|
2625
|
+
body.hunk === undefined
|
|
2626
|
+
? undefined
|
|
2627
|
+
: {
|
|
2628
|
+
text: string(object(body.hunk).text, 'hunk.text', 256 * 1024),
|
|
2629
|
+
patch: string(object(body.hunk).patch, 'hunk.patch', 256 * 1024),
|
|
2630
|
+
};
|
|
2631
|
+
this.send(res, 200, {
|
|
2632
|
+
review: running(
|
|
2633
|
+
checkpoints.act(
|
|
2634
|
+
id,
|
|
2635
|
+
{
|
|
2636
|
+
action: string(body.action, 'action', 16) as 'accept' | 'revert',
|
|
2637
|
+
path: string(body.path, 'path', 1024),
|
|
2638
|
+
...(hunk ? { hunk } : {}),
|
|
2639
|
+
base: string(body.base, 'base', 128),
|
|
2640
|
+
current: string(body.current, 'current', 128),
|
|
2641
|
+
},
|
|
2642
|
+
controller(body),
|
|
2643
|
+
idle,
|
|
2644
|
+
),
|
|
2645
|
+
),
|
|
2646
|
+
});
|
|
2647
|
+
return;
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2019
2650
|
if (parts.length === 4 && parts[3] === 'fork' && req.method === 'POST') {
|
|
2020
2651
|
if (this.agentpod)
|
|
2021
2652
|
throw new ServiceError(
|
|
@@ -2619,6 +3250,48 @@ export class SalaamService {
|
|
|
2619
3250
|
);
|
|
2620
3251
|
return;
|
|
2621
3252
|
}
|
|
3253
|
+
if (parts[3] === 'route' && req.method === 'POST') {
|
|
3254
|
+
const body = object(await readJson(req));
|
|
3255
|
+
this.store.authorizeControl(id, {
|
|
3256
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
3257
|
+
token: string(body.token, 'token', 128),
|
|
3258
|
+
});
|
|
3259
|
+
if (!this.tasks.instructions!.applies(id))
|
|
3260
|
+
throw new ServiceError(
|
|
3261
|
+
'route_switch_unavailable',
|
|
3262
|
+
'Only interactive sessions can switch model mid-session',
|
|
3263
|
+
409,
|
|
3264
|
+
);
|
|
3265
|
+
this.supervisor.assertIdle(id);
|
|
3266
|
+
const input = object(body.route);
|
|
3267
|
+
const requested = providerRoute(
|
|
3268
|
+
string(input.provider, 'provider'),
|
|
3269
|
+
input.model === undefined ? undefined : string(input.model, 'model'),
|
|
3270
|
+
undefined,
|
|
3271
|
+
undefined,
|
|
3272
|
+
undefined,
|
|
3273
|
+
input.reasoning as import('./provider-config.js').ReasoningLevel | undefined,
|
|
3274
|
+
);
|
|
3275
|
+
const prepared = await this.supervisor.prepareProviderRoute(requested).catch(() => {
|
|
3276
|
+
throw new ServiceError(
|
|
3277
|
+
'provider_auth_required',
|
|
3278
|
+
`Configure ${requested.provider} with sal auth login ${requested.provider} or sal auth set ${requested.provider}, then retry.`,
|
|
3279
|
+
400,
|
|
3280
|
+
);
|
|
3281
|
+
});
|
|
3282
|
+
if (prepared.output_cap !== (this.store.localOutputCap(id) ?? true))
|
|
3283
|
+
throw new ServiceError(
|
|
3284
|
+
'provider_output_cap_changed',
|
|
3285
|
+
'That route enforces output caps differently; start a new session for it',
|
|
3286
|
+
409,
|
|
3287
|
+
);
|
|
3288
|
+
this.supervisor.assertIdle(id);
|
|
3289
|
+
this.store.switchRoute(id, prepared.route);
|
|
3290
|
+
await this.supervisor.stopSession(id);
|
|
3291
|
+
void this.supervisor.start(id).catch(() => {});
|
|
3292
|
+
this.send(res, 200, this.supervisor.inspect(id));
|
|
3293
|
+
return;
|
|
3294
|
+
}
|
|
2622
3295
|
if (parts[3] === 'resume' && req.method === 'POST') {
|
|
2623
3296
|
const body = object(await readJson(req));
|
|
2624
3297
|
this.store.authorizeControl(id, {
|
|
@@ -2637,6 +3310,12 @@ export class SalaamService {
|
|
|
2637
3310
|
);
|
|
2638
3311
|
if (session.state !== 'ready' && session.state !== 'starting')
|
|
2639
3312
|
void this.supervisor.start(id).catch(() => {});
|
|
3313
|
+
// Show the project instructions an interactive session will use before its first turn.
|
|
3314
|
+
try {
|
|
3315
|
+
this.tasks.instructions?.refresh(id);
|
|
3316
|
+
} catch {
|
|
3317
|
+
/* reported on the next turn */
|
|
3318
|
+
}
|
|
2640
3319
|
this.send(res, 202, this.supervisor.inspect(id));
|
|
2641
3320
|
return;
|
|
2642
3321
|
}
|
|
@@ -2652,11 +3331,24 @@ export class SalaamService {
|
|
|
2652
3331
|
}
|
|
2653
3332
|
if (parts[3] === 'submissions' && req.method === 'POST') {
|
|
2654
3333
|
const body = object(await readJson(req));
|
|
3334
|
+
const local = this.store.localCoding(id);
|
|
3335
|
+
let text = string(body.text, 'text', 256 * 1024);
|
|
3336
|
+
let images: PromptImage[] = [];
|
|
3337
|
+
// Desk prompts attach their @mentions (Phase 3b); board work never reads the tree here.
|
|
3338
|
+
if (local) {
|
|
3339
|
+
if (body.confirm_sensitive !== undefined && typeof body.confirm_sensitive !== 'boolean')
|
|
3340
|
+
throw new ServiceError('invalid_field', 'Invalid confirm_sensitive flag', 400);
|
|
3341
|
+
({ text, images } = expandPrompt(
|
|
3342
|
+
this.store.session(id).cwd,
|
|
3343
|
+
text,
|
|
3344
|
+
body.confirm_sensitive === true,
|
|
3345
|
+
));
|
|
3346
|
+
}
|
|
2655
3347
|
const submission = this.store.accept(
|
|
2656
3348
|
id,
|
|
2657
3349
|
string(body.request_id, 'request_id', 128),
|
|
2658
3350
|
integer(body.expected_revision, 'expected_revision'),
|
|
2659
|
-
|
|
3351
|
+
text,
|
|
2660
3352
|
{
|
|
2661
3353
|
client_id: string(body.client_id, 'client_id', 128),
|
|
2662
3354
|
token: string(body.token, 'token', 128),
|
|
@@ -2665,6 +3357,8 @@ export class SalaamService {
|
|
|
2665
3357
|
? null
|
|
2666
3358
|
: string(body.continues_attempt_id, 'continues_attempt_id', 128),
|
|
2667
3359
|
);
|
|
3360
|
+
// Same tick as the acceptance, so dispatch cannot claim the submission without them.
|
|
3361
|
+
storeImages(this.store.db, submission.id, images);
|
|
2668
3362
|
this.send(res, 202, submission);
|
|
2669
3363
|
return;
|
|
2670
3364
|
}
|
|
@@ -2795,6 +3489,7 @@ export class SalaamService {
|
|
|
2795
3489
|
await attempt(() => this.toolkitResolutions.close());
|
|
2796
3490
|
await attempt(() => this.children.close());
|
|
2797
3491
|
await attempt(() => this.supervisor.close());
|
|
3492
|
+
await attempt(() => this.supervisor.operatorMcp?.close());
|
|
2798
3493
|
await attempt(() => this.dispatcher.drain());
|
|
2799
3494
|
await attempt(() => this.managed.close());
|
|
2800
3495
|
await attempt(() =>
|
|
@@ -2826,3 +3521,7 @@ export class SalaamService {
|
|
|
2826
3521
|
if (this.shutdownFailure) throw this.shutdownFailure;
|
|
2827
3522
|
}
|
|
2828
3523
|
}
|
|
3524
|
+
|
|
3525
|
+
function iso(ms: number | null): string | null {
|
|
3526
|
+
return ms === null ? null : new Date(ms).toISOString();
|
|
3527
|
+
}
|