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 +4 -4
- package/src/api.ts +36 -1
- package/src/config.ts +13 -3
- package/src/index.ts +28 -5
- package/src/mcp-provisioning.ts +382 -0
- package/src/quota-poller.ts +348 -13
- package/src/relay.ts +28 -4
- package/src/self-supervision.ts +45 -1
- package/src/self-upgrade.ts +205 -14
- package/src/shared-callmux.ts +21 -5
- package/src/spawn/acquisition.ts +28 -1
- package/src/spawn/command.ts +11 -3
- package/src/spawn/runtime.ts +20 -4
- package/src/spawn/sessions.ts +10 -1
- package/src/spawn/supervisor.ts +18 -4
- package/src/spawn/systemd.ts +49 -46
- package/src/workspace-probe/deps.ts +92 -4
- package/src/workspace-probe/integrated-land-gates.ts +9 -12
- package/src/workspace-probe/merge.ts +5 -3
- package/src/workspace-probe/plain-git.ts +6 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.129.
|
|
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.
|
|
20
|
-
"agent-relay-sdk": "0.2.
|
|
21
|
-
"callmux": "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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|