agent-relay-orchestrator 0.129.6 → 0.129.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.129.6",
3
+ "version": "0.129.9",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,9 +16,9 @@
16
16
  "test": "bun test"
17
17
  },
18
18
  "dependencies": {
19
- "agent-relay-providers": "0.104.4",
20
- "agent-relay-sdk": "0.2.125",
21
- "callmux": "0.23.0"
19
+ "agent-relay-providers": "0.104.5",
20
+ "agent-relay-sdk": "0.2.127",
21
+ "callmux": "0.24.2"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/bun": "latest",
package/src/api.ts CHANGED
@@ -379,7 +379,18 @@ function authorized(req: Request, config: OrchestratorConfig): boolean {
379
379
  return req.headers.get("x-agent-relay-token") === config.token;
380
380
  }
381
381
 
382
- export function startApiServer(config: OrchestratorConfig, probeCache: ProviderProbeCache, relay?: RelayClient): { stop(): void; url: string } {
382
+ // #1425 the manual codex reset-consume entry point, satisfied by OrchestratorQuotaPoller.
383
+ // Kept as a narrow interface so the API server does not depend on the whole poller.
384
+ export interface CodexResetConsumer {
385
+ manualConsumeResetCredit(input: { provider?: string; accountKey?: string; idempotencyKey: string }): Promise<{
386
+ outcome: string;
387
+ availableCount?: number;
388
+ accountKey: string;
389
+ provider: string;
390
+ }>;
391
+ }
392
+
393
+ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderProbeCache, relay?: RelayClient, codexResetConsumer?: CodexResetConsumer): { stop(): void; url: string } {
383
394
  const server = Bun.serve<TerminalSocketData>({
384
395
  port: config.apiPort,
385
396
  hostname: "0.0.0.0",
@@ -412,6 +423,30 @@ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderP
412
423
  return json(reminted);
413
424
  }
414
425
 
426
+ // #1425 — manual codex banked-reset trigger. The relay (MCP tool / dashboard button) calls
427
+ // this on the orchestrator that owns the codex app-server websocket. Orchestrator-token
428
+ // gated like the other relay→orchestrator endpoints. The caller supplies the idempotency
429
+ // key so a retried request can't double-consume.
430
+ if (req.method === "POST" && url.pathname === "/api/codex/reset-consume") {
431
+ if (!authorized(req, config)) return error("unauthorized", 401);
432
+ if (!codexResetConsumer) return error("codex reset-consume unavailable", 503);
433
+ try {
434
+ const body = await req.json().catch(() => null) as { idempotencyKey?: unknown; accountKey?: unknown; provider?: unknown } | null;
435
+ const idempotencyKey = body && typeof body.idempotencyKey === "string" && body.idempotencyKey.trim()
436
+ ? body.idempotencyKey.trim()
437
+ : "";
438
+ if (!idempotencyKey) return error("idempotencyKey required", 400);
439
+ const accountKey = body && typeof body.accountKey === "string" && body.accountKey.trim() ? body.accountKey.trim() : undefined;
440
+ // #1425 finding 4 — honor the relay's declared reset-credit provider so the consume can
441
+ // only ever target that provider's account on this host, never a different provider's.
442
+ const provider = body && typeof body.provider === "string" && body.provider.trim() ? body.provider.trim() : undefined;
443
+ const result = await codexResetConsumer.manualConsumeResetCredit({ idempotencyKey, ...(accountKey ? { accountKey } : {}), ...(provider ? { provider } : {}) });
444
+ return json(result);
445
+ } catch (e) {
446
+ return error((e as Error).message, 502);
447
+ }
448
+ }
449
+
415
450
  if (url.pathname === "/api/artifacts" || url.pathname.startsWith("/api/artifacts/")) {
416
451
  if (!authorized(req, config)) return error("unauthorized", 401);
417
452
  return proxyArtifactRequest(req, config).catch((e) => error((e as Error).message, 502));
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
2
2
  import { homedir, hostname as osHostname } from "node:os";
3
3
  import { join, dirname } from "node:path";
4
4
  import { getAllManifests, getManifest } from "agent-relay-providers";
@@ -53,8 +53,17 @@ export function providerHomeRootFromEnv(): string {
53
53
  return process.env.AGENT_RELAY_PROVIDER_HOME_ROOT || join(homedir(), ".agent-relay", "provider-homes");
54
54
  }
55
55
 
56
+ // An ephemeral/test instance advertises AGENT_RELAY_EPHEMERAL=1 (see
57
+ // src/execution-mode.ts). Mirrored here so the orchestrator package stays
58
+ // dependency-free of the server package.
59
+ export function isEphemeralMode(): boolean {
60
+ return process.env.AGENT_RELAY_EPHEMERAL === "1";
61
+ }
62
+
56
63
  export function disableSystemdSupervisor(): boolean {
57
- return process.env.AGENT_RELAY_DISABLE_SYSTEMD_SUPERVISOR === "1";
64
+ // Force process (never systemd) supervision in ephemeral mode so an ephemeral
65
+ // boot can never create/adopt host `agent-relay-*` systemd units (#1280).
66
+ return process.env.AGENT_RELAY_DISABLE_SYSTEMD_SUPERVISOR === "1" || isEphemeralMode();
58
67
  }
59
68
 
60
69
  export function forceSystemdSupervisor(): boolean {
@@ -151,6 +160,7 @@ export function initConfigFile(config: Partial<RawConfig>): string {
151
160
  env: {},
152
161
  };
153
162
  const merged = { ...defaults, ...config };
154
- writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n");
163
+ writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 });
164
+ chmodSync(configPath, 0o600);
155
165
  return configPath;
156
166
  }
package/src/index.ts CHANGED
@@ -4,13 +4,13 @@ import { loadConfig, initConfigFile } from "./config";
4
4
  import { createRelayClient } from "./relay";
5
5
  import type { ManagedSessionExitDiagnostics } from "./relay";
6
6
  import { createControlHandler } from "./control";
7
- import { diagnoseSessionExit, hydrateTerminalGuests, managedSessionLiveness, reapTerminalGuests, refreshManagedAgentReport } from "./spawn";
7
+ import { diagnoseSessionExit, hydrateTerminalGuests, managedSessionLivenessDetailed, reapTerminalGuests, refreshManagedAgentReport } from "./spawn";
8
8
  import { startApiServer } from "./api";
9
9
  import { recoverManagedAgents } from "./recovery";
10
10
  import { ProviderProbeCache } from "./provider-probe";
11
11
  import { sweepEmptyWorkspaceContainers, workspacesRoot } from "./workspace-probe";
12
12
  import { startOrchestratorMaintenanceScheduler } from "./maintenance";
13
- import { OrchestratorQuotaPoller } from "./quota-poller";
13
+ import { OrchestratorQuotaPoller, resolveLiveCodexResetConsumeTransport, type CodexResetFiredEvent } from "./quota-poller";
14
14
  import { SharedCallmuxSupervisor } from "./shared-callmux";
15
15
  import { createCommandPoller } from "./command-poller";
16
16
 
@@ -54,7 +54,25 @@ const config = loadConfig();
54
54
  const probeCache = new ProviderProbeCache(config);
55
55
  const relay = createRelayClient(config, probeCache);
56
56
  const control = createControlHandler(config, relay);
57
- const quotaPoller = new OrchestratorQuotaPoller(config, relay);
57
+ // #1425 finding 1 the live banked-reset consume transport is wired ONLY for a genuine production
58
+ // run (explicit AGENT_RELAY_CODEX_RESET_CONSUME_LIVE=1 opt-in) and NEVER for the dev-service profile
59
+ // (AGENT_RELAY_DEV_PROFILE=1) / a test / CI. In every other case the resolver returns undefined, so
60
+ // the poller's class-default THROWING stub stands and the destructive RPC is UNREACHABLE by
61
+ // construction — a dev/test/CI invocation of the manual consume errors instead of burning a scarce
62
+ // reset. The env is the merged process + orchestrator-config env (dev sets its flag in config.env).
63
+ // Auto-fire also gets the onResetFired hook here (finding 7) so an autonomous fire emits the SAME
64
+ // `provider-quota.reset-fired` relay event as the manual path — the count refresh already flows via
65
+ // reportProviderQuota inside the poller. Best-effort + auto-only: the manual path emits its own event
66
+ // relay-side, so we never double-emit for manual.
67
+ const liveCodexResetConsume = resolveLiveCodexResetConsumeTransport({ ...process.env, ...config.env });
68
+ const quotaPoller = new OrchestratorQuotaPoller(config, relay, {
69
+ ...(liveCodexResetConsume ? { codexResetCreditConsume: liveCodexResetConsume } : {}),
70
+ onResetFired: (event: CodexResetFiredEvent) => {
71
+ if (event.trigger !== "auto") return;
72
+ void relay.reportCodexResetFired(event).catch((err) =>
73
+ console.error(`[orchestrator] codex reset-fired event report failed: ${errMessage(err)}`));
74
+ },
75
+ });
58
76
  const sharedCallmux = new SharedCallmuxSupervisor(config);
59
77
 
60
78
  const POLL_INTERVAL_MS = 3_000;
@@ -73,7 +91,7 @@ async function startup(): Promise<void> {
73
91
  console.error(`[orchestrator] env keys: ${Object.keys(config.env).length}`);
74
92
 
75
93
  // Start API server before registration so we can advertise the URL
76
- apiServer = startApiServer(config, probeCache, relay);
94
+ apiServer = startApiServer(config, probeCache, relay, quotaPoller);
77
95
  console.error(`[orchestrator] apiUrl: ${apiServer.url}`);
78
96
 
79
97
  // Register with relay. The server and orchestrator are often restarted
@@ -154,7 +172,11 @@ async function healthCheck(): Promise<void> {
154
172
  changed = true;
155
173
  }
156
174
  const sessionName = refreshed.sessionName ?? refreshed.tmuxSession;
157
- const liveness = managedSessionLiveness(sessionName);
175
+ // Capture the systemd diagnostics read at the exact moment liveness is first
176
+ // observed dead, and reuse it below instead of querying again — a `--collect`ed
177
+ // transient unit can be garbage-collected between two separate reads, which is
178
+ // what let #1317 launder a real status=1 death into a reported "success exit=0/0".
179
+ const { liveness, systemd: capturedSystemd } = managedSessionLivenessDetailed(sessionName);
158
180
  if (liveness === "unknown") {
159
181
  console.error(`[orchestrator] Session liveness unknown: ${sessionName}; preserving and retrying next health check`);
160
182
  continue;
@@ -165,6 +187,7 @@ async function healthCheck(): Promise<void> {
165
187
  policyName: refreshed.policyName,
166
188
  spawnRequestId: refreshed.spawnRequestId,
167
189
  tmuxSession: sessionName,
190
+ systemdOverride: capturedSystemd,
168
191
  }) ?? {
169
192
  agentId: refreshed.agentId,
170
193
  provider: refreshed.provider,
@@ -0,0 +1,382 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
+ import { homedir, hostname as hostHostname, arch as hostArch, platform as hostPlatform } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { errMessage, shellQuote } from "agent-relay-sdk";
6
+ import type { CallmuxConfig } from "callmux";
7
+ import { agentRelayHome } from "./config";
8
+
9
+ // #1331 — A Callmux descriptor is not portable just because it is JSON. The old
10
+ // shared config named launch scripts and package installs from the authoring host.
11
+ // Keep the source registry declarative, then materialize the small set of host-owned
12
+ // executables here under AGENT_RELAY_HOME. Nothing below points at a developer repo
13
+ // or ~/.npm-global, and all package/git inputs are immutable pins.
14
+
15
+ const PACKAGES = {
16
+ tokenlean: { pkg: "tokenlean", version: "0.50.11", bin: "tl-mcp" },
17
+ reasoning: { pkg: "@modelcontextprotocol/server-sequential-thinking", version: "2026.7.4", bin: "mcp-server-sequential-thinking" },
18
+ searxng: { pkg: "mcp-searxng", version: "1.11.0", bin: "mcp-searxng" },
19
+ } as const;
20
+
21
+ // This is the official github/github-mcp-server release used by macmini's
22
+ // working shared callmux. Do not replace it with the unrelated npm package
23
+ // that happens to publish the same binary name (#1331).
24
+ const GITHUB_MCP_VERSION = "1.0.3";
25
+ const GITHUB_MCP_RELEASES: Record<string, { asset: string; sha256: string }> = {
26
+ "darwin-arm64": { asset: "github-mcp-server_Darwin_arm64.tar.gz", sha256: "c7e910537553d59e2e9a7b07bb7da856985c0c0a63c5ecbee4633807d42e420c" },
27
+ "darwin-x64": { asset: "github-mcp-server_Darwin_x86_64.tar.gz", sha256: "8c4f37ac37f8d05792615f6091f9a42ac433ff503709da17707a565016aacbc9" },
28
+ "linux-arm64": { asset: "github-mcp-server_Linux_arm64.tar.gz", sha256: "e53535f3af758f75b0dbe304d98dd7b26937f68e7e21b722665db7d9d53cc263" },
29
+ "linux-x64": { asset: "github-mcp-server_Linux_x86_64.tar.gz", sha256: "6da1c42167306357587cecb2f937bd8fd1842d3e81a44eb57e85f386c0a8bd85" },
30
+ };
31
+
32
+ // qmd runs on the macOS host named `macmini`; macmini2 is the Linux
33
+ // orchestrator host and has no /opt/homebrew/bin/qmd. Host keys are public and
34
+ // pinning the live ed25519 key makes first provisioning strict without TOFU.
35
+ const QMD_HOST = "macmini";
36
+ const QMD_USER = "admin";
37
+ const QMD_ED25519_KEY = "AAAAC3NzaC1lZDI1NTE5AAAAICJJ9IngZt0zJFyYz9L0v2qPdmCW2raIw70oNQOnObg5";
38
+ const SEARXNG_SSH_HOST = "macmini2";
39
+ const SEARXNG_SSH_USER = "edimuj";
40
+ const SEARXNG_ED25519_KEY = "AAAAC3NzaC1lZDI1NTE5AAAAIBDgu73ZWFejv7U8aBsqCMA9oJo6tcVQvSaFYYfEeIbN";
41
+
42
+ export const PORTABLE_SHARED_MCP_SERVER_NAMES = ["tokenlean", "github", "reasoning", "searxng", "vent", "qmd"] as const;
43
+
44
+ const VENT_REPO = "https://github.com/edimuj/mcp-vent.git";
45
+ const VENT_REF = "da604f7776de8ff115ea5967bca57d4e665b8e5e";
46
+
47
+ export interface PortableMcpProvisioningDeps {
48
+ existsSync(path: string): boolean;
49
+ mkdirSync(path: string, options?: { recursive?: boolean }): void;
50
+ writeFileSync(path: string, content: string, options?: { mode?: number }): void;
51
+ chmodSync(path: string, mode: number): void;
52
+ statSync(path: string): { mode: number };
53
+ renameSync(oldPath: string, newPath: string): void;
54
+ sha256(path: string): string;
55
+ platform(): string;
56
+ arch(): string;
57
+ hostname(): string;
58
+ run(command: string, args: string[]): { exitCode: number; stdout: string; stderr: string };
59
+ }
60
+
61
+ export interface PortableMcpProvisioningResult {
62
+ servers: CallmuxConfig["servers"];
63
+ failures: Array<{ name: string; reason: string }>;
64
+ }
65
+
66
+ interface PortableMcpProvisioningOptions {
67
+ home?: string;
68
+ env?: Record<string, string | undefined>;
69
+ /** Registry-selected server names. Absent means bootstrap the complete core surface. */
70
+ names?: Iterable<string>;
71
+ deps?: PortableMcpProvisioningDeps;
72
+ }
73
+
74
+ export function provisionPortableSharedMcpServers(opts: PortableMcpProvisioningOptions = {}): PortableMcpProvisioningResult {
75
+ const home = opts.home ?? agentRelayHome();
76
+ const env = opts.env ?? process.env;
77
+ const deps = opts.deps ?? defaultDeps();
78
+ const root = join(home, "mcp");
79
+ const requested = opts.names ? new Set(opts.names) : undefined;
80
+ const servers: CallmuxConfig["servers"] = {};
81
+ const failures: PortableMcpProvisioningResult["failures"] = [];
82
+ const add = (name: string, build: () => CallmuxConfig["servers"][string]) => {
83
+ if (requested && !requested.has(name)) return;
84
+ try {
85
+ servers[name] = build();
86
+ } catch (error) {
87
+ failures.push({ name, reason: errMessage(error) });
88
+ }
89
+ };
90
+
91
+ add("tokenlean", () => ({
92
+ command: ensureNpmBinary("tokenlean", PACKAGES.tokenlean, root, deps),
93
+ prefix: "",
94
+ alwaysLoad: ["tl_symbols", "tl_snippet", "tl_pack", "tl_run", "tl_guard", "tl_lookup"],
95
+ requireSessionCwd: true,
96
+ }));
97
+ add("github", () => ({
98
+ command: writeGithubLauncher(root, ensureOfficialGithubBinary(root, deps), deps),
99
+ prefix: "gh",
100
+ tools: ["issue_read", "issue_write", "list_issues", "add_issue_comment", "search_issues", "search_code", "get_file_contents", "sub_issue_write"],
101
+ cachePolicy: { allowTools: ["issue_read", "list_issues", "search_issues", "search_code", "get_file_contents"] },
102
+ }));
103
+ add("reasoning", () => ({ command: ensureNpmBinary("reasoning", PACKAGES.reasoning, root, deps) }));
104
+ add("searxng", () => searxngDescriptor(root, env, deps));
105
+ add("vent", () => ({
106
+ command: writeVentLauncher(root, ensureVent(root, env, deps), deps),
107
+ env: {
108
+ VENT_REPO: env.AGENT_RELAY_VENT_REPO ?? "edimuj/exelerus-agent-vent",
109
+ VENT_HOST: env.AGENT_RELAY_VENT_HOST ?? env.HOSTNAME ?? "agent-relay",
110
+ },
111
+ }));
112
+ add("qmd", () => qmdDescriptor(root, env, deps));
113
+ return { servers, failures };
114
+ }
115
+
116
+ function ensureOfficialGithubBinary(root: string, deps: PortableMcpProvisioningDeps): string {
117
+ const release = GITHUB_MCP_RELEASES[`${deps.platform()}-${deps.arch()}`];
118
+ if (!release) throw new Error(`official GitHub MCP has no pinned release for ${deps.platform()}-${deps.arch()}`);
119
+ const installRoot = join(root, "packages", "github", GITHUB_MCP_VERSION, `${deps.platform()}-${deps.arch()}`);
120
+ const binary = join(installRoot, "github-mcp-server");
121
+ if (deps.existsSync(binary)) return binary;
122
+
123
+ const staging = `${installRoot}.staging-${process.pid}`;
124
+ const archive = join(staging, release.asset);
125
+ deps.mkdirSync(staging, { recursive: true });
126
+ const url = `https://github.com/github/github-mcp-server/releases/download/v${GITHUB_MCP_VERSION}/${release.asset}`;
127
+ runOrThrow(deps, "curl", ["--fail", "--silent", "--show-error", "--location", "--output", archive, url], "download official GitHub MCP");
128
+ const digest = deps.sha256(archive);
129
+ if (digest !== release.sha256) throw new Error(`official GitHub MCP checksum mismatch: expected ${release.sha256}, got ${digest}`);
130
+ runOrThrow(deps, "tar", ["-xzf", archive, "-C", staging], "extract official GitHub MCP");
131
+ if (!deps.existsSync(join(staging, "github-mcp-server"))) throw new Error("official GitHub MCP archive has no github-mcp-server binary");
132
+ deps.chmodSync(join(staging, "github-mcp-server"), 0o700);
133
+ deps.mkdirSync(dirname(installRoot), { recursive: true });
134
+ deps.renameSync(staging, installRoot);
135
+ if (!deps.existsSync(binary)) throw new Error("installed official GitHub MCP binary is missing");
136
+ return binary;
137
+ }
138
+
139
+ function ensureNpmBinary(name: string, spec: { pkg: string; version: string; bin: string }, root: string, deps: PortableMcpProvisioningDeps): string {
140
+ const installRoot = join(root, "packages", name, spec.version);
141
+ const binary = join(installRoot, "node_modules", ".bin", spec.bin);
142
+ if (!deps.existsSync(binary)) {
143
+ deps.mkdirSync(installRoot, { recursive: true });
144
+ runOrThrow(deps, "npm", ["install", "--prefix", installRoot, "--omit=dev", "--no-package-lock", `${spec.pkg}@${spec.version}`], `install ${spec.pkg}@${spec.version}`);
145
+ }
146
+ if (!deps.existsSync(binary)) throw new Error(`installed ${spec.pkg}@${spec.version}, but ${spec.bin} is missing`);
147
+ return binary;
148
+ }
149
+
150
+ function ensureVent(root: string, env: Record<string, string | undefined>, deps: PortableMcpProvisioningDeps): string {
151
+ const ref = env.AGENT_RELAY_VENT_REF ?? VENT_REF;
152
+ if (!/^[a-f0-9]{40}$/i.test(ref)) throw new Error("AGENT_RELAY_VENT_REF must be a full immutable git SHA");
153
+ const repo = env.AGENT_RELAY_VENT_REPO_URL ?? VENT_REPO;
154
+ if (!/^https:\/\//.test(repo)) throw new Error("AGENT_RELAY_VENT_REPO_URL must be an https git URL");
155
+ const target = join(root, "checkouts", "mcp-vent", ref);
156
+ const entry = join(target, "dist", "index.js");
157
+ if (deps.existsSync(entry)) return entry;
158
+ const staging = `${target}.staging-${process.pid}`;
159
+ if (deps.existsSync(target)) throw new Error(`incomplete mcp-vent checkout at ${target}; remove it only after inspection`);
160
+ runOrThrow(deps, "git", ["clone", "--no-checkout", repo, staging], "clone mcp-vent");
161
+ runOrThrow(deps, "git", ["-C", staging, "checkout", "--detach", ref], "checkout mcp-vent pin");
162
+ runOrThrow(deps, "npm", ["install", "--prefix", staging, "--include=dev", "--no-package-lock"], "install mcp-vent dependencies");
163
+ runOrThrow(deps, "npm", ["run", "build", "--prefix", staging], "build mcp-vent");
164
+ deps.mkdirSync(dirname(target), { recursive: true });
165
+ deps.renameSync(staging, target);
166
+ if (!deps.existsSync(entry)) throw new Error("built mcp-vent checkout has no dist/index.js");
167
+ return entry;
168
+ }
169
+
170
+ function writeGithubLauncher(root: string, binary: string, deps: PortableMcpProvisioningDeps): string {
171
+ return writeLauncher(join(root, "launchers", "github"), `#!/usr/bin/env bash
172
+ set -euo pipefail
173
+ if [ -z "\${GITHUB_PERSONAL_ACCESS_TOKEN:-}" ] && command -v gh >/dev/null 2>&1; then
174
+ GITHUB_PERSONAL_ACCESS_TOKEN="$(gh auth token 2>/dev/null || true)"
175
+ export GITHUB_PERSONAL_ACCESS_TOKEN
176
+ fi
177
+ if [ -z "\${GITHUB_PERSONAL_ACCESS_TOKEN:-}" ]; then
178
+ echo "agent-relay github MCP: configure gh auth or GITHUB_PERSONAL_ACCESS_TOKEN on this host" >&2
179
+ exit 1
180
+ fi
181
+ exec ${shellQuote(binary)} stdio
182
+ `, deps);
183
+ }
184
+
185
+ function writeVentLauncher(root: string, entry: string, deps: PortableMcpProvisioningDeps): string {
186
+ return writeLauncher(join(root, "launchers", "vent"), `#!/usr/bin/env bash
187
+ set -euo pipefail
188
+ if [ -z "\${GITHUB_TOKEN:-}" ] && [ -z "\${VENT_GITHUB_TOKEN:-}" ] && command -v gh >/dev/null 2>&1; then
189
+ GITHUB_TOKEN="$(gh auth token 2>/dev/null || true)"
190
+ export GITHUB_TOKEN
191
+ fi
192
+ if [ -z "\${GITHUB_TOKEN:-}" ] && [ -z "\${VENT_GITHUB_TOKEN:-}" ]; then
193
+ echo "agent-relay vent MCP: configure gh auth or GITHUB_TOKEN on this host" >&2
194
+ exit 1
195
+ fi
196
+ exec node ${shellQuote(entry)}
197
+ `, deps);
198
+ }
199
+
200
+ function writeLauncher(path: string, content: string, deps: PortableMcpProvisioningDeps): string {
201
+ deps.mkdirSync(dirname(path), { recursive: true });
202
+ deps.writeFileSync(path, content, { mode: 0o700 });
203
+ deps.chmodSync(path, 0o700);
204
+ return path;
205
+ }
206
+
207
+ function searxngDescriptor(root: string, env: Record<string, string | undefined>, deps: PortableMcpProvisioningDeps): CallmuxConfig["servers"][string] {
208
+ const directUrl = env.AGENT_RELAY_SEARXNG_URL?.trim();
209
+ const sshHost = env.AGENT_RELAY_SEARXNG_SSH_HOST?.trim() || SEARXNG_SSH_HOST;
210
+ const binary = ensureNpmBinary("searxng", PACKAGES.searxng, root, deps);
211
+ if (directUrl || isLocalHost(deps.hostname(), sshHost)) {
212
+ return {
213
+ command: binary,
214
+ env: { SEARXNG_URL: directUrl || "http://127.0.0.1:8888" },
215
+ cachePolicy: { allowTools: ["searxng_web_search"] },
216
+ };
217
+ }
218
+
219
+ const identityFile = resolveSshIdentity("searxng", sshHost, "AGENT_RELAY_SEARXNG_IDENTITY_FILE", env, deps, "~/.ssh/id_ed25519");
220
+ const configuredHostKey = env.AGENT_RELAY_SEARXNG_HOST_KEY?.trim();
221
+ const hostKey = configuredHostKey || (sshHost === SEARXNG_SSH_HOST ? `ssh-ed25519 ${SEARXNG_ED25519_KEY}` : undefined);
222
+ if (!hostKey) throw new Error(`searxng requires AGENT_RELAY_SEARXNG_HOST_KEY for SSH host ${sshHost}`);
223
+ const knownHosts = writePinnedKnownHosts(root, "searxng", sshHost, hostKey, deps);
224
+ const user = env.AGENT_RELAY_SEARXNG_SSH_USER?.trim() || SEARXNG_SSH_USER;
225
+ const sshArgs = sharedSshArgs(knownHosts, identityFile).concat([
226
+ "-o", "ExitOnForwardFailure=yes",
227
+ "-N",
228
+ ]);
229
+ const launcher = writeSearxngTunnelLauncher(root, binary, sshArgs, user, sshHost, deps);
230
+ return {
231
+ command: launcher,
232
+ cachePolicy: { allowTools: ["searxng_web_search"] },
233
+ };
234
+ }
235
+
236
+ function writeSearxngTunnelLauncher(root: string, binary: string, sshArgs: string[], user: string, host: string, deps: PortableMcpProvisioningDeps): string {
237
+ const allocatePort = `const net = require("node:net");
238
+ const server = net.createServer();
239
+ server.on("error", (error) => { console.error(error.message); process.exit(1); });
240
+ server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => {
241
+ const address = server.address();
242
+ if (!address || typeof address === "string") process.exit(1);
243
+ process.stdout.write(String(address.port));
244
+ server.close();
245
+ });`;
246
+ return writeLauncher(join(root, "launchers", "searxng"), `#!/usr/bin/env bash
247
+ set -u
248
+ tunnel_pid=""
249
+ mcp_pid=""
250
+ tunnel_target=${shellQuote(`${user}@${host}`)}
251
+ cleanup() {
252
+ [ -z "$mcp_pid" ] || kill "$mcp_pid" 2>/dev/null || true
253
+ [ -z "$tunnel_pid" ] || kill "$tunnel_pid" 2>/dev/null || true
254
+ [ -z "$mcp_pid" ] || wait "$mcp_pid" 2>/dev/null || true
255
+ [ -z "$tunnel_pid" ] || wait "$tunnel_pid" 2>/dev/null || true
256
+ }
257
+ trap cleanup EXIT INT TERM HUP
258
+ exec 3<&0
259
+ local_port="$(node -e ${shellQuote(allocatePort)})" || {
260
+ echo "agent-relay searxng could not allocate a local SSH-forward port" >&2
261
+ exit 1
262
+ }
263
+ case "$local_port" in
264
+ ""|*[!0-9]*) echo "agent-relay searxng received an invalid local SSH-forward port" >&2; exit 1 ;;
265
+ esac
266
+ export SEARXNG_URL="http://127.0.0.1:\${local_port}"
267
+ ssh ${sshArgs.map(shellQuote).join(" ")} '-L' "127.0.0.1:\${local_port}:127.0.0.1:8888" '-l' ${shellQuote(user)} ${shellQuote(host)} &
268
+ tunnel_pid=$!
269
+ sleep 0.25
270
+ if ! kill -0 "$tunnel_pid" 2>/dev/null; then
271
+ wait "$tunnel_pid"
272
+ status=$?
273
+ [ "$status" -ne 0 ] || status=1
274
+ echo "agent-relay searxng SSH tunnel to \${tunnel_target} failed (exit $status); Callmux will reconnect with backoff" >&2
275
+ exit "$status"
276
+ fi
277
+ ${shellQuote(binary)} <&3 &
278
+ mcp_pid=$!
279
+ while kill -0 "$tunnel_pid" 2>/dev/null && kill -0 "$mcp_pid" 2>/dev/null; do
280
+ sleep 1
281
+ done
282
+ if ! kill -0 "$tunnel_pid" 2>/dev/null; then
283
+ wait "$tunnel_pid"
284
+ status=$?
285
+ [ "$status" -ne 0 ] || status=1
286
+ echo "agent-relay searxng SSH tunnel to \${tunnel_target} closed (exit $status); Callmux will reconnect with backoff" >&2
287
+ exit "$status"
288
+ fi
289
+ wait "$mcp_pid"
290
+ exit $?
291
+ `, deps);
292
+ }
293
+
294
+ function qmdDescriptor(root: string, env: Record<string, string | undefined>, deps: PortableMcpProvisioningDeps): CallmuxConfig["servers"][string] {
295
+ const host = env.AGENT_RELAY_QMD_HOST ?? QMD_HOST;
296
+ const identityFile = resolveSshIdentity("qmd", host, "AGENT_RELAY_QMD_IDENTITY_FILE", env, deps);
297
+ const pinnedHostKey = env.AGENT_RELAY_QMD_KNOWN_HOSTS;
298
+ const hostKeyLine = pinnedHostKey?.trim() || `${host} ssh-ed25519 ${QMD_ED25519_KEY}`;
299
+ const knownHosts = writePinnedKnownHosts(root, "qmd", host, hostKeyLine, deps);
300
+ const user = env.AGENT_RELAY_QMD_USER ?? QMD_USER;
301
+ return {
302
+ command: "ssh",
303
+ args: ["-T", ...sharedSshArgs(knownHosts, identityFile), "-l", user, host, "/opt/homebrew/bin/qmd", "mcp"],
304
+ cachePolicy: { allowTools: ["query", "get", "multi_get", "status"] },
305
+ };
306
+ }
307
+
308
+ function resolveSshIdentity(label: string, host: string, envName: string, env: Record<string, string | undefined>, deps: PortableMcpProvisioningDeps, defaultIdentity?: string): string {
309
+ const configured = env[envName] ?? defaultIdentity;
310
+ if (configured) {
311
+ const path = expandHome(configured, env.HOME ?? homedir());
312
+ if (!deps.existsSync(path)) throw new Error(`${label} identity file is missing: ${path}`);
313
+ return path;
314
+ }
315
+ const sshConfig = deps.run("ssh", ["-G", host]);
316
+ if (sshConfig.exitCode !== 0) throw new Error(`cannot resolve ${label} SSH config for ${host}: ${sshConfig.stderr.trim().slice(0, 300)}`);
317
+ const identities = sshConfig.stdout.split(/\r?\n/)
318
+ .filter((line) => line.startsWith("identityfile "))
319
+ .map((line) => expandHome(line.slice("identityfile ".length).trim(), env.HOME ?? homedir()));
320
+ const identity = identities.find((path) => deps.existsSync(path));
321
+ if (!identity) throw new Error(`${label} requires an existing SSH IdentityFile for ${host} (or ${envName})`);
322
+ return identity;
323
+ }
324
+
325
+ function sharedSshArgs(knownHosts: string, identityFile: string): string[] {
326
+ return [
327
+ "-o", "BatchMode=yes",
328
+ "-o", "StrictHostKeyChecking=yes",
329
+ "-o", `UserKnownHostsFile=${knownHosts}`,
330
+ "-o", `IdentityFile=${identityFile}`,
331
+ "-o", "IdentitiesOnly=yes",
332
+ "-o", "ServerAliveInterval=30",
333
+ "-o", "ServerAliveCountMax=3",
334
+ ];
335
+ }
336
+
337
+ function writePinnedKnownHosts(root: string, label: string, host: string, hostKey: string, deps: PortableMcpProvisioningDeps): string {
338
+ const knownHosts = join(root, label, "known_hosts");
339
+ const trimmed = hostKey.trim();
340
+ const line = trimmed.startsWith(`${host} `) || trimmed.startsWith(`[${host}]`) ? trimmed
341
+ : trimmed.startsWith("ssh-") ? `${host} ${trimmed}`
342
+ : `${host} ssh-ed25519 ${trimmed}`;
343
+ deps.mkdirSync(dirname(knownHosts), { recursive: true });
344
+ deps.writeFileSync(knownHosts, `${line}\n`, { mode: 0o600 });
345
+ deps.chmodSync(knownHosts, 0o600);
346
+ if ((deps.statSync(knownHosts).mode & 0o077) !== 0) throw new Error(`${label} known_hosts must not be group/world readable`);
347
+ return knownHosts;
348
+ }
349
+
350
+ function isLocalHost(localHostname: string, targetHostname: string): boolean {
351
+ const local = localHostname.toLowerCase().split(".")[0];
352
+ const target = targetHostname.toLowerCase().split(".")[0];
353
+ return Boolean(local && local === target);
354
+ }
355
+
356
+ function expandHome(path: string, home: string): string {
357
+ return path === "~" ? home : path.startsWith("~/") ? join(home, path.slice(2)) : path;
358
+ }
359
+
360
+ function runOrThrow(deps: PortableMcpProvisioningDeps, command: string, args: string[], action: string): void {
361
+ const result = deps.run(command, args);
362
+ if (result.exitCode !== 0) throw new Error(`${action} failed (${result.exitCode}): ${result.stderr.trim().slice(0, 300)}`);
363
+ }
364
+
365
+ function defaultDeps(): PortableMcpProvisioningDeps {
366
+ return {
367
+ existsSync,
368
+ mkdirSync,
369
+ writeFileSync,
370
+ chmodSync,
371
+ statSync,
372
+ renameSync,
373
+ sha256: (path) => createHash("sha256").update(readFileSync(path)).digest("hex"),
374
+ platform: hostPlatform,
375
+ arch: hostArch,
376
+ hostname: hostHostname,
377
+ run(command, args) {
378
+ const result = Bun.spawnSync([command, ...args], { stdout: "pipe", stderr: "pipe" });
379
+ return { exitCode: result.exitCode, stdout: result.stdout.toString(), stderr: result.stderr.toString() };
380
+ },
381
+ };
382
+ }