@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-outcome.js +177 -17
  4. package/dist/src/acp.js +41 -7
  5. package/dist/src/agent-profiles.js +45 -0
  6. package/dist/src/agentpod-authority.js +169 -11
  7. package/dist/src/agentpod-default-config.js +12 -0
  8. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  9. package/dist/src/agentpod-work-decisions.js +108 -4
  10. package/dist/src/agentpod-work.js +1214 -127
  11. package/dist/src/board-reads.js +106 -0
  12. package/dist/src/children.js +83 -0
  13. package/dist/src/cli-help.js +27 -2
  14. package/dist/src/cli.js +87 -3
  15. package/dist/src/command-network.js +200 -0
  16. package/dist/src/command-sandbox.js +217 -0
  17. package/dist/src/execution-evidence.js +33 -1
  18. package/dist/src/host-bind.js +111 -0
  19. package/dist/src/host-breaker.js +88 -0
  20. package/dist/src/host-start.js +41 -1
  21. package/dist/src/host.js +192 -6
  22. package/dist/src/house-extension.js +162 -57
  23. package/dist/src/local-coding.js +214 -18
  24. package/dist/src/local-edit-helper.js +25 -0
  25. package/dist/src/local-undo.js +25 -16
  26. package/dist/src/managed.js +1 -1
  27. package/dist/src/operator-mcp-cli.js +107 -0
  28. package/dist/src/operator-mcp.js +342 -0
  29. package/dist/src/persona.js +31 -0
  30. package/dist/src/pi.js +22 -1
  31. package/dist/src/protocol.js +5 -2
  32. package/dist/src/rate-limit-retries.js +62 -0
  33. package/dist/src/recurring-work.js +1 -0
  34. package/dist/src/repository-snapshots.js +5 -2
  35. package/dist/src/run-grants.js +62 -0
  36. package/dist/src/service.js +141 -6
  37. package/dist/src/station-checkpoints.js +139 -0
  38. package/dist/src/station-config.js +8 -1
  39. package/dist/src/station-files.js +20 -1
  40. package/dist/src/station-task-verification.js +6 -3
  41. package/dist/src/station-work.js +315 -22
  42. package/dist/src/store.js +19 -0
  43. package/dist/src/supervisor.js +177 -5
  44. package/dist/src/task-budget.js +39 -3
  45. package/dist/src/task-critic.js +115 -0
  46. package/dist/src/task-driver.js +46 -2
  47. package/dist/src/task-policy.js +44 -12
  48. package/dist/src/task-verification.js +42 -9
  49. package/dist/src/task-work.js +47 -0
  50. package/dist/src/tool-identity.js +69 -0
  51. package/dist/src/usage.js +1 -1
  52. package/npm-shrinkwrap.json +2 -2
  53. package/package.json +1 -1
  54. package/src/acp-outcome.ts +169 -19
  55. package/src/acp.ts +53 -7
  56. package/src/agent-profiles.ts +48 -0
  57. package/src/agentpod-authority.ts +234 -15
  58. package/src/agentpod-default-config.ts +16 -0
  59. package/src/agentpod-work-decision-protocol.ts +69 -8
  60. package/src/agentpod-work-decisions.ts +123 -5
  61. package/src/agentpod-work.ts +1455 -152
  62. package/src/board-reads.ts +148 -0
  63. package/src/children.ts +112 -0
  64. package/src/cli-help.ts +27 -2
  65. package/src/cli.ts +109 -3
  66. package/src/command-network.ts +215 -0
  67. package/src/command-sandbox.ts +273 -0
  68. package/src/execution-evidence.ts +31 -1
  69. package/src/host-bind.ts +156 -0
  70. package/src/host-breaker.ts +89 -0
  71. package/src/host-start.ts +51 -1
  72. package/src/host.ts +244 -6
  73. package/src/house-extension.ts +174 -56
  74. package/src/local-coding.ts +270 -18
  75. package/src/local-edit-helper.ts +26 -0
  76. package/src/local-undo.ts +29 -14
  77. package/src/managed.ts +2 -2
  78. package/src/operator-mcp-cli.ts +108 -0
  79. package/src/operator-mcp.ts +420 -0
  80. package/src/persona.ts +37 -0
  81. package/src/pi.ts +23 -1
  82. package/src/protocol.ts +6 -1
  83. package/src/rate-limit-retries.ts +69 -0
  84. package/src/recurring-work.ts +1 -0
  85. package/src/repository-snapshots.ts +5 -2
  86. package/src/run-grants.ts +107 -0
  87. package/src/service.ts +222 -6
  88. package/src/station-checkpoints.ts +183 -0
  89. package/src/station-config.ts +10 -1
  90. package/src/station-files.ts +20 -1
  91. package/src/station-task-verification.ts +6 -3
  92. package/src/station-work.ts +369 -20
  93. package/src/store.ts +18 -0
  94. package/src/supervisor.ts +203 -4
  95. package/src/task-budget.ts +48 -3
  96. package/src/task-critic.ts +171 -0
  97. package/src/task-driver.ts +49 -2
  98. package/src/task-policy.ts +65 -13
  99. package/src/task-verification.ts +53 -9
  100. package/src/task-work.ts +75 -0
  101. package/src/tool-identity.ts +110 -0
  102. package/src/usage.ts +1 -1
