@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.
Files changed (172) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-desk.js +140 -0
  4. package/dist/src/acp-outcome.js +182 -18
  5. package/dist/src/acp.js +296 -15
  6. package/dist/src/agent-profiles.js +45 -0
  7. package/dist/src/agentpod-authority.js +169 -11
  8. package/dist/src/agentpod-default-config.js +12 -0
  9. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  10. package/dist/src/agentpod-work-decisions.js +108 -4
  11. package/dist/src/agentpod-work.js +1214 -127
  12. package/dist/src/approval-rules.js +287 -0
  13. package/dist/src/attach.js +3 -1
  14. package/dist/src/autonomy.js +80 -9
  15. package/dist/src/board-reads.js +106 -0
  16. package/dist/src/children.js +83 -0
  17. package/dist/src/cli-help.js +61 -6
  18. package/dist/src/cli.js +140 -4
  19. package/dist/src/command-danger.js +797 -0
  20. package/dist/src/command-network.js +200 -0
  21. package/dist/src/command-sandbox.js +327 -0
  22. package/dist/src/custom-commands.js +159 -0
  23. package/dist/src/danger-classifier.js +286 -0
  24. package/dist/src/execution-evidence.js +33 -1
  25. package/dist/src/git-helpers.js +160 -0
  26. package/dist/src/hooks.js +351 -0
  27. package/dist/src/host-bind.js +111 -0
  28. package/dist/src/host-breaker.js +88 -0
  29. package/dist/src/host-start.js +41 -1
  30. package/dist/src/host.js +284 -6
  31. package/dist/src/house-extension.js +292 -61
  32. package/dist/src/local-coding.js +726 -35
  33. package/dist/src/local-edit-helper.js +25 -0
  34. package/dist/src/local-edits.js +168 -6
  35. package/dist/src/local-undo.js +25 -16
  36. package/dist/src/managed.js +1 -1
  37. package/dist/src/mentions.js +277 -0
  38. package/dist/src/operator-mcp-cli.js +107 -0
  39. package/dist/src/operator-mcp.js +342 -0
  40. package/dist/src/persona.js +31 -0
  41. package/dist/src/pi-sessions.js +5 -1
  42. package/dist/src/pi.js +22 -1
  43. package/dist/src/project-instructions.js +195 -0
  44. package/dist/src/protocol.js +9 -3
  45. package/dist/src/rate-limit-retries.js +62 -0
  46. package/dist/src/recurring-work.js +1 -0
  47. package/dist/src/repository-snapshots.js +5 -2
  48. package/dist/src/run-grants.js +62 -0
  49. package/dist/src/service.js +482 -10
  50. package/dist/src/session-pick.js +77 -0
  51. package/dist/src/shell-passthrough.js +39 -0
  52. package/dist/src/station-checkpoints.js +139 -0
  53. package/dist/src/station-config.js +8 -1
  54. package/dist/src/station-files.js +20 -1
  55. package/dist/src/station-task-verification.js +6 -3
  56. package/dist/src/station-work.js +315 -22
  57. package/dist/src/store.js +70 -4
  58. package/dist/src/supervisor.js +408 -25
  59. package/dist/src/task-budget.js +39 -3
  60. package/dist/src/task-context.js +8 -2
  61. package/dist/src/task-critic.js +115 -0
  62. package/dist/src/task-driver.js +46 -2
  63. package/dist/src/task-policy.js +44 -12
  64. package/dist/src/task-verification.js +42 -9
  65. package/dist/src/task-work.js +47 -0
  66. package/dist/src/terminal-status.js +88 -0
  67. package/dist/src/tool-identity.js +69 -0
  68. package/dist/src/tui-activity.js +99 -0
  69. package/dist/src/tui-classifier.js +25 -0
  70. package/dist/src/tui-client.js +186 -5
  71. package/dist/src/tui-commands.js +23 -5
  72. package/dist/src/tui-composer.js +113 -0
  73. package/dist/src/tui-keymap.js +116 -0
  74. package/dist/src/tui-mentions.js +38 -0
  75. package/dist/src/tui-modals.js +157 -34
  76. package/dist/src/tui-notify.js +62 -0
  77. package/dist/src/tui-review.js +55 -0
  78. package/dist/src/tui-turn-keys.js +52 -0
  79. package/dist/src/tui-view.js +243 -69
  80. package/dist/src/tui.js +875 -118
  81. package/dist/src/turn-checkpoints.js +221 -0
  82. package/dist/src/turn-rewind.js +168 -0
  83. package/dist/src/usage.js +1 -1
  84. package/dist/src/web-fetch.js +114 -0
  85. package/dist/src/web-guard.js +163 -0
  86. package/dist/src/web-search.js +302 -0
  87. package/npm-shrinkwrap.json +2 -2
  88. package/package.json +1 -1
  89. package/src/acp-desk.ts +155 -0
  90. package/src/acp-outcome.ts +174 -20
  91. package/src/acp.ts +386 -30
  92. package/src/agent-profiles.ts +48 -0
  93. package/src/agentpod-authority.ts +234 -15
  94. package/src/agentpod-default-config.ts +16 -0
  95. package/src/agentpod-work-decision-protocol.ts +69 -8
  96. package/src/agentpod-work-decisions.ts +123 -5
  97. package/src/agentpod-work.ts +1455 -152
  98. package/src/approval-rules.ts +360 -0
  99. package/src/attach.ts +4 -1
  100. package/src/autonomy.ts +111 -10
  101. package/src/board-reads.ts +148 -0
  102. package/src/children.ts +112 -0
  103. package/src/cli-help.ts +60 -6
  104. package/src/cli.ts +174 -4
  105. package/src/command-danger.ts +886 -0
  106. package/src/command-network.ts +215 -0
  107. package/src/command-sandbox.ts +393 -0
  108. package/src/custom-commands.ts +175 -0
  109. package/src/danger-classifier.ts +321 -0
  110. package/src/execution-evidence.ts +31 -1
  111. package/src/git-helpers.ts +177 -0
  112. package/src/hooks.ts +425 -0
  113. package/src/host-bind.ts +156 -0
  114. package/src/host-breaker.ts +89 -0
  115. package/src/host-start.ts +51 -1
  116. package/src/host.ts +365 -6
  117. package/src/house-extension.ts +313 -60
  118. package/src/local-coding.ts +894 -43
  119. package/src/local-edit-helper.ts +26 -0
  120. package/src/local-edits.ts +191 -8
  121. package/src/local-undo.ts +29 -14
  122. package/src/managed.ts +2 -2
  123. package/src/mentions.ts +304 -0
  124. package/src/operator-mcp-cli.ts +108 -0
  125. package/src/operator-mcp.ts +420 -0
  126. package/src/persona.ts +37 -0
  127. package/src/pi-sessions.ts +9 -1
  128. package/src/pi.ts +23 -1
  129. package/src/project-instructions.ts +229 -0
  130. package/src/protocol.ts +25 -3
  131. package/src/rate-limit-retries.ts +69 -0
  132. package/src/recurring-work.ts +1 -0
  133. package/src/repository-snapshots.ts +5 -2
  134. package/src/run-grants.ts +107 -0
  135. package/src/service.ts +709 -10
  136. package/src/session-pick.ts +102 -0
  137. package/src/shell-passthrough.ts +38 -0
  138. package/src/station-checkpoints.ts +183 -0
  139. package/src/station-config.ts +10 -1
  140. package/src/station-files.ts +20 -1
  141. package/src/station-task-verification.ts +6 -3
  142. package/src/station-work.ts +369 -20
  143. package/src/store.ts +70 -3
  144. package/src/supervisor.ts +463 -26
  145. package/src/task-budget.ts +48 -3
  146. package/src/task-context.ts +9 -2
  147. package/src/task-critic.ts +171 -0
  148. package/src/task-driver.ts +49 -2
  149. package/src/task-policy.ts +65 -13
  150. package/src/task-verification.ts +53 -9
  151. package/src/task-work.ts +75 -0
  152. package/src/terminal-status.ts +103 -0
  153. package/src/tool-identity.ts +110 -0
  154. package/src/tui-activity.ts +133 -0
  155. package/src/tui-classifier.ts +38 -0
  156. package/src/tui-client.ts +239 -8
  157. package/src/tui-commands.ts +49 -10
  158. package/src/tui-composer.ts +124 -0
  159. package/src/tui-keymap.ts +152 -0
  160. package/src/tui-mentions.ts +43 -0
  161. package/src/tui-modals.ts +173 -43
  162. package/src/tui-notify.ts +77 -0
  163. package/src/tui-review.ts +76 -0
  164. package/src/tui-turn-keys.ts +77 -0
  165. package/src/tui-view.ts +341 -88
  166. package/src/tui.ts +1047 -137
  167. package/src/turn-checkpoints.ts +253 -0
  168. package/src/turn-rewind.ts +258 -0
  169. package/src/usage.ts +1 -1
  170. package/src/web-fetch.ts +140 -0
  171. package/src/web-guard.ts +195 -0
  172. package/src/web-search.ts +402 -0
