@superjackfruitlabs/salaam 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +12 -11
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +182 -18
- package/dist/src/acp.js +296 -15
- package/dist/src/agent-profiles.js +45 -0
- package/dist/src/agentpod-authority.js +169 -11
- package/dist/src/agentpod-default-config.js +12 -0
- package/dist/src/agentpod-work-decision-protocol.js +40 -6
- package/dist/src/agentpod-work-decisions.js +108 -4
- package/dist/src/agentpod-work.js +1214 -127
- package/dist/src/approval-rules.js +287 -0
- package/dist/src/attach.js +3 -1
- package/dist/src/autonomy.js +80 -9
- package/dist/src/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +61 -6
- package/dist/src/cli.js +140 -4
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +327 -0
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host-bind.js +111 -0
- package/dist/src/host-breaker.js +88 -0
- package/dist/src/host-start.js +41 -1
- package/dist/src/host.js +284 -6
- package/dist/src/house-extension.js +292 -61
- package/dist/src/local-coding.js +726 -35
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-edits.js +168 -6
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/mentions.js +277 -0
- package/dist/src/operator-mcp-cli.js +107 -0
- package/dist/src/operator-mcp.js +342 -0
- package/dist/src/persona.js +31 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/pi.js +22 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +9 -3
- package/dist/src/rate-limit-retries.js +62 -0
- package/dist/src/recurring-work.js +1 -0
- package/dist/src/repository-snapshots.js +5 -2
- package/dist/src/run-grants.js +62 -0
- package/dist/src/service.js +482 -10
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/station-checkpoints.js +139 -0
- package/dist/src/station-config.js +8 -1
- package/dist/src/station-files.js +20 -1
- package/dist/src/station-task-verification.js +6 -3
- package/dist/src/station-work.js +315 -22
- package/dist/src/store.js +70 -4
- package/dist/src/supervisor.js +408 -25
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-context.js +8 -2
- package/dist/src/task-critic.js +115 -0
- package/dist/src/task-driver.js +46 -2
- package/dist/src/task-policy.js +44 -12
- package/dist/src/task-verification.js +42 -9
- package/dist/src/task-work.js +47 -0
- package/dist/src/terminal-status.js +88 -0
- package/dist/src/tool-identity.js +69 -0
- package/dist/src/tui-activity.js +99 -0
- package/dist/src/tui-classifier.js +25 -0
- package/dist/src/tui-client.js +186 -5
- package/dist/src/tui-commands.js +23 -5
- package/dist/src/tui-composer.js +113 -0
- package/dist/src/tui-keymap.js +116 -0
- package/dist/src/tui-mentions.js +38 -0
- package/dist/src/tui-modals.js +157 -34
- package/dist/src/tui-notify.js +62 -0
- package/dist/src/tui-review.js +55 -0
- package/dist/src/tui-turn-keys.js +52 -0
- package/dist/src/tui-view.js +243 -69
- package/dist/src/tui.js +875 -118
- package/dist/src/turn-checkpoints.js +221 -0
- package/dist/src/turn-rewind.js +168 -0
- package/dist/src/usage.js +1 -1
- package/dist/src/web-fetch.js +114 -0
- package/dist/src/web-guard.js +163 -0
- package/dist/src/web-search.js +302 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-desk.ts +155 -0
- package/src/acp-outcome.ts +174 -20
- package/src/acp.ts +386 -30
- package/src/agent-profiles.ts +48 -0
- package/src/agentpod-authority.ts +234 -15
- package/src/agentpod-default-config.ts +16 -0
- package/src/agentpod-work-decision-protocol.ts +69 -8
- package/src/agentpod-work-decisions.ts +123 -5
- package/src/agentpod-work.ts +1455 -152
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +60 -6
- package/src/cli.ts +174 -4
- package/src/command-danger.ts +886 -0
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +393 -0
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/execution-evidence.ts +31 -1
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host-bind.ts +156 -0
- package/src/host-breaker.ts +89 -0
- package/src/host-start.ts +51 -1
- package/src/host.ts +365 -6
- package/src/house-extension.ts +313 -60
- package/src/local-coding.ts +894 -43
- package/src/local-edit-helper.ts +26 -0
- package/src/local-edits.ts +191 -8
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/mentions.ts +304 -0
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi-sessions.ts +9 -1
- package/src/pi.ts +23 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +25 -3
- package/src/rate-limit-retries.ts +69 -0
- package/src/recurring-work.ts +1 -0
- package/src/repository-snapshots.ts +5 -2
- package/src/run-grants.ts +107 -0
- package/src/service.ts +709 -10
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/station-checkpoints.ts +183 -0
- package/src/station-config.ts +10 -1
- package/src/station-files.ts +20 -1
- package/src/station-task-verification.ts +6 -3
- package/src/station-work.ts +369 -20
- package/src/store.ts +70 -3
- package/src/supervisor.ts +463 -26
- package/src/task-budget.ts +48 -3
- package/src/task-context.ts +9 -2
- package/src/task-critic.ts +171 -0
- package/src/task-driver.ts +49 -2
- package/src/task-policy.ts +65 -13
- package/src/task-verification.ts +53 -9
- package/src/task-work.ts +75 -0
- package/src/terminal-status.ts +103 -0
- package/src/tool-identity.ts +110 -0
- package/src/tui-activity.ts +133 -0
- package/src/tui-classifier.ts +38 -0
- package/src/tui-client.ts +239 -8
- package/src/tui-commands.ts +49 -10
- package/src/tui-composer.ts +124 -0
- package/src/tui-keymap.ts +152 -0
- package/src/tui-mentions.ts +43 -0
- package/src/tui-modals.ts +173 -43
- package/src/tui-notify.ts +77 -0
- package/src/tui-review.ts +76 -0
- package/src/tui-turn-keys.ts +77 -0
- package/src/tui-view.ts +341 -88
- package/src/tui.ts +1047 -137
- package/src/turn-checkpoints.ts +253 -0
- package/src/turn-rewind.ts +258 -0
- package/src/usage.ts +1 -1
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
declarationDigest,
|
|
3
|
+
operatorMcpPath,
|
|
4
|
+
readOperatorMcpFile,
|
|
5
|
+
validateDeclaration,
|
|
6
|
+
writeOperatorMcpFile,
|
|
7
|
+
type McpDeclaration,
|
|
8
|
+
} from './operator-mcp.js';
|
|
9
|
+
|
|
10
|
+
export const MCP_HELP = `salaam mcp: operator-declared MCP servers
|
|
11
|
+
|
|
12
|
+
salaam mcp add <name> [--env K='\${VAR}']... -- <command> [args...]
|
|
13
|
+
salaam mcp add <name> --url <https-url> [--header K='Bearer \${VAR}']...
|
|
14
|
+
salaam mcp list
|
|
15
|
+
salaam mcp rm <name>
|
|
16
|
+
|
|
17
|
+
Options: --config <path> (default $XDG_CONFIG_HOME/salaam/mcp.json)
|
|
18
|
+
Values in --env and --header must reference environment variables as \${VAR};
|
|
19
|
+
inline secrets are refused. Changes apply at the next salaam service start.`;
|
|
20
|
+
|
|
21
|
+
function pairs(values: string[], flag: string): Record<string, string> | undefined {
|
|
22
|
+
if (!values.length) return undefined;
|
|
23
|
+
const out: Record<string, string> = {};
|
|
24
|
+
for (const value of values) {
|
|
25
|
+
const i = value.indexOf('=');
|
|
26
|
+
if (i < 1) throw new Error(`${flag} expects K=V`);
|
|
27
|
+
const key = value.slice(0, i);
|
|
28
|
+
if (Object.hasOwn(out, key)) throw new Error(`${flag} ${key} given twice`);
|
|
29
|
+
out[key] = value.slice(i + 1);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `salaam mcp ...`; argv excludes the leading "mcp". Returns text for stdout. */
|
|
35
|
+
export function mcpCli(argv: string[], env: NodeJS.ProcessEnv = process.env): string {
|
|
36
|
+
const dash = argv.indexOf('--');
|
|
37
|
+
const head = dash < 0 ? [...argv] : argv.slice(0, dash);
|
|
38
|
+
const command = dash < 0 ? [] : argv.slice(dash + 1);
|
|
39
|
+
const take = (flag: string, many = false): string[] => {
|
|
40
|
+
const found: string[] = [];
|
|
41
|
+
for (let i = head.indexOf(flag); i >= 0; i = head.indexOf(flag)) {
|
|
42
|
+
const value = head[i + 1];
|
|
43
|
+
if (value === undefined) throw new Error(`Missing value for ${flag}`);
|
|
44
|
+
found.push(value);
|
|
45
|
+
head.splice(i, 2);
|
|
46
|
+
if (!many) break;
|
|
47
|
+
}
|
|
48
|
+
return found;
|
|
49
|
+
};
|
|
50
|
+
const path = take('--config')[0] ?? operatorMcpPath(env);
|
|
51
|
+
const url = take('--url')[0];
|
|
52
|
+
const headers = pairs(take('--header', true), '--header');
|
|
53
|
+
const envs = pairs(take('--env', true), '--env');
|
|
54
|
+
const [verb, name, ...rest] = head;
|
|
55
|
+
if (rest.length || head.some((x) => x.startsWith('--')))
|
|
56
|
+
throw new Error('Unexpected arguments; run salaam mcp --help');
|
|
57
|
+
const file = readOperatorMcpFile(path);
|
|
58
|
+
if (verb === 'list') {
|
|
59
|
+
if (dash >= 0 || name) throw new Error('salaam mcp list takes no arguments');
|
|
60
|
+
return JSON.stringify(
|
|
61
|
+
{
|
|
62
|
+
config: path,
|
|
63
|
+
servers: file.servers.map((s) => ({ ...s, declaration_digest: declarationDigest(s) })),
|
|
64
|
+
},
|
|
65
|
+
null,
|
|
66
|
+
2,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (!name) throw new Error('A server name is required');
|
|
70
|
+
if (verb === 'rm') {
|
|
71
|
+
if (dash >= 0 || url || headers || envs) throw new Error('salaam mcp rm takes only a name');
|
|
72
|
+
const servers = file.servers.filter((s) => s.name !== name);
|
|
73
|
+
if (servers.length === file.servers.length) throw new Error(`No declared server "${name}"`);
|
|
74
|
+
writeOperatorMcpFile(path, { version: 1, servers });
|
|
75
|
+
return JSON.stringify({ removed: name, config: path });
|
|
76
|
+
}
|
|
77
|
+
if (verb === 'add') {
|
|
78
|
+
if (file.servers.some((s) => s.name === name))
|
|
79
|
+
throw new Error(`Server "${name}" is already declared; rm it first`);
|
|
80
|
+
let declaration: McpDeclaration;
|
|
81
|
+
if (url) {
|
|
82
|
+
if (command.length || envs) throw new Error('Use either --url or -- <command>, not both');
|
|
83
|
+
declaration = validateDeclaration({
|
|
84
|
+
name,
|
|
85
|
+
transport: 'http',
|
|
86
|
+
url,
|
|
87
|
+
...(headers ? { headers } : {}),
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
if (!command.length) throw new Error('Supply -- <command> [args...] or --url <url>');
|
|
91
|
+
if (headers) throw new Error('--header applies to --url servers');
|
|
92
|
+
declaration = validateDeclaration({
|
|
93
|
+
name,
|
|
94
|
+
transport: 'stdio',
|
|
95
|
+
command: command[0],
|
|
96
|
+
args: command.slice(1),
|
|
97
|
+
...(envs ? { env: envs } : {}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
writeOperatorMcpFile(path, { version: 1, servers: [...file.servers, declaration] });
|
|
101
|
+
return JSON.stringify({
|
|
102
|
+
added: name,
|
|
103
|
+
config: path,
|
|
104
|
+
declaration_digest: declarationDigest(declaration),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
throw new Error('Use salaam mcp add, list or rm');
|
|
108
|
+
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { delimiter, dirname, isAbsolute, join } from 'node:path';
|
|
4
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
5
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
6
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
7
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
8
|
+
import { digest, ServiceError } from './protocol.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Operator-declared MCP servers: the operator's own grant, read only from Salaam's
|
|
12
|
+
* configuration directory. Workspaces never declare servers; Pi keeps --no-mcp and every
|
|
13
|
+
* declared server is reached through the daemon over the worker bridge.
|
|
14
|
+
*/
|
|
15
|
+
export interface StdioDeclaration {
|
|
16
|
+
name: string;
|
|
17
|
+
transport: 'stdio';
|
|
18
|
+
command: string;
|
|
19
|
+
args?: string[];
|
|
20
|
+
cwd?: string;
|
|
21
|
+
env?: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
export interface HttpDeclaration {
|
|
24
|
+
name: string;
|
|
25
|
+
transport: 'http';
|
|
26
|
+
url: string;
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
export type McpDeclaration = StdioDeclaration | HttpDeclaration;
|
|
30
|
+
export interface OperatorMcpFile {
|
|
31
|
+
version: 1;
|
|
32
|
+
servers: McpDeclaration[];
|
|
33
|
+
}
|
|
34
|
+
/** Public provenance for one server a session got. Never carries resolved secrets. */
|
|
35
|
+
export interface McpServerProvenance {
|
|
36
|
+
name: string;
|
|
37
|
+
source: 'operator' | 'agentpod';
|
|
38
|
+
transport: 'stdio' | 'http';
|
|
39
|
+
command?: string;
|
|
40
|
+
args?: string[];
|
|
41
|
+
url?: string;
|
|
42
|
+
endpoint_digest?: string;
|
|
43
|
+
declaration_digest: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const NAME = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
47
|
+
const REF = /\$\{([A-Za-z_][A-Za-z0-9_]{0,127})\}/g;
|
|
48
|
+
// Literal text around references may only be a short scheme word such as "Bearer ".
|
|
49
|
+
const LITERAL = /^(?:[A-Za-z][A-Za-z-]{0,15} ?)?$/;
|
|
50
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
51
|
+
const HEADER = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,128}$/;
|
|
52
|
+
const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
53
|
+
|
|
54
|
+
export function operatorMcpPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
55
|
+
const base =
|
|
56
|
+
env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME)
|
|
57
|
+
? env.XDG_CONFIG_HOME
|
|
58
|
+
: join(env.HOME || homedir(), '.config');
|
|
59
|
+
return join(base, 'salaam', 'mcp.json');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function fail(message: string): never {
|
|
63
|
+
throw new ServiceError('invalid_mcp_config', message, 400);
|
|
64
|
+
}
|
|
65
|
+
function keys(value: Record<string, unknown>, allowed: string[], where: string) {
|
|
66
|
+
for (const key of Object.keys(value))
|
|
67
|
+
if (!allowed.includes(key)) fail(`${where}: unknown key "${key}"`);
|
|
68
|
+
}
|
|
69
|
+
function record(value: unknown, where: string): Record<string, unknown> {
|
|
70
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
71
|
+
fail(`${where} must be an object`);
|
|
72
|
+
return value as Record<string, unknown>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A value must be built from ${VAR} references; inline secrets are refused. */
|
|
76
|
+
export function assertReference(value: unknown, where: string): string[] {
|
|
77
|
+
if (typeof value !== 'string' || value.length > 1024) fail(`${where} must be a string`);
|
|
78
|
+
const names = [...value.matchAll(REF)].map((m) => m[1]!);
|
|
79
|
+
const literal = value.replace(REF, '');
|
|
80
|
+
if (!names.length || !LITERAL.test(literal))
|
|
81
|
+
fail(`${where} must reference an environment variable as \${VAR}; inline secrets are refused`);
|
|
82
|
+
return names;
|
|
83
|
+
}
|
|
84
|
+
function referenceMap(value: unknown, where: string, key: RegExp): Record<string, string> {
|
|
85
|
+
const map = record(value, where);
|
|
86
|
+
for (const [k, v] of Object.entries(map)) {
|
|
87
|
+
if (!key.test(k)) fail(`${where}: invalid name "${k}"`);
|
|
88
|
+
assertReference(v, `${where}.${k}`);
|
|
89
|
+
}
|
|
90
|
+
return map as Record<string, string>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Shape validation shared by the CLI and the service. Does not touch PATH or env. */
|
|
94
|
+
export function validateDeclaration(input: unknown, index = 0): McpDeclaration {
|
|
95
|
+
const where = `servers[${index}]`;
|
|
96
|
+
const s = record(input, where);
|
|
97
|
+
if (typeof s.name !== 'string' || !NAME.test(s.name))
|
|
98
|
+
fail(`${where}.name must match ${NAME.source}`);
|
|
99
|
+
const at = `server "${s.name}"`;
|
|
100
|
+
if (s.transport === 'stdio') {
|
|
101
|
+
keys(s, ['name', 'transport', 'command', 'args', 'cwd', 'env'], at);
|
|
102
|
+
if (typeof s.command !== 'string' || !s.command || s.command.length > 4096)
|
|
103
|
+
fail(`${at}: command is required`);
|
|
104
|
+
if (!isAbsolute(s.command) && s.command.includes('/'))
|
|
105
|
+
fail(`${at}: command must be absolute or a bare name on PATH`);
|
|
106
|
+
if (
|
|
107
|
+
s.args !== undefined &&
|
|
108
|
+
(!Array.isArray(s.args) ||
|
|
109
|
+
s.args.length > 256 ||
|
|
110
|
+
s.args.some((a) => typeof a !== 'string' || a.length > 4096))
|
|
111
|
+
)
|
|
112
|
+
fail(`${at}: args must be a list of strings`);
|
|
113
|
+
if (s.cwd !== undefined && (typeof s.cwd !== 'string' || !isAbsolute(s.cwd)))
|
|
114
|
+
fail(`${at}: cwd must be absolute`);
|
|
115
|
+
if (s.env !== undefined) referenceMap(s.env, `${at}: env`, ENV_KEY);
|
|
116
|
+
return {
|
|
117
|
+
name: s.name,
|
|
118
|
+
transport: 'stdio',
|
|
119
|
+
command: s.command,
|
|
120
|
+
...(s.args ? { args: [...(s.args as string[])] } : {}),
|
|
121
|
+
...(s.cwd ? { cwd: s.cwd as string } : {}),
|
|
122
|
+
...(s.env ? { env: { ...(s.env as Record<string, string>) } } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (s.transport === 'http') {
|
|
126
|
+
keys(s, ['name', 'transport', 'url', 'headers'], at);
|
|
127
|
+
if (typeof s.url !== 'string' || !URL.canParse(s.url)) fail(`${at}: url is invalid`);
|
|
128
|
+
const url = new URL(s.url);
|
|
129
|
+
if (url.username || url.password) fail(`${at}: url must not carry credentials`);
|
|
130
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOOPBACK.has(url.hostname)))
|
|
131
|
+
fail(`${at}: url must be https (http only on loopback)`);
|
|
132
|
+
if (s.headers !== undefined) referenceMap(s.headers, `${at}: headers`, HEADER);
|
|
133
|
+
return {
|
|
134
|
+
name: s.name,
|
|
135
|
+
transport: 'http',
|
|
136
|
+
url: s.url,
|
|
137
|
+
...(s.headers ? { headers: { ...(s.headers as Record<string, string>) } } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
fail(`${at}: transport must be "stdio" or "http"`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function validateOperatorMcp(input: unknown): OperatorMcpFile {
|
|
144
|
+
const file = record(input, 'mcp.json');
|
|
145
|
+
keys(file, ['version', 'servers'], 'mcp.json');
|
|
146
|
+
if (file.version !== 1) fail('mcp.json: version must be 1');
|
|
147
|
+
if (!Array.isArray(file.servers) || file.servers.length > 64)
|
|
148
|
+
fail('mcp.json: servers must be a list of at most 64');
|
|
149
|
+
const servers = file.servers.map((s, i) => validateDeclaration(s, i));
|
|
150
|
+
const seen = new Set<string>();
|
|
151
|
+
for (const s of servers) {
|
|
152
|
+
if (seen.has(s.name)) fail(`mcp.json: duplicate server name "${s.name}"`);
|
|
153
|
+
seen.add(s.name);
|
|
154
|
+
}
|
|
155
|
+
return { version: 1, servers };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function readOperatorMcpFile(path: string): OperatorMcpFile {
|
|
159
|
+
if (!existsSync(path)) return { version: 1, servers: [] };
|
|
160
|
+
let parsed: unknown;
|
|
161
|
+
try {
|
|
162
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
163
|
+
} catch {
|
|
164
|
+
fail(`${path} is not valid JSON`);
|
|
165
|
+
}
|
|
166
|
+
return validateOperatorMcp(parsed);
|
|
167
|
+
}
|
|
168
|
+
export function writeOperatorMcpFile(path: string, file: OperatorMcpFile): void {
|
|
169
|
+
const valid = validateOperatorMcp(file);
|
|
170
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
171
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
172
|
+
writeFileSync(temp, JSON.stringify(valid, null, 2) + '\n', { mode: 0o600 });
|
|
173
|
+
renameSync(temp, path);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const declarationDigest = (d: McpDeclaration): string => digest(d);
|
|
177
|
+
|
|
178
|
+
export function resolveCommand(command: string, env: NodeJS.ProcessEnv): string {
|
|
179
|
+
const executable = (path: string) => {
|
|
180
|
+
try {
|
|
181
|
+
return statSync(path).isFile() && (statSync(path).mode & 0o111) !== 0;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
if (isAbsolute(command)) {
|
|
187
|
+
if (!executable(command)) fail(`command ${command} is not an executable file`);
|
|
188
|
+
return command;
|
|
189
|
+
}
|
|
190
|
+
for (const dir of (env.PATH ?? '').split(delimiter))
|
|
191
|
+
if (dir && isAbsolute(dir) && executable(join(dir, command))) return join(dir, command);
|
|
192
|
+
fail(`command "${command}" is not on PATH`);
|
|
193
|
+
}
|
|
194
|
+
function resolveReferences(
|
|
195
|
+
map: Record<string, string> | undefined,
|
|
196
|
+
env: NodeJS.ProcessEnv,
|
|
197
|
+
where: string,
|
|
198
|
+
): Record<string, string> | undefined {
|
|
199
|
+
if (!map) return undefined;
|
|
200
|
+
return Object.fromEntries(
|
|
201
|
+
Object.entries(map).map(([k, v]) => [
|
|
202
|
+
k,
|
|
203
|
+
v.replace(REF, (_m, name: string) => {
|
|
204
|
+
const value = env[name];
|
|
205
|
+
if (value === undefined) fail(`${where}.${k} references unset variable ${name}`);
|
|
206
|
+
return value;
|
|
207
|
+
}),
|
|
208
|
+
]),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface Resolved {
|
|
213
|
+
declaration: McpDeclaration;
|
|
214
|
+
provenance: McpServerProvenance;
|
|
215
|
+
command?: string;
|
|
216
|
+
env?: Record<string, string>;
|
|
217
|
+
headers?: Record<string, string>;
|
|
218
|
+
}
|
|
219
|
+
interface Connection {
|
|
220
|
+
client: Client;
|
|
221
|
+
transport: Transport;
|
|
222
|
+
ready: Promise<void>;
|
|
223
|
+
}
|
|
224
|
+
const CALL_TIMEOUT_MS = 120_000;
|
|
225
|
+
const RESULT_LIMIT = 262_144;
|
|
226
|
+
|
|
227
|
+
export class OperatorMcp {
|
|
228
|
+
readonly servers: Resolved[];
|
|
229
|
+
readonly digest: string;
|
|
230
|
+
private readonly sessions = new Map<string, Map<string, Connection>>();
|
|
231
|
+
private readonly secrets: string[];
|
|
232
|
+
|
|
233
|
+
/** Resolves commands and references now, so a bad declaration refuses service start. */
|
|
234
|
+
constructor(
|
|
235
|
+
readonly file: OperatorMcpFile,
|
|
236
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
237
|
+
) {
|
|
238
|
+
const valid = validateOperatorMcp(file);
|
|
239
|
+
this.servers = valid.servers.map((declaration) => {
|
|
240
|
+
const declaration_digest = declarationDigest(declaration);
|
|
241
|
+
if (declaration.transport === 'stdio') {
|
|
242
|
+
const command = resolveCommand(declaration.command, env);
|
|
243
|
+
return {
|
|
244
|
+
declaration,
|
|
245
|
+
command,
|
|
246
|
+
env: resolveReferences(declaration.env, env, `server "${declaration.name}": env`),
|
|
247
|
+
provenance: {
|
|
248
|
+
name: declaration.name,
|
|
249
|
+
source: 'operator',
|
|
250
|
+
transport: 'stdio',
|
|
251
|
+
command,
|
|
252
|
+
args: declaration.args ?? [],
|
|
253
|
+
declaration_digest,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
declaration,
|
|
259
|
+
headers: resolveReferences(
|
|
260
|
+
declaration.headers,
|
|
261
|
+
env,
|
|
262
|
+
`server "${declaration.name}": headers`,
|
|
263
|
+
),
|
|
264
|
+
provenance: {
|
|
265
|
+
name: declaration.name,
|
|
266
|
+
source: 'operator',
|
|
267
|
+
transport: 'http',
|
|
268
|
+
url: declaration.url,
|
|
269
|
+
declaration_digest,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
this.digest = digest(valid);
|
|
274
|
+
this.secrets = this.servers
|
|
275
|
+
.flatMap((s) => [...Object.values(s.env ?? {}), ...Object.values(s.headers ?? {})])
|
|
276
|
+
.filter((v) => v.length >= 8);
|
|
277
|
+
}
|
|
278
|
+
static load(path: string, env: NodeJS.ProcessEnv = process.env): OperatorMcp {
|
|
279
|
+
return new OperatorMcp(readOperatorMcpFile(path), env);
|
|
280
|
+
}
|
|
281
|
+
provenance(): McpServerProvenance[] {
|
|
282
|
+
return this.servers.map((s) => structuredClone(s.provenance));
|
|
283
|
+
}
|
|
284
|
+
get empty(): boolean {
|
|
285
|
+
return this.servers.length === 0;
|
|
286
|
+
}
|
|
287
|
+
private server(name: unknown): Resolved {
|
|
288
|
+
const server = this.servers.find((s) => s.declaration.name === name);
|
|
289
|
+
if (!server) throw new ServiceError('invalid_mcp_server', 'Unknown declared MCP server', 400);
|
|
290
|
+
return server;
|
|
291
|
+
}
|
|
292
|
+
private connection(session: string, server: Resolved): Connection {
|
|
293
|
+
let owned = this.sessions.get(session);
|
|
294
|
+
if (!owned) this.sessions.set(session, (owned = new Map()));
|
|
295
|
+
const existing = owned.get(server.declaration.name);
|
|
296
|
+
if (existing) return existing;
|
|
297
|
+
const client = new Client({ name: 'salaam', version: '1' }, { capabilities: {} });
|
|
298
|
+
const d = server.declaration;
|
|
299
|
+
const transport: Transport =
|
|
300
|
+
d.transport === 'stdio'
|
|
301
|
+
? new StdioClientTransport({
|
|
302
|
+
command: server.command!,
|
|
303
|
+
args: d.args ?? [],
|
|
304
|
+
...(d.cwd ? { cwd: d.cwd } : {}),
|
|
305
|
+
...(server.env ? { env: server.env } : {}),
|
|
306
|
+
stderr: 'ignore',
|
|
307
|
+
})
|
|
308
|
+
: new StreamableHTTPClientTransport(new URL(d.url), {
|
|
309
|
+
requestInit: { headers: server.headers ?? {}, redirect: 'error' },
|
|
310
|
+
});
|
|
311
|
+
const connection: Connection = {
|
|
312
|
+
client,
|
|
313
|
+
transport,
|
|
314
|
+
ready: client.connect(transport, { timeout: 30_000 }),
|
|
315
|
+
};
|
|
316
|
+
connection.ready.catch(() => owned!.delete(d.name));
|
|
317
|
+
owned.set(d.name, connection);
|
|
318
|
+
return connection;
|
|
319
|
+
}
|
|
320
|
+
private redact(text: string): string {
|
|
321
|
+
let out = text;
|
|
322
|
+
for (const secret of this.secrets) out = out.split(secret).join('[redacted]');
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
private failure(error: unknown): never {
|
|
326
|
+
if (error instanceof ServiceError) throw error;
|
|
327
|
+
throw new ServiceError(
|
|
328
|
+
'mcp_server_failed',
|
|
329
|
+
this.redact(`Declared MCP server failed: ${(error as Error)?.message ?? 'unknown'}`).slice(
|
|
330
|
+
0,
|
|
331
|
+
512,
|
|
332
|
+
),
|
|
333
|
+
502,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
async catalog(session: string, serverName?: unknown, tool?: unknown): Promise<unknown> {
|
|
337
|
+
if (serverName === undefined || serverName === null || serverName === '')
|
|
338
|
+
return { servers: this.provenance().map(({ name, transport }) => ({ name, transport })) };
|
|
339
|
+
const server = this.server(serverName);
|
|
340
|
+
try {
|
|
341
|
+
const c = this.connection(session, server);
|
|
342
|
+
await c.ready;
|
|
343
|
+
const { tools } = await c.client.listTools(undefined, { timeout: 30_000 });
|
|
344
|
+
if (tool === undefined || tool === null || tool === '')
|
|
345
|
+
return {
|
|
346
|
+
server: server.declaration.name,
|
|
347
|
+
tools: tools.map((t) => ({ name: t.name, description: t.description ?? '' })),
|
|
348
|
+
};
|
|
349
|
+
const found = tools.find((t) => t.name === tool);
|
|
350
|
+
if (!found) throw new ServiceError('invalid_mcp_tool', 'Unknown tool on that server', 400);
|
|
351
|
+
return { server: server.declaration.name, tool: found };
|
|
352
|
+
} catch (error) {
|
|
353
|
+
this.failure(error);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async call(
|
|
357
|
+
session: string,
|
|
358
|
+
serverName: unknown,
|
|
359
|
+
tool: unknown,
|
|
360
|
+
args: unknown,
|
|
361
|
+
signal?: AbortSignal,
|
|
362
|
+
): Promise<{ content: { type: 'text'; text: string }[]; is_error: boolean }> {
|
|
363
|
+
const server = this.server(serverName);
|
|
364
|
+
if (typeof tool !== 'string' || !tool || tool.length > 128)
|
|
365
|
+
throw new ServiceError('invalid_mcp_tool', 'A tool name is required', 400);
|
|
366
|
+
if (args !== undefined && (args === null || typeof args !== 'object' || Array.isArray(args)))
|
|
367
|
+
throw new ServiceError('invalid_mcp_arguments', 'Arguments must be an object', 400);
|
|
368
|
+
try {
|
|
369
|
+
const c = this.connection(session, server);
|
|
370
|
+
await c.ready;
|
|
371
|
+
const result = await c.client.callTool(
|
|
372
|
+
{ name: tool, arguments: (args ?? {}) as Record<string, unknown> },
|
|
373
|
+
undefined,
|
|
374
|
+
{ timeout: CALL_TIMEOUT_MS, ...(signal ? { signal } : {}) },
|
|
375
|
+
);
|
|
376
|
+
const parts = Array.isArray(result.content) ? result.content : [];
|
|
377
|
+
let budget = RESULT_LIMIT;
|
|
378
|
+
const content = parts.map((part) => {
|
|
379
|
+
const text = this.redact(
|
|
380
|
+
part.type === 'text' ? String(part.text) : JSON.stringify(part),
|
|
381
|
+
).slice(0, Math.max(0, budget));
|
|
382
|
+
budget -= text.length;
|
|
383
|
+
return { type: 'text' as const, text };
|
|
384
|
+
});
|
|
385
|
+
if (result.structuredContent && !content.length)
|
|
386
|
+
content.push({
|
|
387
|
+
type: 'text',
|
|
388
|
+
text: this.redact(JSON.stringify(result.structuredContent)).slice(0, RESULT_LIMIT),
|
|
389
|
+
});
|
|
390
|
+
return { content, is_error: !!result.isError };
|
|
391
|
+
} catch (error) {
|
|
392
|
+
this.failure(error);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async stopSession(session: string): Promise<void> {
|
|
396
|
+
const owned = this.sessions.get(session);
|
|
397
|
+
this.sessions.delete(session);
|
|
398
|
+
if (!owned) return;
|
|
399
|
+
await Promise.allSettled([...owned.values()].map((c) => c.client.close()));
|
|
400
|
+
}
|
|
401
|
+
async close(): Promise<void> {
|
|
402
|
+
await Promise.allSettled([...this.sessions.keys()].map((s) => this.stopSession(s)));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Both sources may serve one session; a shared name is refused rather than shadowed. */
|
|
407
|
+
export function mergeMcpProvenance(
|
|
408
|
+
operator: readonly McpServerProvenance[],
|
|
409
|
+
agentpod: readonly McpServerProvenance[],
|
|
410
|
+
): McpServerProvenance[] {
|
|
411
|
+
const names = new Set(agentpod.map((s) => s.name));
|
|
412
|
+
const clash = operator.find((s) => names.has(s.name));
|
|
413
|
+
if (clash)
|
|
414
|
+
throw new ServiceError(
|
|
415
|
+
'mcp_name_collision',
|
|
416
|
+
`Declared MCP server "${clash.name}" collides with an AgentPod-assigned server`,
|
|
417
|
+
409,
|
|
418
|
+
);
|
|
419
|
+
return [...operator, ...agentpod];
|
|
420
|
+
}
|
package/src/persona.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { digest } from './protocol.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Item 9: the operator's persona for this host's agent. A plain UTF-8 text file the operator
|
|
7
|
+
* reviews at `host plan`; the manifest pins its path and digest, `host start` refuses a changed
|
|
8
|
+
* file, and every worker on the host gets it as a final system-prompt section. It shapes voice and
|
|
9
|
+
* conduct only: it grants no tool, path or permission, and no workspace file can supply it.
|
|
10
|
+
*/
|
|
11
|
+
export const PERSONA_MAX_BYTES = 16 * 1024;
|
|
12
|
+
export interface Persona {
|
|
13
|
+
text: string;
|
|
14
|
+
digest: string;
|
|
15
|
+
}
|
|
16
|
+
export function loadPersona(path: string): Persona {
|
|
17
|
+
if (!isAbsolute(path) || realpathSync(path) !== path)
|
|
18
|
+
throw Error('Use a canonical absolute persona path');
|
|
19
|
+
const st = statSync(path);
|
|
20
|
+
if (!st.isFile() || st.size > PERSONA_MAX_BYTES)
|
|
21
|
+
throw Error('A persona is a regular file of at most 16 KiB');
|
|
22
|
+
const raw = readFileSync(path);
|
|
23
|
+
const text = raw.toString('utf8');
|
|
24
|
+
if (Buffer.from(text, 'utf8').compare(raw) !== 0 || text.includes('\0'))
|
|
25
|
+
throw Error('A persona is UTF-8 text');
|
|
26
|
+
if (!text.trim()) throw Error('A persona must not be empty');
|
|
27
|
+
return { text, digest: digest({ persona: text }) };
|
|
28
|
+
}
|
|
29
|
+
/** The section appended to the system prompt. */
|
|
30
|
+
export function personaSection(p: Persona): string {
|
|
31
|
+
return (
|
|
32
|
+
"\n\n## Persona (set by this host's operator)\n" +
|
|
33
|
+
'This shapes your voice and conduct only. It grants no tools, paths or permissions, and it never ' +
|
|
34
|
+
'overrides approvals, policy or the facts of a tool result.\n\n' +
|
|
35
|
+
p.text.trim()
|
|
36
|
+
);
|
|
37
|
+
}
|
package/src/pi-sessions.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { SessionManager } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
/** Session files are written only by Pi's pinned session manager, never edited as JSONL. */
|
|
3
|
-
export function forkPiSession(
|
|
3
|
+
export function forkPiSession(
|
|
4
|
+
source: string,
|
|
5
|
+
cwd: string,
|
|
6
|
+
directory: string,
|
|
7
|
+
id: string,
|
|
8
|
+
/** Continue from this earlier entry (a rewind) instead of the latest one. */
|
|
9
|
+
at?: { leaf: string | null; summary: string },
|
|
10
|
+
): string {
|
|
4
11
|
const branch = SessionManager.forkFrom(source, cwd, directory, { id, parentSession: source });
|
|
5
12
|
const file = branch.getSessionFile();
|
|
6
13
|
if (!file || branch.getSessionId() !== id)
|
|
7
14
|
throw new Error('Pi did not create the requested branch identity');
|
|
15
|
+
if (at) branch.branchWithSummary(at.leaf, at.summary);
|
|
8
16
|
return file;
|
|
9
17
|
}
|
package/src/pi.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { redactFailureText } from './acp-outcome.js';
|
|
1
2
|
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
3
|
import { EventEmitter } from 'node:events';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -12,10 +13,31 @@ export interface ToolObservation {
|
|
|
12
13
|
input_digest?: string;
|
|
13
14
|
input_truncated?: boolean;
|
|
14
15
|
failed?: boolean;
|
|
16
|
+
output?: string;
|
|
17
|
+
output_truncated?: boolean;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export class PiCommandError extends Error {}
|
|
18
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The text a finished tool returned, for the board's activity stream. A card showed every step
|
|
24
|
+
* with `result: null` because only the status crossed over. Images are left out, known secret
|
|
25
|
+
* shapes are redacted, and the text is clipped to 2 KiB.
|
|
26
|
+
*/
|
|
27
|
+
function toolOutput(result: unknown): Pick<ToolObservation, 'output' | 'output_truncated'> {
|
|
28
|
+
const content = (result as { content?: unknown } | null)?.content;
|
|
29
|
+
if (!Array.isArray(content)) return {};
|
|
30
|
+
const text = content
|
|
31
|
+
.filter((p) => p && p.type === 'text' && typeof p.text === 'string')
|
|
32
|
+
.map((p) => p.text as string)
|
|
33
|
+
.join('\n');
|
|
34
|
+
if (!text) return {};
|
|
35
|
+
const clean = redactFailureText(text);
|
|
36
|
+
return clean.length > 2048
|
|
37
|
+
? { output: clean.slice(0, 2047) + '…', output_truncated: true }
|
|
38
|
+
: { output: clean };
|
|
39
|
+
}
|
|
40
|
+
|
|
19
41
|
// The upstream client does not expose process identity/group ownership or the
|
|
20
42
|
// extension UI subprotocol. Keep this narrow transport behind the Pi adapter.
|
|
21
43
|
export class PiProcess extends EventEmitter {
|
|
@@ -94,7 +116,7 @@ export class PiProcess extends EventEmitter {
|
|
|
94
116
|
input_digest: textDigest(input),
|
|
95
117
|
input_truncated: input.length > 8192,
|
|
96
118
|
}
|
|
97
|
-
: { failed: record.isError === true }),
|
|
119
|
+
: { failed: record.isError === true, ...toolOutput(record.result) }),
|
|
98
120
|
} satisfies ToolObservation);
|
|
99
121
|
}
|
|
100
122
|
this.emit('record', record);
|