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