@@ -0,0 +1,351 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, relative, sep } from 'node:path';
5
+ import { ServiceError } from './protocol.js';
6
+ /** Hooks for a person's own desk sessions (Phase 4b, operator 2026-10-31), in the Claude Code
7
+ * format: `{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command",
8
+ * "command": "...", "timeout": 30}]}]}}`. Yours live in `~/.config/salaam/hooks.json`; a
9
+ * repository's `.salaam/hooks.json` is ignored until you trust that exact file, and any change to it
10
+ * needs trusting again. A hook can block a tool call or a prompt; it can never approve anything. */
11
+ export const HOOK_EVENTS = [
12
+ 'PreToolUse',
13
+ 'PostToolUse',
14
+ 'UserPromptSubmit',
15
+ 'Stop',
16
+ 'Notification',
17
+ ];
18
+ const MAX_FILE = 64 * 1024;
19
+ const MAX_COMMAND = 4096;
20
+ const MAX_HANDLERS = 16;
21
+ const MAX_CONTEXT = 8192;
22
+ const DEFAULT_TIMEOUT = 60;
23
+ const MAX_TIMEOUT = 600;
24
+ const BLOCKING = new Set(['PreToolUse', 'UserPromptSubmit']);
25
+ const TOOL_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
26
+ export function defaultUserHooksFile(env = process.env) {
27
+ return (env.SALAAM_HOOKS_FILE ||
28
+ join(env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config'), 'salaam', 'hooks.json'));
29
+ }
30
+ const sha = (data) => 'sha256:' + createHash('sha256').update(data).digest('hex');
31
+ export function parseHooks(text) {
32
+ const problems = [];
33
+ const config = {};
34
+ let raw;
35
+ try {
36
+ raw = JSON.parse(text);
37
+ }
38
+ catch (error) {
39
+ return { config, problems: ['not valid JSON: ' + error.message] };
40
+ }
41
+ const events = raw?.hooks;
42
+ if (!events || typeof events !== 'object' || Array.isArray(events))
43
+ return { config, problems: ['expected {"hooks": {...}}'] };
44
+ let handlers = 0;
45
+ for (const [event, groups] of Object.entries(events)) {
46
+ if (!HOOK_EVENTS.includes(event)) {
47
+ problems.push(`${event}: not supported; Salaam runs ${HOOK_EVENTS.join(', ')}`);
48
+ continue;
49
+ }
50
+ if (!Array.isArray(groups)) {
51
+ problems.push(`${event}: expected a list of matcher groups`);
52
+ continue;
53
+ }
54
+ for (const group of groups) {
55
+ const g = group;
56
+ if (g?.matcher !== undefined && typeof g.matcher !== 'string') {
57
+ problems.push(`${event}: matcher must be a string`);
58
+ continue;
59
+ }
60
+ if (typeof g.matcher === 'string' && !validMatcher(g.matcher)) {
61
+ problems.push(`${event}: matcher ${JSON.stringify(g.matcher)} is not a valid pattern`);
62
+ continue;
63
+ }
64
+ if (!Array.isArray(g.hooks)) {
65
+ problems.push(`${event}: a group needs a hooks list`);
66
+ continue;
67
+ }
68
+ const kept = [];
69
+ for (const h of g.hooks) {
70
+ const hook = h;
71
+ if (hook?.type !== 'command') {
72
+ problems.push(`${event}: hook type ${JSON.stringify(hook?.type)} is not supported (command only)`);
73
+ continue;
74
+ }
75
+ if (typeof hook.command !== 'string' || !hook.command.trim()) {
76
+ problems.push(`${event}: a command hook needs a command`);
77
+ continue;
78
+ }
79
+ if (Buffer.byteLength(hook.command) > MAX_COMMAND) {
80
+ problems.push(`${event}: a hook command is longer than 4 KiB`);
81
+ continue;
82
+ }
83
+ if (hook.timeout !== undefined &&
84
+ (typeof hook.timeout !== 'number' || !(hook.timeout > 0) || hook.timeout > MAX_TIMEOUT)) {
85
+ problems.push(`${event}: timeout must be 1–${MAX_TIMEOUT} seconds`);
86
+ continue;
87
+ }
88
+ if (++handlers > MAX_HANDLERS * HOOK_EVENTS.length) {
89
+ problems.push('too many hooks; the rest are ignored');
90
+ break;
91
+ }
92
+ kept.push({
93
+ command: hook.command,
94
+ ...(hook.timeout !== undefined ? { timeout: hook.timeout } : {}),
95
+ });
96
+ }
97
+ if (kept.length)
98
+ (config[event] ??= []).push({
99
+ ...(g.matcher !== undefined ? { matcher: g.matcher } : {}),
100
+ hooks: kept.slice(0, MAX_HANDLERS),
101
+ });
102
+ }
103
+ }
104
+ return { config, problems };
105
+ }
106
+ function validMatcher(m) {
107
+ if (m === '' || m === '*' || /^[A-Za-z0-9_|,-]+$/.test(m))
108
+ return true;
109
+ try {
110
+ new RegExp(m);
111
+ return true;
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ /** Claude Code's rule: empty or `*` matches everything, plain names (letters, digits, `_`, `-`,
118
+ * separated by `|` or `,`) are exact, anything else is a regular expression. `names` holds the
119
+ * Salaam tool name and its Claude Code alias (`Bash`, `Edit`, …). */
120
+ export function matchesTool(matcher, names) {
121
+ if (matcher === undefined || matcher === '' || matcher === '*')
122
+ return true;
123
+ if (/^[A-Za-z0-9_|,-]+$/.test(matcher)) {
124
+ const wanted = matcher.split(/[|,]/);
125
+ return names.some((n) => wanted.includes(n));
126
+ }
127
+ const re = new RegExp('^(?:' + matcher + ')$');
128
+ return names.some((n) => re.test(n));
129
+ }
130
+ export class Hooks {
131
+ store;
132
+ run;
133
+ userFile;
134
+ constructor(store, run, userFile = defaultUserHooksFile()) {
135
+ this.store = store;
136
+ this.run = run;
137
+ this.userFile = userFile;
138
+ store.db.exec('CREATE TABLE IF NOT EXISTS hook_trust(root TEXT NOT NULL,path TEXT NOT NULL,digest TEXT NOT NULL,trusted_at TEXT NOT NULL,PRIMARY KEY(root,path))');
139
+ }
140
+ repoFile(session) {
141
+ return join(this.store.session(session).cwd, '.salaam', 'hooks.json');
142
+ }
143
+ /** Your hooks and this workspace's, read afresh, each with its trust state. */
144
+ sources(session) {
145
+ const root = this.store.session(session).cwd;
146
+ const out = [];
147
+ for (const [scope, path] of [
148
+ ['user', this.userFile],
149
+ ['repo', this.repoFile(session)],
150
+ ]) {
151
+ if (!existsSync(path) && !isLink(path))
152
+ continue;
153
+ const problems = [];
154
+ let digest = null;
155
+ let config = {};
156
+ try {
157
+ const real = realpathSync(path);
158
+ if (scope === 'repo' && !inside(realpathSync(root), real))
159
+ throw new Error('resolves outside the repository; ignored');
160
+ if (lstatSync(real).size > MAX_FILE)
161
+ throw new Error('larger than 64 KiB; ignored');
162
+ const bytes = readFileSync(real);
163
+ digest = sha(bytes);
164
+ const parsed = parseHooks(bytes.toString('utf8'));
165
+ config = parsed.config;
166
+ problems.push(...parsed.problems);
167
+ }
168
+ catch (error) {
169
+ problems.push(error.message);
170
+ digest = null;
171
+ }
172
+ const trusted = scope === 'user'
173
+ ? digest !== null
174
+ : digest !== null && this.trustedDigest(root, path) === digest;
175
+ out.push({ scope, path, digest, trusted, config, problems });
176
+ }
177
+ return out;
178
+ }
179
+ trustedDigest(root, path) {
180
+ const row = this.store.db
181
+ .prepare('SELECT digest FROM hook_trust WHERE root=? AND path=?')
182
+ .get(root, path);
183
+ return row?.digest;
184
+ }
185
+ /** Trust this workspace's hooks file as reviewed: `digest` must be the content shown to you. */
186
+ trust(session, digest) {
187
+ const source = this.sources(session).find((s) => s.scope === 'repo');
188
+ if (!source || !source.digest)
189
+ throw new ServiceError('hooks_missing', 'This workspace has no readable .salaam/hooks.json', 404);
190
+ if (source.digest !== digest)
191
+ throw new ServiceError('hooks_changed', 'The hooks file changed since you reviewed it; review it again', 409);
192
+ const root = this.store.session(session).cwd;
193
+ this.store.transaction(() => {
194
+ this.store.db
195
+ .prepare('INSERT INTO hook_trust VALUES (?,?,?,?) ON CONFLICT(root,path) DO UPDATE SET digest=excluded.digest,trusted_at=excluded.trusted_at')
196
+ .run(root, source.path, digest, new Date().toISOString());
197
+ this.store.event(session, 'hooks.trusted', { path: source.path, digest });
198
+ });
199
+ return { ...source, trusted: true };
200
+ }
201
+ revoke(session) {
202
+ const root = this.store.session(session).cwd;
203
+ const path = this.repoFile(session);
204
+ this.store.transaction(() => {
205
+ this.store.db.prepare('DELETE FROM hook_trust WHERE root=? AND path=?').run(root, path);
206
+ this.store.event(session, 'hooks.revoked', { path });
207
+ });
208
+ }
209
+ /** Run every trusted hook for `event`. Blocking events stop at the first block. */
210
+ async fire(session, event, payload, names) {
211
+ const notices = [];
212
+ const context = [];
213
+ const cwd = this.store.session(session).cwd;
214
+ const input = JSON.stringify({ session_id: session, cwd, hook_event_name: event, ...payload });
215
+ for (const source of this.sources(session)) {
216
+ if (!source.trusted)
217
+ continue;
218
+ for (const group of source.config[event] ?? []) {
219
+ if (TOOL_EVENTS.has(event) && !matchesTool(group.matcher, names))
220
+ continue;
221
+ for (const handler of group.hooks) {
222
+ const started = Date.now();
223
+ const r = await this.run(session, handler.command, input, (handler.timeout ?? DEFAULT_TIMEOUT) * 1000);
224
+ const decision = parseDecision(r.stdout);
225
+ let blocked;
226
+ if (BLOCKING.has(event) && !r.refused && !r.timedOut) {
227
+ if (r.code === 2)
228
+ blocked = r.stderr.trim() || `Blocked by a ${event} hook`;
229
+ else if (r.code === 0 && decision.block)
230
+ blocked = decision.reason || `Blocked by a ${event} hook`;
231
+ }
232
+ if (r.refused)
233
+ notices.push(`${event} hook refused: ${r.refused}`);
234
+ else if (r.timedOut)
235
+ notices.push(`${event} hook timed out: ${short(handler.command)}`);
236
+ else if (r.code !== 0 && r.code !== 2)
237
+ notices.push(`${event} hook failed (exit ${r.code}): ${short(handler.command)}`);
238
+ if (event === 'UserPromptSubmit' && r.code === 0 && !blocked) {
239
+ const extra = decision.context ?? (decision.json ? '' : r.stdout.trim());
240
+ if (extra)
241
+ context.push(extra);
242
+ }
243
+ this.store.transaction(() => this.store.event(session, 'hook.run', {
244
+ event,
245
+ scope: source.scope,
246
+ path: source.path,
247
+ file_digest: source.digest,
248
+ command_digest: sha(handler.command),
249
+ exit_code: r.code,
250
+ timed_out: r.timedOut,
251
+ refused: !!r.refused,
252
+ blocked: !!blocked,
253
+ ...(decision.ignored ? { ignored_decision: decision.ignored } : {}),
254
+ duration_ms: Date.now() - started,
255
+ stdout_digest: sha(r.stdout),
256
+ stderr_digest: sha(r.stderr),
257
+ output_bytes: Buffer.byteLength(r.stdout) + Buffer.byteLength(r.stderr),
258
+ truncated: r.truncated,
259
+ }));
260
+ if (blocked)
261
+ return { blocked, notices };
262
+ }
263
+ }
264
+ }
265
+ const joined = context.join('\n\n');
266
+ return {
267
+ notices,
268
+ ...(joined ? { context: truncate(joined, MAX_CONTEXT) } : {}),
269
+ };
270
+ }
271
+ }
272
+ /** A JSON object on stdout, read the Claude Code way. "allow" and "ask" are recorded as ignored:
273
+ * Salaam's own approval flow decides those. */
274
+ function parseDecision(stdout) {
275
+ const text = stdout.trim();
276
+ if (!text.startsWith('{'))
277
+ return { json: false };
278
+ let value;
279
+ try {
280
+ value = JSON.parse(text);
281
+ }
282
+ catch {
283
+ return { json: false };
284
+ }
285
+ if (!value || typeof value !== 'object' || Array.isArray(value))
286
+ return { json: false };
287
+ const specific = (value.hookSpecificOutput ?? {});
288
+ const decision = specific.permissionDecision;
289
+ const reason = typeof specific.permissionDecisionReason === 'string'
290
+ ? specific.permissionDecisionReason
291
+ : typeof value.reason === 'string'
292
+ ? value.reason
293
+ : undefined;
294
+ const context = typeof specific.additionalContext === 'string' ? specific.additionalContext : undefined;
295
+ if (decision === 'deny' || value.decision === 'block')
296
+ return { json: true, block: true, ...(reason ? { reason } : {}) };
297
+ return {
298
+ json: true,
299
+ ...(context ? { context } : {}),
300
+ ...(typeof decision === 'string' ? { ignored: decision } : {}),
301
+ ...(value.decision === 'approve' ? { ignored: 'approve' } : {}),
302
+ };
303
+ }
304
+ function inside(root, path) {
305
+ const r = relative(root, path);
306
+ return !r.startsWith('..') && !r.startsWith(sep);
307
+ }
308
+ function isLink(path) {
309
+ try {
310
+ return lstatSync(path).isSymbolicLink();
311
+ }
312
+ catch {
313
+ return false;
314
+ }
315
+ }
316
+ function short(command) {
317
+ const line = command.split('\n')[0];
318
+ return line.length > 60 ? line.slice(0, 57) + '…' : line;
319
+ }
320
+ function truncate(text, max) {
321
+ const buf = Buffer.from(text);
322
+ return buf.length <= max
323
+ ? text
324
+ : buf.subarray(0, max).toString('utf8') + '\n[hook output truncated]';
325
+ }
326
+ /** What `/hooks` shows, and what you review before trusting a workspace file: every command. */
327
+ export function describeHooks(sources) {
328
+ if (!sources.length)
329
+ return "No hooks. Add your own in ~/.config/salaam/hooks.json, or a workspace's in .salaam/hooks.json (it runs only once you trust it with /hooks trust).";
330
+ return sources
331
+ .map((s) => {
332
+ const state = s.scope === 'user'
333
+ ? 'yours, runs'
334
+ : s.trusted
335
+ ? 'workspace, trusted (this exact content)'
336
+ : s.digest
337
+ ? 'workspace, NOT trusted, does not run (/hooks trust to review)'
338
+ : 'workspace, unreadable';
339
+ const lines = [`${s.path} [${state}]`];
340
+ for (const event of HOOK_EVENTS)
341
+ for (const g of s.config[event] ?? [])
342
+ for (const h of g.hooks)
343
+ lines.push(` ${event}${g.matcher ? ` (${g.matcher})` : ''}: ${h.command}${h.timeout ? ` [${h.timeout}s]` : ''}`);
344
+ for (const p of s.problems)
345
+ lines.push(` ! ${p}`);
346
+ if (s.digest)
347
+ lines.push(` ${s.digest}`);
348
+ return lines.join('\n');
349
+ })
350
+ .join('\n\n');
351
+ }
@@ -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
+ }