@@ -0,0 +1,217 @@
1
+ import { COMMAND_NETWORK_PORT, inSandboxRelaySource } from './command-network.js';
2
+ import { existsSync } from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { isAbsolute } from 'node:path';
5
+ import { ServiceError } from './protocol.js';
6
+ /**
7
+ * Item 10: the sandbox a local command runs in. `workspace-write` (the default) and `read-only` run
8
+ * the command under the host's OS sandbox, with the system read-only, a private tmp, the workspace
9
+ * writable or read-only, and no network unless declared:
10
+ * - Linux: bubblewrap, with an empty root and user, PID, IPC, UTS and network namespaces unshared,
11
+ * dying with its parent;
12
+ * - macOS: Seatbelt (`sandbox-exec`), with a deny-by-default profile.
13
+ * `full-access` runs the command as is and must be chosen explicitly by the operator. A host with
14
+ * no usable sandbox refuses local commands (operator ruling 2026-10-30, option A) unless the host
15
+ * config sets `unsandboxed_commands: "allow"`; then it runs them unsandboxed and says so:
16
+ * "unsandboxed" in host plan and in every approval, and kind `none` in the approved arguments and
17
+ * the execution result.
18
+ */
19
+ export const SANDBOX_LEVELS = ['read-only', 'workspace-write', 'full-access'];
20
+ /** The pinned bubblewrap: its absolute path and the version it reports, or null when unusable. */
21
+ export function findBwrap(candidates = ['/usr/bin/bwrap', '/bin/bwrap']) {
22
+ for (const path of candidates) {
23
+ if (!existsSync(path))
24
+ continue;
25
+ try {
26
+ const version = execFileSync(path, ['--version'], { encoding: 'utf8', timeout: 5000 }).trim();
27
+ // Usable means it can actually make the namespaces we ask for, not just that it exists.
28
+ execFileSync(path, ['--unshare-user', '--unshare-net', '--ro-bind', '/', '/', '--', '/bin/true'], {
29
+ timeout: 5000,
30
+ stdio: 'ignore',
31
+ });
32
+ return { path, version };
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+ export function sandboxArgv(level, root, argv, bwrap, options) {
41
+ if (level === 'full-access')
42
+ return argv;
43
+ if (!bwrap)
44
+ throw new ServiceError('sandbox_unavailable', 'This host has no usable bubblewrap, so it cannot run a sandboxed command; an operator can choose full access explicitly');
45
+ if (!isAbsolute(root) || root.includes('\0'))
46
+ throw new ServiceError('invalid_field', 'A sandboxed command needs an absolute workspace path');
47
+ // Optional system directories, bound read-only only where the host has them.
48
+ const optional = ['/lib', '/lib64', '/lib32', '/bin', '/sbin', '/etc/alternatives'].flatMap((dir) => ['--ro-bind-try', dir, dir]);
49
+ return [
50
+ bwrap.path,
51
+ '--unshare-user',
52
+ '--unshare-pid',
53
+ '--unshare-ipc',
54
+ '--unshare-uts',
55
+ ...(options.network ? [] : ['--unshare-net']),
56
+ '--die-with-parent',
57
+ '--new-session',
58
+ '--tmpfs',
59
+ '/',
60
+ // The system read-only; /usr and /etc/ssl always asked for, the rest only where present.
61
+ '--ro-bind',
62
+ '/usr',
63
+ '/usr',
64
+ '--ro-bind',
65
+ '/etc/ssl',
66
+ '/etc/ssl',
67
+ ...optional,
68
+ '--proc',
69
+ '/proc',
70
+ '--dev',
71
+ '/dev',
72
+ '--tmpfs',
73
+ '/tmp',
74
+ level === 'read-only' ? '--ro-bind' : '--bind',
75
+ root,
76
+ root,
77
+ // Broker network: only the host relay's socket directory and the node that bridges to it.
78
+ ...(options.broker
79
+ ? [
80
+ '--bind',
81
+ options.broker.dir,
82
+ '/run/salaam-net',
83
+ '--ro-bind',
84
+ options.broker.node,
85
+ '/run/salaam-node',
86
+ ]
87
+ : []),
88
+ // The empty root itself is read-only too: only the workspace (and the private /tmp) is writable.
89
+ '--remount-ro',
90
+ '/',
91
+ '--chdir',
92
+ root,
93
+ '--',
94
+ ...(options.broker
95
+ ? [
96
+ '/run/salaam-node',
97
+ '-e',
98
+ inSandboxRelaySource,
99
+ '/run/salaam-net/relay.sock',
100
+ String(COMMAND_NETWORK_PORT),
101
+ ]
102
+ : []),
103
+ ...argv,
104
+ ];
105
+ }
106
+ function brokerPort(port) {
107
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
108
+ throw new ServiceError('invalid_field', 'A broker relay port is 1–65535');
109
+ return port;
110
+ }
111
+ /** The pinned Seatbelt launcher, or null when this host cannot apply a deny-by-default profile. */
112
+ export function findSeatbelt(path = '/usr/bin/sandbox-exec') {
113
+ if (!existsSync(path))
114
+ return null;
115
+ try {
116
+ // Usable means it applies a deny-by-default profile and the command still runs.
117
+ execFileSync(path, [
118
+ '-p',
119
+ '(version 1)(deny default)(allow process-exec process-fork)(allow file-read*)(allow sysctl-read)',
120
+ '/usr/bin/true',
121
+ ], { timeout: 5000, stdio: 'ignore' });
122
+ return { path };
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ /** A path Seatbelt can quote: absolute, with no quote, backslash or control character. */
129
+ function sbplPath(path, what) {
130
+ if (!isAbsolute(path) || /["\\\x00-\x1f\x7f]/.test(path))
131
+ throw new ServiceError('invalid_field', `A sandboxed command needs a plain absolute ${what}`);
132
+ return `"${path}"`;
133
+ }
134
+ /**
135
+ * The deny-by-default Seatbelt profile for a level. Reads: the system, developer tools and
136
+ * package prefixes, the workspace and the private tmp (file metadata anywhere, so paths resolve).
137
+ * Writes: the workspace (workspace-write only), the private tmp and the standard devices. No
138
+ * network unless declared, no mach services, no home directory.
139
+ */
140
+ export function seatbeltProfile(level, root, tmp,
141
+ /** brokerPort: the host relay's loopback port, the only place a broker-network command may connect. */
142
+ options) {
143
+ const w = sbplPath(root, 'workspace path'), t = sbplPath(tmp, 'tmp path');
144
+ const system = [
145
+ '/usr',
146
+ '/bin',
147
+ '/sbin',
148
+ '/System',
149
+ '/Library/Apple',
150
+ '/Library/Developer/CommandLineTools',
151
+ '/Applications/Xcode.app',
152
+ '/opt/homebrew',
153
+ '/usr/local',
154
+ '/private/etc',
155
+ '/private/var/db/dyld',
156
+ '/private/var/db/timezone',
157
+ '/dev',
158
+ ]
159
+ .map((p) => `(subpath "${p}")`)
160
+ .join(' ');
161
+ const devices = ['/dev/null', '/dev/zero', '/dev/stdout', '/dev/stderr', '/dev/tty']
162
+ .map((p) => `(literal "${p}")`)
163
+ .join(' ');
164
+ return [
165
+ '(version 1)',
166
+ '(deny default)',
167
+ '(allow process-exec process-fork)',
168
+ '(allow signal (target same-sandbox))',
169
+ '(allow sysctl-read)',
170
+ '(allow file-read-metadata)',
171
+ `(allow file-read* (literal "/") ${system} (subpath ${w}) (subpath ${t}))`,
172
+ level === 'workspace-write'
173
+ ? `(allow file-write* (subpath ${w}) (subpath ${t}) ${devices} (regex #"^/dev/fd/"))`
174
+ : `(allow file-write* (subpath ${t}) ${devices} (regex #"^/dev/fd/"))`,
175
+ `(allow file-ioctl ${devices} (regex #"^/dev/fd/"))`,
176
+ ...(options.network ? ['(allow network*)'] : []),
177
+ ...(options.brokerPort !== undefined && !options.network
178
+ ? [`(allow network-outbound (remote tcp "localhost:${brokerPort(options.brokerPort)}"))`]
179
+ : []),
180
+ ].join('\n');
181
+ }
182
+ export function seatbeltArgv(level, root, tmp, argv, seatbelt, options) {
183
+ if (level === 'full-access')
184
+ return argv;
185
+ if (!seatbelt)
186
+ throw new ServiceError('sandbox_unavailable', 'This host has no usable Seatbelt, so it cannot run a sandboxed command; an operator can choose full access explicitly');
187
+ return [
188
+ seatbelt.path,
189
+ '-p',
190
+ seatbeltProfile(level, root, tmp, options),
191
+ '/usr/bin/env',
192
+ `TMPDIR=${tmp}/`,
193
+ ...argv,
194
+ ];
195
+ }
196
+ /** The sandbox this host gives local commands: bubblewrap on Linux and Seatbelt on macOS when
197
+ * usable, otherwise none. */
198
+ export function chooseLocalSandbox(level, platform = process.platform, find = findBwrap, findSb = findSeatbelt) {
199
+ const none = { kind: 'none', level, bwrap: null, seatbelt: null };
200
+ if (level === 'full-access')
201
+ return none;
202
+ if (platform === 'linux') {
203
+ const bwrap = find();
204
+ return bwrap ? { ...none, kind: 'bwrap', bwrap } : none;
205
+ }
206
+ if (platform === 'darwin') {
207
+ const seatbelt = findSb();
208
+ return seatbelt ? { ...none, kind: 'seatbelt', seatbelt } : none;
209
+ }
210
+ return none;
211
+ }
212
+ /** How host plan and approvals name a sandbox kind. */
213
+ export function sandboxLabel(kind) {
214
+ return kind === 'bwrap' ? 'bubblewrap' : kind === 'seatbelt' ? 'seatbelt' : 'unsandboxed';
215
+ }
216
+ /** The refusal for a local command on a host with no usable sandbox and no operator opt-in. */
217
+ export const UNSANDBOXED_REFUSAL = 'Local commands are refused: this host has no usable command sandbox (bubblewrap on Linux, Seatbelt on macOS). Install or repair bubblewrap, or have the operator set unsandboxed_commands: "allow" in the host config to run them unsandboxed.';
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { digest, ServiceError, textDigest } from './protocol.js';
2
3
  import { verifyReadinessFingerprint } from './readiness-fingerprint.js';
3
4
  import { committedToolkitResolutionEvidence } from './toolkit-evidence.js';
@@ -181,6 +182,34 @@ export function executionEvidence(store, artifacts, admission, taskId, session)
181
182
  const artifactRows = bounded('artifacts', owned(`
182
183
  SELECT id,session_id,digest,bytes FROM artifacts
183
184
  WHERE session_id IN (SELECT id FROM tree) ORDER BY id LIMIT 257`));
185
+ // A verified completion names each deliverable's artifact with the sha256 of its bytes.
186
+ // Only an artifact whose bytes match that digest counts as produced; dispositions are
187
+ // computed for this session's own artifacts alone, so another session's id names nothing.
188
+ const deliverables = new Set();
189
+ const completed = store.db
190
+ .prepare("SELECT artifact_id FROM task_completions WHERE task_id=? AND state='succeeded'")
191
+ .get(taskId);
192
+ if (completed?.artifact_id) {
193
+ let record = {};
194
+ try {
195
+ record = JSON.parse(artifacts.read(String(completed.artifact_id)));
196
+ }
197
+ catch {
198
+ record = {};
199
+ }
200
+ for (const d of Array.isArray(record.deliverables) ? record.deliverables : []) {
201
+ const id = d.artifact_id, sha = d.sha256;
202
+ if (typeof id !== 'string' || typeof sha !== 'string')
203
+ continue;
204
+ try {
205
+ if (createHash('sha256').update(artifacts.read(id)).digest('hex') === sha)
206
+ deliverables.add(id);
207
+ }
208
+ catch {
209
+ /* a missing or corrupt artifact is not a deliverable */
210
+ }
211
+ }
212
+ }
184
213
  const artifactEvidence = artifactRows.map((row) => {
185
214
  // Check actual immutable bytes, not just a database's claimed fingerprint.
186
215
  const content = artifacts.read(String(row.id));
@@ -207,7 +236,10 @@ export function executionEvidence(store, artifacts, admission, taskId, session)
207
236
  ? 'integrated'
208
237
  : children.some((child) => child.artifact_id === row.id)
209
238
  ? 'proposed'
210
- : appliedPatch || completion || checks.some((check) => check.artifact_id === row.id)
239
+ : appliedPatch ||
240
+ completion ||
241
+ deliverables.has(String(row.id)) ||
242
+ checks.some((check) => check.artifact_id === row.id)
211
243
  ? 'produced'
212
244
  : 'observed';
213
245
  return { ...row, disposition };
@@ -0,0 +1,111 @@
1
+ import { chmodSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, writeFileSync, existsSync, } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { isAbsolute, join, resolve } from 'node:path';
5
+ import { planHost } from './host.js';
6
+ import { canonical, digest } from './protocol.js';
7
+ import { stationHash, stationRuntimeFingerprint, stationWorkspaceIdentity, } from './station-config.js';
8
+ const userName = /^[a-z_][a-z0-9_-]{0,31}$/;
9
+ function pin(path) {
10
+ const real = realpathSync(path);
11
+ return { path: real, sha256: stationHash(readFileSync(real)) };
12
+ }
13
+ /** Write a private file only when its content changes; report whether it did. */
14
+ function put(path, value) {
15
+ const text = JSON.stringify(value, null, 2) + '\n';
16
+ if (existsSync(path) && readFileSync(path, 'utf8') === text)
17
+ return false;
18
+ const tmp = path + '.tmp', fd = openSync(tmp, 'w', 0o600);
19
+ try {
20
+ writeFileSync(fd, text);
21
+ fsyncSync(fd);
22
+ }
23
+ finally {
24
+ closeSync(fd);
25
+ }
26
+ renameSync(tmp, path);
27
+ return true;
28
+ }
29
+ /** Bind one AgentPod station to a launcher-profile strict work host. Root on Linux.
30
+ * Every output is derived from the inputs; nothing is edited by hand. */
31
+ export async function bindHost(input) {
32
+ if (process.platform !== 'linux' || process.getuid?.() !== 0)
33
+ throw Error('host bind runs as root on Linux');
34
+ const workspace = input.workspace;
35
+ if (!isAbsolute(workspace) || realpathSync(workspace) !== workspace)
36
+ throw Error('Use the canonical absolute workspace path');
37
+ const key = 'salaam:' + createHash('sha256').update(workspace).digest('hex').slice(0, 16);
38
+ if (input.station_key !== key)
39
+ throw Error('Station key does not name this workspace (expected ' + key + ')');
40
+ if (!userName.test(input.tools_user))
41
+ throw Error('tools user must be a user name');
42
+ try {
43
+ execFileSync('/usr/bin/getent', ['passwd', input.tools_user], { stdio: 'ignore' });
44
+ }
45
+ catch {
46
+ throw Error('tools user does not exist');
47
+ }
48
+ if (input.tools_user === 'root')
49
+ throw Error('tools user must not be root');
50
+ const id = key.slice('salaam:'.length), roots = input.roots ?? { config: '/etc/salaam/stations', state: '/var/lib/salaam/stations' }, dir = resolve(input.dir ?? join(roots.config, id)), state = resolve(input.state ?? join(roots.state, id, 'state'));
51
+ mkdirSync(dir, { recursive: true, mode: 0o755 });
52
+ // Ancestors of state stay traversable so the operator group can reach operator.sock;
53
+ // state itself is the daemon's (0700, or 0710 for the operator group once served).
54
+ mkdirSync(join(state, '..'), { recursive: true, mode: 0o755 });
55
+ chmodSync(join(state, '..'), 0o755);
56
+ const pinsPath = join(dir, 'agentpod-pins.json'), stationPath = join(dir, 'station-config.json'), configPath = join(dir, 'host-config.json'), manifestPath = join(dir, 'host.json');
57
+ let changed = put(pinsPath, {
58
+ version: 1,
59
+ organization_id: input.organization_id,
60
+ node_id: input.node_id,
61
+ station_key: key,
62
+ workspace,
63
+ });
64
+ const runtime = realpathSync(input.runtime_root);
65
+ changed =
66
+ put(stationPath, {
67
+ version: 1,
68
+ kind: 'linux-systemd-station-v1',
69
+ workspace,
70
+ workspace_identity: stationWorkspaceIdentity(workspace),
71
+ files: pin(input.files),
72
+ systemd_run: pin(input.systemd_run ?? '/usr/bin/systemd-run'),
73
+ systemctl: pin(input.systemctl ?? '/usr/bin/systemctl'),
74
+ runtime_root: runtime,
75
+ runtime_fingerprint: stationRuntimeFingerprint(runtime),
76
+ runtime_image_digest: input.runtime_image_digest,
77
+ tools_user: input.tools_user,
78
+ }) || changed;
79
+ const config = {
80
+ mode: 'agentpod',
81
+ state,
82
+ agentpod_config: pinsPath,
83
+ station_config: stationPath,
84
+ strict_work_version: 1,
85
+ ...(input.operator_group ? { operator_group: input.operator_group } : {}),
86
+ ...(input.provider ? { provider: input.provider } : {}),
87
+ ...(input.model ? { model: input.model } : {}),
88
+ ...(input.mcp_config ? { operator_mcp_config: realpathSync(input.mcp_config) } : {}),
89
+ ...(input.persona ? { persona_config: realpathSync(input.persona) } : {}),
90
+ ...(input.managed_mcp_config ? { mcp_config: realpathSync(input.managed_mcp_config) } : {}),
91
+ };
92
+ changed = put(configPath, config) || changed;
93
+ const manifest = await planHost(config);
94
+ const before = existsSync(manifestPath)
95
+ ? canonical(JSON.parse(readFileSync(manifestPath, 'utf8')))
96
+ : null;
97
+ if (before !== canonical(manifest)) {
98
+ put(manifestPath, manifest);
99
+ changed = true;
100
+ }
101
+ return {
102
+ version: 1,
103
+ station_key: key,
104
+ dir,
105
+ state,
106
+ manifest: manifestPath,
107
+ manifest_digest: digest(manifest),
108
+ unit: 'salaam-host@' + id + '.service',
109
+ changed,
110
+ };
111
+ }
@@ -0,0 +1,88 @@
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
+ export function fetchHost(args) {
11
+ const url = args?.url;
12
+ if (typeof url !== 'string')
13
+ return null;
14
+ try {
15
+ const host = new URL(url).hostname.toLowerCase();
16
+ return host || null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export class HostBreaker {
23
+ now;
24
+ hosts = new Map();
25
+ constructor(now = Date.now) {
26
+ this.now = now;
27
+ }
28
+ key(session, host) {
29
+ return session + '\u0000' + host;
30
+ }
31
+ /** Milliseconds this host stays paused for the session, 0 when calls may go ahead. */
32
+ paused(session, host) {
33
+ const h = this.hosts.get(this.key(session, host));
34
+ return h ? Math.max(0, h.until - this.now()) : 0;
35
+ }
36
+ /** Records a call's outcome; true when this failure just opened the breaker. */
37
+ record(session, host, ok) {
38
+ const key = this.key(session, host);
39
+ if (ok) {
40
+ this.hosts.delete(key);
41
+ return false;
42
+ }
43
+ const h = this.hosts.get(key) ?? { failures: 0, until: 0 };
44
+ h.failures++;
45
+ this.hosts.set(key, h);
46
+ if (h.failures < BREAKER_FAILURES)
47
+ return false;
48
+ h.failures = 0;
49
+ h.until = this.now() + BREAKER_PAUSE_MS;
50
+ return true;
51
+ }
52
+ forget(session) {
53
+ for (const key of this.hosts.keys())
54
+ if (key.startsWith(session + '\u0000'))
55
+ this.hosts.delete(key);
56
+ }
57
+ }
58
+ /**
59
+ * One operator MCP call through the breaker. A paused host returns a tool error without calling;
60
+ * a thrown failure or a tool error counts against the host; the call that opens the breaker
61
+ * records `fetch.host_paused` for the run's activity. The call's own result or error is unchanged.
62
+ */
63
+ export async function callThroughBreaker(breaker, session, args, event, call) {
64
+ const host = fetchHost(args);
65
+ if (!host)
66
+ return call();
67
+ const wait = breaker.paused(session, host);
68
+ if (wait)
69
+ return {
70
+ content: [
71
+ {
72
+ type: 'text',
73
+ text: `Requests to ${host} are paused for ${Math.ceil(wait / 1000)}s after repeated failures; try another source or retry later.`,
74
+ },
75
+ ],
76
+ is_error: true,
77
+ };
78
+ let ok = false;
79
+ try {
80
+ const result = await call();
81
+ ok = !result.is_error;
82
+ return result;
83
+ }
84
+ finally {
85
+ if (breaker.record(session, host, ok))
86
+ event({ host, failures: BREAKER_FAILURES, pause_ms: BREAKER_PAUSE_MS });
87
+ }
88
+ }
@@ -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';
@@ -118,3 +118,43 @@ export async function startHost(path) {
118
118
  release();
119
119
  }
120
120
  }
121
+ const launcherUnit = /^salaam-host@[a-f0-9]{16}\.service$/;
122
+ /** Launcher profile: an unprivileged operator asks systemd to start a root-owned,
123
+ * provisioned unit. It names only the unit; root reads its own reviewed manifest. */
124
+ export async function startHostUnit(unit, state, options = {}) {
125
+ if (!launcherUnit.test(unit))
126
+ throw Error('Unit must be salaam-host@<16 hex>.service');
127
+ if (realpathSync(state) !== state)
128
+ throw Error('Use the canonical state path');
129
+ const socket = join(state, 'operator.sock'), deadline = Date.now() + (options.timeoutMs ?? 20000);
130
+ const ready = async () => {
131
+ let h;
132
+ try {
133
+ h = await call(socket, '/v1/health', undefined, undefined, 2000);
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ if (!h.host_receipt || h.host_receipt.manifest_digest !== h.host_manifest_digest)
139
+ throw new ServiceError('host_mismatch', 'Running daemon reports no reviewed host receipt');
140
+ if (h.host_receipt.state !== state)
141
+ throw new ServiceError('host_mismatch', 'Running daemon serves another state directory');
142
+ if (h.host_supervisor_connected !== true || h.service !== 'healthy')
143
+ return null;
144
+ if (h.job_execution?.control_error ||
145
+ h.job_execution?.schedule_control_error ||
146
+ h.job_execution?.inbox_control_error)
147
+ throw Error('Reviewed host controls are not ready');
148
+ return h.host_receipt;
149
+ };
150
+ let receipt = await ready();
151
+ if (receipt)
152
+ return receipt;
153
+ await new Promise((done, fail) => execFile(options.systemctl ?? '/usr/bin/systemctl', ['start', '--no-ask-password', unit], { env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' }, timeout: 20000 }, (error) => error ? fail(Error('systemd refused to start ' + unit + '; inspect its journal')) : done()));
154
+ while (!(receipt = await ready())) {
155
+ if (Date.now() >= deadline)
156
+ throw Error('Host unit did not become ready; inspect its journal');
157
+ await new Promise((r) => setTimeout(r, 100));
158
+ }
159
+ return receipt;
160
+ }