@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,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A circuit breaker for agent fetches (operator MCP calls whose arguments carry a `url`). After
|
|
3
|
+
* repeated consecutive failures to one host within a session, calls to that host are paused for a
|
|
4
|
+
* short while: the agent gets a tool error saying so and can do something else, and the run's
|
|
5
|
+
* activity gets one line. Nothing fails the run, and a paused call is never made, so it records no
|
|
6
|
+
* operator MCP call. One success to the host closes the breaker again.
|
|
7
|
+
*/
|
|
8
|
+
export const BREAKER_FAILURES = 3;
|
|
9
|
+
export const BREAKER_PAUSE_MS = 60_000;
|
|
10
|
+
|
|
11
|
+
export function fetchHost(args: unknown): string | null {
|
|
12
|
+
const url = (args as { url?: unknown } | null)?.url;
|
|
13
|
+
if (typeof url !== 'string') return null;
|
|
14
|
+
try {
|
|
15
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
16
|
+
return host || null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class HostBreaker {
|
|
23
|
+
private readonly hosts = new Map<string, { failures: number; until: number }>();
|
|
24
|
+
constructor(private readonly now: () => number = Date.now) {}
|
|
25
|
+
private key(session: string, host: string) {
|
|
26
|
+
return session + '\u0000' + host;
|
|
27
|
+
}
|
|
28
|
+
/** Milliseconds this host stays paused for the session, 0 when calls may go ahead. */
|
|
29
|
+
paused(session: string, host: string): number {
|
|
30
|
+
const h = this.hosts.get(this.key(session, host));
|
|
31
|
+
return h ? Math.max(0, h.until - this.now()) : 0;
|
|
32
|
+
}
|
|
33
|
+
/** Records a call's outcome; true when this failure just opened the breaker. */
|
|
34
|
+
record(session: string, host: string, ok: boolean): boolean {
|
|
35
|
+
const key = this.key(session, host);
|
|
36
|
+
if (ok) {
|
|
37
|
+
this.hosts.delete(key);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
const h = this.hosts.get(key) ?? { failures: 0, until: 0 };
|
|
41
|
+
h.failures++;
|
|
42
|
+
this.hosts.set(key, h);
|
|
43
|
+
if (h.failures < BREAKER_FAILURES) return false;
|
|
44
|
+
h.failures = 0;
|
|
45
|
+
h.until = this.now() + BREAKER_PAUSE_MS;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
forget(session: string): void {
|
|
49
|
+
for (const key of this.hosts.keys())
|
|
50
|
+
if (key.startsWith(session + '\u0000')) this.hosts.delete(key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type McpResult = { content: { type: 'text'; text: string }[]; is_error: boolean };
|
|
55
|
+
/**
|
|
56
|
+
* One operator MCP call through the breaker. A paused host returns a tool error without calling;
|
|
57
|
+
* a thrown failure or a tool error counts against the host; the call that opens the breaker
|
|
58
|
+
* records `fetch.host_paused` for the run's activity. The call's own result or error is unchanged.
|
|
59
|
+
*/
|
|
60
|
+
export async function callThroughBreaker(
|
|
61
|
+
breaker: HostBreaker,
|
|
62
|
+
session: string,
|
|
63
|
+
args: unknown,
|
|
64
|
+
event: (payload: Record<string, unknown>) => void,
|
|
65
|
+
call: () => Promise<McpResult>,
|
|
66
|
+
): Promise<McpResult> {
|
|
67
|
+
const host = fetchHost(args);
|
|
68
|
+
if (!host) return call();
|
|
69
|
+
const wait = breaker.paused(session, host);
|
|
70
|
+
if (wait)
|
|
71
|
+
return {
|
|
72
|
+
content: [
|
|
73
|
+
{
|
|
74
|
+
type: 'text',
|
|
75
|
+
text: `Requests to ${host} are paused for ${Math.ceil(wait / 1000)}s after repeated failures; try another source or retry later.`,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
is_error: true,
|
|
79
|
+
};
|
|
80
|
+
let ok = false;
|
|
81
|
+
try {
|
|
82
|
+
const result = await call();
|
|
83
|
+
ok = !result.is_error;
|
|
84
|
+
return result;
|
|
85
|
+
} finally {
|
|
86
|
+
if (breaker.record(session, host, ok))
|
|
87
|
+
event({ host, failures: BREAKER_FAILURES, pause_ms: BREAKER_PAUSE_MS });
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/host-start.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
1
|
+
import { spawn, execFile } from 'node:child_process';
|
|
2
2
|
import { constants, openSync, closeSync, fstatSync, mkdirSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { readHost, hostMcp, hostToolkit } from './host.js';
|
|
@@ -130,3 +130,53 @@ export async function startHost(path: string) {
|
|
|
130
130
|
release();
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
+
|
|
134
|
+
const launcherUnit = /^salaam-host@[a-f0-9]{16}\.service$/;
|
|
135
|
+
/** Launcher profile: an unprivileged operator asks systemd to start a root-owned,
|
|
136
|
+
* provisioned unit. It names only the unit; root reads its own reviewed manifest. */
|
|
137
|
+
export async function startHostUnit(
|
|
138
|
+
unit: string,
|
|
139
|
+
state: string,
|
|
140
|
+
options: { systemctl?: string; timeoutMs?: number } = {},
|
|
141
|
+
) {
|
|
142
|
+
if (!launcherUnit.test(unit)) throw Error('Unit must be salaam-host@<16 hex>.service');
|
|
143
|
+
if (realpathSync(state) !== state) throw Error('Use the canonical state path');
|
|
144
|
+
const socket = join(state, 'operator.sock'),
|
|
145
|
+
deadline = Date.now() + (options.timeoutMs ?? 20000);
|
|
146
|
+
const ready = async () => {
|
|
147
|
+
let h: Record<string, any>;
|
|
148
|
+
try {
|
|
149
|
+
h = await call(socket, '/v1/health', undefined, undefined, 2000);
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
if (!h.host_receipt || h.host_receipt.manifest_digest !== h.host_manifest_digest)
|
|
154
|
+
throw new ServiceError('host_mismatch', 'Running daemon reports no reviewed host receipt');
|
|
155
|
+
if (h.host_receipt.state !== state)
|
|
156
|
+
throw new ServiceError('host_mismatch', 'Running daemon serves another state directory');
|
|
157
|
+
if (h.host_supervisor_connected !== true || h.service !== 'healthy') return null;
|
|
158
|
+
if (
|
|
159
|
+
h.job_execution?.control_error ||
|
|
160
|
+
h.job_execution?.schedule_control_error ||
|
|
161
|
+
h.job_execution?.inbox_control_error
|
|
162
|
+
)
|
|
163
|
+
throw Error('Reviewed host controls are not ready');
|
|
164
|
+
return h.host_receipt as Record<string, unknown>;
|
|
165
|
+
};
|
|
166
|
+
let receipt = await ready();
|
|
167
|
+
if (receipt) return receipt;
|
|
168
|
+
await new Promise<void>((done, fail) =>
|
|
169
|
+
execFile(
|
|
170
|
+
options.systemctl ?? '/usr/bin/systemctl',
|
|
171
|
+
['start', '--no-ask-password', unit],
|
|
172
|
+
{ env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' }, timeout: 20000 },
|
|
173
|
+
(error) =>
|
|
174
|
+
error ? fail(Error('systemd refused to start ' + unit + '; inspect its journal')) : done(),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
while (!(receipt = await ready())) {
|
|
178
|
+
if (Date.now() >= deadline) throw Error('Host unit did not become ready; inspect its journal');
|
|
179
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
180
|
+
}
|
|
181
|
+
return receipt;
|
|
182
|
+
}
|
package/src/host.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DEFAULT_COMMAND_NETWORK_SOCKET } from './command-network.js';
|
|
2
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
2
3
|
import { once } from 'node:events';
|
|
3
4
|
import {
|
|
4
5
|
readFileSync,
|
|
@@ -30,6 +31,9 @@ import { acquireStateLock } from './state-lock.js';
|
|
|
30
31
|
import { call } from './transport.js';
|
|
31
32
|
import { readStationConfig } from './station-config.js';
|
|
32
33
|
import { readToolkitConfig } from './toolkit-config.js';
|
|
34
|
+
import { OperatorMcp } from './operator-mcp.js';
|
|
35
|
+
import { chooseLocalSandbox, sandboxLabel, type SandboxKind } from './command-sandbox.js';
|
|
36
|
+
import { loadPersona } from './persona.js';
|
|
33
37
|
interface HostIdentity {
|
|
34
38
|
node: string;
|
|
35
39
|
node_version: string;
|
|
@@ -53,6 +57,7 @@ interface AgentPodHostIdentity extends HostIdentity {
|
|
|
53
57
|
pins_digest: string;
|
|
54
58
|
workspace: string;
|
|
55
59
|
cli_digest: string;
|
|
60
|
+
local_sandbox?: HostLocalSandbox;
|
|
56
61
|
}
|
|
57
62
|
export interface AgentPodHostManifest extends AgentPodHostIdentity {
|
|
58
63
|
schema: 2;
|
|
@@ -78,6 +83,92 @@ export interface AgentPodStrictWorkHostManifest extends Omit<
|
|
|
78
83
|
> {
|
|
79
84
|
schema: 6;
|
|
80
85
|
strict_work: { version: 1 };
|
|
86
|
+
/** Launcher profile: a root host whose operator socket one unprivileged group may use. */
|
|
87
|
+
operator_access?: OperatorAccess;
|
|
88
|
+
/** Operator-declared MCP servers (docs/mcp.md), pinned by declaration digest. */
|
|
89
|
+
operator_mcp?: { config: string; digest: string };
|
|
90
|
+
/** Item 9: the operator's persona file, pinned by content digest. */
|
|
91
|
+
persona?: { config: string; digest: string };
|
|
92
|
+
/** How long a strict approval offer stays answerable; the daemon default when absent. */
|
|
93
|
+
approval_ttl_ms?: number;
|
|
94
|
+
}
|
|
95
|
+
/** Item 10: the command sandbox this host gives local commands, as reviewed at host plan. With no
|
|
96
|
+
* usable sandbox the host refuses local commands (label "refused") unless the operator set
|
|
97
|
+
* `unsandboxed_commands: "allow"`, which the manifest pins (operator ruling 2026-10-30, option A). */
|
|
98
|
+
export interface HostLocalSandbox {
|
|
99
|
+
kind: SandboxKind;
|
|
100
|
+
label: string;
|
|
101
|
+
unsandboxed_commands?: 'allow';
|
|
102
|
+
/** Network allowlist (operator rulings 2026-10-30): local commands reach the network only
|
|
103
|
+
* through stk's local-commands socket, whose egress.hosts the host operator sets. */
|
|
104
|
+
command_network?: { mode: 'broker'; socket: string; label: string };
|
|
105
|
+
}
|
|
106
|
+
export function hostLocalSandbox(
|
|
107
|
+
platform: NodeJS.Platform = process.platform,
|
|
108
|
+
find?: Parameters<typeof chooseLocalSandbox>[2],
|
|
109
|
+
findSb?: Parameters<typeof chooseLocalSandbox>[3],
|
|
110
|
+
allowUnsandboxed = false,
|
|
111
|
+
brokerSocket?: string,
|
|
112
|
+
): HostLocalSandbox {
|
|
113
|
+
const { kind } = chooseLocalSandbox('workspace-write', platform, find, findSb);
|
|
114
|
+
return {
|
|
115
|
+
kind,
|
|
116
|
+
label: kind === 'none' && !allowUnsandboxed ? 'refused' : sandboxLabel(kind),
|
|
117
|
+
...(allowUnsandboxed ? { unsandboxed_commands: 'allow' as const } : {}),
|
|
118
|
+
...(brokerSocket
|
|
119
|
+
? {
|
|
120
|
+
command_network: {
|
|
121
|
+
mode: 'broker' as const,
|
|
122
|
+
socket: brokerSocket,
|
|
123
|
+
label: 'broker (hosts set by the stk policy)',
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
: {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** The broker socket local commands reach the network through, or null for no network. */
|
|
130
|
+
export function hostCommandNetwork(m: HostManifest): { socket: string } | null {
|
|
131
|
+
const n = (m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.command_network;
|
|
132
|
+
return n?.mode === 'broker' && typeof n.socket === 'string' ? { socket: n.socket } : null;
|
|
133
|
+
}
|
|
134
|
+
/** Whether the reviewed manifest lets local commands run unsandboxed on a host without a sandbox. */
|
|
135
|
+
export function hostAllowsUnsandboxed(m: HostManifest): boolean {
|
|
136
|
+
return (
|
|
137
|
+
(m as { local_sandbox?: HostLocalSandbox }).local_sandbox?.unsandboxed_commands === 'allow'
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
export interface OperatorAccess {
|
|
141
|
+
group: string;
|
|
142
|
+
gid: number;
|
|
143
|
+
}
|
|
144
|
+
const groupName = /^[a-z_][a-z0-9_-]{0,31}$/;
|
|
145
|
+
/** Resolve a system group by name. The launcher profile never accepts a numeric id alone. */
|
|
146
|
+
export function resolveOperatorGroup(group: unknown): OperatorAccess {
|
|
147
|
+
if (typeof group !== 'string' || !groupName.test(group))
|
|
148
|
+
throw Error('operator_group must be a system group name');
|
|
149
|
+
let line: string;
|
|
150
|
+
try {
|
|
151
|
+
line = execFileSync('/usr/bin/getent', ['group', group], { encoding: 'utf8' }).trim();
|
|
152
|
+
} catch {
|
|
153
|
+
throw Error('operator_group does not exist');
|
|
154
|
+
}
|
|
155
|
+
const [name, , id] = line.split(':');
|
|
156
|
+
const gid = Number(id);
|
|
157
|
+
if (name !== group || !Number.isInteger(gid) || gid <= 0)
|
|
158
|
+
throw Error('operator_group must name a non-root group');
|
|
159
|
+
return { group, gid };
|
|
160
|
+
}
|
|
161
|
+
/** One minute to one day: long enough for a person to answer, short enough to free the station. */
|
|
162
|
+
function approvalTtl(value: unknown): number {
|
|
163
|
+
if (!Number.isInteger(value) || (value as number) < 60000 || (value as number) > 86400000)
|
|
164
|
+
throw Error('approval_ttl_ms must be an integer from 60000 to 86400000');
|
|
165
|
+
return value as number;
|
|
166
|
+
}
|
|
167
|
+
export function hostApprovalTtlMs(m: HostManifest): number | undefined {
|
|
168
|
+
return m.schema === 6 ? m.approval_ttl_ms : undefined;
|
|
169
|
+
}
|
|
170
|
+
export function hostOperatorAccess(m: HostManifest): OperatorAccess | undefined {
|
|
171
|
+
return m.schema === 6 ? m.operator_access : undefined;
|
|
81
172
|
}
|
|
82
173
|
export type HostManifest =
|
|
83
174
|
| CodingHostManifest
|
|
@@ -109,7 +200,7 @@ function privateDocument(path: string): Record<string, unknown> {
|
|
|
109
200
|
closeSync(fd);
|
|
110
201
|
}
|
|
111
202
|
}
|
|
112
|
-
function privateState(path: string): string {
|
|
203
|
+
function privateState(path: string, gid?: number): string {
|
|
113
204
|
if (!isAbsolute(path) || resolve(path) !== path)
|
|
114
205
|
throw Error('Use canonical absolute private state paths');
|
|
115
206
|
// Existing ancestors must resolve to their literal identity; never follow a workspace symlink.
|
|
@@ -119,7 +210,9 @@ function privateState(path: string): string {
|
|
|
119
210
|
throw Error('Private state path must not contain symlinks');
|
|
120
211
|
if (existsSync(path)) {
|
|
121
212
|
const st = lstatSync(path);
|
|
122
|
-
|
|
213
|
+
// The launcher profile lets exactly one group traverse (x) to the operator socket.
|
|
214
|
+
const shared = gid !== undefined && (st.mode & 0o077) === 0o010 && st.gid === gid;
|
|
215
|
+
if (!st.isDirectory() || st.uid !== process.getuid?.() || (st.mode & 0o077 && !shared))
|
|
123
216
|
throw Error('Host state must be a private owner-held directory');
|
|
124
217
|
}
|
|
125
218
|
return path;
|
|
@@ -145,6 +238,13 @@ async function planAgentPod(
|
|
|
145
238
|
'toolkit_config',
|
|
146
239
|
'station_config',
|
|
147
240
|
'strict_work_version',
|
|
241
|
+
'operator_group',
|
|
242
|
+
'operator_mcp_config',
|
|
243
|
+
'persona_config',
|
|
244
|
+
'approval_ttl_ms',
|
|
245
|
+
'unsandboxed_commands',
|
|
246
|
+
'command_network',
|
|
247
|
+
'command_network_socket',
|
|
148
248
|
];
|
|
149
249
|
if (Object.keys(c).some((k) => !fields.includes(k)))
|
|
150
250
|
throw Error('Unexpected AgentPod host config field');
|
|
@@ -153,7 +253,53 @@ async function planAgentPod(
|
|
|
153
253
|
(c.strict_work_version !== 1 || c.station_config === undefined)
|
|
154
254
|
)
|
|
155
255
|
throw Error('Strict work version1 requires reviewed protected station config');
|
|
156
|
-
|
|
256
|
+
let access: OperatorAccess | undefined;
|
|
257
|
+
if (c.operator_group !== undefined) {
|
|
258
|
+
if (c.strict_work_version !== 1)
|
|
259
|
+
throw Error('operator_group is only for the strict work launcher profile');
|
|
260
|
+
if (process.platform !== 'linux' || process.getuid?.() !== 0)
|
|
261
|
+
throw Error('The launcher profile is planned by root on Linux');
|
|
262
|
+
access = resolveOperatorGroup(c.operator_group);
|
|
263
|
+
}
|
|
264
|
+
let operatorMcp: { config: string; digest: string } | undefined;
|
|
265
|
+
if (c.operator_mcp_config !== undefined) {
|
|
266
|
+
if (c.strict_work_version !== 1)
|
|
267
|
+
throw Error('operator_mcp_config is only for the strict work host profile');
|
|
268
|
+
const config = string(c.operator_mcp_config, 'operator_mcp_config');
|
|
269
|
+
if (!isAbsolute(config) || realpathSync(config) !== config)
|
|
270
|
+
throw Error('Use a canonical absolute operator MCP config path');
|
|
271
|
+
operatorMcp = { config, digest: OperatorMcp.load(config).digest };
|
|
272
|
+
}
|
|
273
|
+
let persona: { config: string; digest: string } | undefined;
|
|
274
|
+
if (c.persona_config !== undefined) {
|
|
275
|
+
if (c.strict_work_version !== 1)
|
|
276
|
+
throw Error('persona_config is only for the strict work host profile');
|
|
277
|
+
const config = string(c.persona_config, 'persona_config');
|
|
278
|
+
persona = { config, digest: loadPersona(config).digest };
|
|
279
|
+
}
|
|
280
|
+
let ttl: number | undefined;
|
|
281
|
+
if (c.approval_ttl_ms !== undefined) {
|
|
282
|
+
if (c.strict_work_version !== 1)
|
|
283
|
+
throw Error('approval_ttl_ms is only for the strict work host profile');
|
|
284
|
+
ttl = approvalTtl(c.approval_ttl_ms);
|
|
285
|
+
}
|
|
286
|
+
if (c.unsandboxed_commands !== undefined && c.unsandboxed_commands !== 'allow')
|
|
287
|
+
throw Error('unsandboxed_commands is "allow" or absent');
|
|
288
|
+
const allowUnsandboxed = c.unsandboxed_commands === 'allow';
|
|
289
|
+
if (c.command_network !== undefined && c.command_network !== 'broker')
|
|
290
|
+
throw Error('command_network is "broker" or absent');
|
|
291
|
+
if (c.command_network_socket !== undefined) {
|
|
292
|
+
if (c.command_network !== 'broker')
|
|
293
|
+
throw Error('command_network_socket is only for command_network "broker"');
|
|
294
|
+
const sock = c.command_network_socket;
|
|
295
|
+
if (typeof sock !== 'string' || !isAbsolute(sock) || sock.includes('\0'))
|
|
296
|
+
throw Error('command_network_socket must be an absolute path');
|
|
297
|
+
}
|
|
298
|
+
const brokerSocket =
|
|
299
|
+
c.command_network === 'broker'
|
|
300
|
+
? String(c.command_network_socket ?? DEFAULT_COMMAND_NETWORK_SOCKET)
|
|
301
|
+
: undefined;
|
|
302
|
+
const state = privateState(string(c.state, 'state'), access?.gid);
|
|
157
303
|
const config = string(c.agentpod_config, 'agentpod_config');
|
|
158
304
|
if (!isAbsolute(config) || realpathSync(config) !== config)
|
|
159
305
|
throw Error('Use a canonical absolute pins path');
|
|
@@ -205,7 +351,14 @@ async function planAgentPod(
|
|
|
205
351
|
...(station
|
|
206
352
|
? {
|
|
207
353
|
...(c.strict_work_version === 1
|
|
208
|
-
? {
|
|
354
|
+
? {
|
|
355
|
+
schema: 6 as const,
|
|
356
|
+
strict_work: { version: 1 as const },
|
|
357
|
+
...(access ? { operator_access: access } : {}),
|
|
358
|
+
...(operatorMcp ? { operator_mcp: operatorMcp } : {}),
|
|
359
|
+
...(persona ? { persona } : {}),
|
|
360
|
+
...(ttl !== undefined ? { approval_ttl_ms: ttl } : {}),
|
|
361
|
+
}
|
|
209
362
|
: { schema: 5 as const }),
|
|
210
363
|
station_execution: station,
|
|
211
364
|
...(toolkit ? { managed_toolkit: toolkit } : {}),
|
|
@@ -231,6 +384,13 @@ async function planAgentPod(
|
|
|
231
384
|
pins_digest: digest(pins),
|
|
232
385
|
workspace: pins.workspace,
|
|
233
386
|
cli_digest: digest(readFileSync(join(ROOT, 'bin/salaam.mjs'), 'utf8')),
|
|
387
|
+
local_sandbox: hostLocalSandbox(
|
|
388
|
+
undefined,
|
|
389
|
+
undefined,
|
|
390
|
+
undefined,
|
|
391
|
+
allowUnsandboxed,
|
|
392
|
+
brokerSocket,
|
|
393
|
+
),
|
|
234
394
|
};
|
|
235
395
|
}
|
|
236
396
|
export function hostDigests() {
|
|
@@ -335,6 +495,11 @@ export function readHost(path: string): HostManifest {
|
|
|
335
495
|
: []),
|
|
336
496
|
...([5, 6].includes(m.schema) ? ['station_execution'] : []),
|
|
337
497
|
...(m.schema === 6 ? ['strict_work'] : []),
|
|
498
|
+
...(m.schema === 6 && Object.hasOwn(m, 'operator_access') ? ['operator_access'] : []),
|
|
499
|
+
...(m.schema === 6 && Object.hasOwn(m, 'operator_mcp') ? ['operator_mcp'] : []),
|
|
500
|
+
...(m.schema === 6 && Object.hasOwn(m, 'persona') ? ['persona'] : []),
|
|
501
|
+
...(m.schema === 6 && Object.hasOwn(m, 'approval_ttl_ms') ? ['approval_ttl_ms'] : []),
|
|
502
|
+
...(Object.hasOwn(m, 'local_sandbox') ? ['local_sandbox'] : []),
|
|
338
503
|
];
|
|
339
504
|
if (m.mode !== 'agentpod' || Object.keys(m).sort().join(',') !== fields.sort().join(','))
|
|
340
505
|
throw Error('Unsupported AgentPod host manifest fields');
|
|
@@ -343,9 +508,55 @@ export function readHost(path: string): HostManifest {
|
|
|
343
508
|
(Object.keys(object(m.strict_work)).join(',') !== 'version' || m.strict_work.version !== 1)
|
|
344
509
|
)
|
|
345
510
|
throw Error('Unsupported strict work host contract');
|
|
346
|
-
|
|
511
|
+
const access = hostOperatorAccess(m);
|
|
512
|
+
if (access !== undefined) {
|
|
513
|
+
const a = object(access);
|
|
514
|
+
if (
|
|
515
|
+
Object.keys(a).sort().join(',') !== 'gid,group' ||
|
|
516
|
+
canonical(resolveOperatorGroup(a.group)) !== canonical({ group: a.group, gid: a.gid })
|
|
517
|
+
)
|
|
518
|
+
throw Error('Host operator group changed');
|
|
519
|
+
}
|
|
520
|
+
privateState(m.state, access?.gid);
|
|
521
|
+
if (m.schema === 6 && Object.hasOwn(m, 'approval_ttl_ms')) approvalTtl(m.approval_ttl_ms);
|
|
522
|
+
if (m.schema === 6 && Object.hasOwn(m, 'operator_mcp')) {
|
|
523
|
+
const o = object(m.operator_mcp);
|
|
524
|
+
if (
|
|
525
|
+
Object.keys(o).sort().join(',') !== 'config,digest' ||
|
|
526
|
+
typeof o.config !== 'string' ||
|
|
527
|
+
!isAbsolute(o.config) ||
|
|
528
|
+
realpathSync(o.config) !== o.config ||
|
|
529
|
+
OperatorMcp.load(o.config).digest !== o.digest
|
|
530
|
+
)
|
|
531
|
+
throw Error('Operator MCP declarations changed');
|
|
532
|
+
}
|
|
533
|
+
if (m.schema === 6 && Object.hasOwn(m, 'persona')) {
|
|
534
|
+
const p = object(m.persona);
|
|
535
|
+
let current: string | null = null;
|
|
536
|
+
try {
|
|
537
|
+
current = loadPersona(String(p.config)).digest;
|
|
538
|
+
} catch {
|
|
539
|
+
current = null;
|
|
540
|
+
}
|
|
541
|
+
if (Object.keys(p).sort().join(',') !== 'config,digest' || current !== p.digest)
|
|
542
|
+
throw Error('Host persona changed; run host plan again');
|
|
543
|
+
}
|
|
347
544
|
if (m.cli_digest !== digest(readFileSync(join(ROOT, 'bin/salaam.mjs'), 'utf8')))
|
|
348
545
|
throw Error('Host CLI changed');
|
|
546
|
+
if (
|
|
547
|
+
Object.hasOwn(m, 'local_sandbox') &&
|
|
548
|
+
canonical(m.local_sandbox) !==
|
|
549
|
+
canonical(
|
|
550
|
+
hostLocalSandbox(
|
|
551
|
+
undefined,
|
|
552
|
+
undefined,
|
|
553
|
+
undefined,
|
|
554
|
+
hostAllowsUnsandboxed(m),
|
|
555
|
+
hostCommandNetwork(m)?.socket,
|
|
556
|
+
),
|
|
557
|
+
)
|
|
558
|
+
)
|
|
559
|
+
throw Error('Host command sandbox changed; run host plan again');
|
|
349
560
|
const pins = readAgentPodPins(m.agentpod_config, m.state);
|
|
350
561
|
const managed = hostMcp(m);
|
|
351
562
|
if (m.schema === 3 || ([4, 5, 6].includes(m.schema) && Object.hasOwn(m, 'managed_mcp'))) {
|
|
@@ -396,6 +607,33 @@ export function readHost(path: string): HostManifest {
|
|
|
396
607
|
}
|
|
397
608
|
return m;
|
|
398
609
|
}
|
|
610
|
+
/** What the daemon reports about itself so that a caller which cannot read the
|
|
611
|
+
* manifest (the launcher profile) receives the same receipt as host start. */
|
|
612
|
+
export function hostReceipt(m: HostManifest) {
|
|
613
|
+
if (m.schema === 1) return undefined;
|
|
614
|
+
const pins = readAgentPodPins(m.agentpod_config, m.state);
|
|
615
|
+
return {
|
|
616
|
+
version: 1,
|
|
617
|
+
ready: true,
|
|
618
|
+
manifest_digest: digest(m),
|
|
619
|
+
node: m.node,
|
|
620
|
+
cli: join(m.repo, 'bin/salaam.mjs'),
|
|
621
|
+
state: m.state,
|
|
622
|
+
workspace: m.workspace,
|
|
623
|
+
node_id: pins.node_id,
|
|
624
|
+
pins_digest: m.pins_digest,
|
|
625
|
+
strict_work: m.schema === 6 ? m.strict_work : null,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
/** The tools user a reviewed station config names, for host serve's --tools-user. */
|
|
629
|
+
export function hostToolsUser(m: HostManifest): string | undefined {
|
|
630
|
+
if (m.schema !== 5 && m.schema !== 6) return undefined;
|
|
631
|
+
return readStationConfig(
|
|
632
|
+
m.station_execution.config,
|
|
633
|
+
m.workspace,
|
|
634
|
+
m.station_execution.configuration_digest,
|
|
635
|
+
).tools_user;
|
|
636
|
+
}
|
|
399
637
|
export function hostUnit(path: string, platform: string): string {
|
|
400
638
|
const m = readHost(path),
|
|
401
639
|
manifest = realpathSync(path),
|