@moondeck/station 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/meta/_journal.json +223 -0
- package/db/schema.ts +850 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1453 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +891 -0
- package/lib/env.ts +195 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +131 -4
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2583 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2899 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6349 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5545 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
|
@@ -0,0 +1,2594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP gateway (ADR-057) — the host-side proxy that puts the owner's MCP
|
|
3
|
+
* connections in front of task containers WITHOUT the credentials ever
|
|
4
|
+
* entering them.
|
|
5
|
+
*
|
|
6
|
+
* Containers call `http://<gateway-host>:<port>/t/<token>/<slug>` (Docker:
|
|
7
|
+
* host.docker.internal; Apple: the vmnet gateway IP); the
|
|
8
|
+
* gateway resolves the per-task token to an ACL file written at session
|
|
9
|
+
* ensure (which connections this task may use), attaches the connection's
|
|
10
|
+
* Authorization header host-side (refreshing OAuth tokens as needed), and
|
|
11
|
+
* streams the MCP traffic through (streamable HTTP + SSE). Every request
|
|
12
|
+
* lands in an audit JSONL. Disconnecting a connection kills its secrets in
|
|
13
|
+
* the host store, so the gateway 401s instantly — the kill switch.
|
|
14
|
+
*
|
|
15
|
+
* VERIFY-ON-MAC: OrbStack forwards host.docker.internal to loopback-bound
|
|
16
|
+
* host services; if not, set UAI_MCP_GATEWAY_BIND=0.0.0.0 (the unguessable
|
|
17
|
+
* per-task token stays the auth).
|
|
18
|
+
*
|
|
19
|
+
* ADR-154 §11: a task on cloud credentials is served from its lease instead
|
|
20
|
+
* (credential-consumers/mcp.ts): the header comes from memory, every request
|
|
21
|
+
* goes through the SSRF rule, and an expired or refused OAuth token is
|
|
22
|
+
* refreshed by the cloud's broker, never here. On a runner past its
|
|
23
|
+
* credentials floor nothing falls back to the local store.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
appendFileSync,
|
|
28
|
+
lstatSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
rmSync,
|
|
32
|
+
writeFileSync,
|
|
33
|
+
} from "node:fs";
|
|
34
|
+
import {
|
|
35
|
+
createServer as createHttpServer,
|
|
36
|
+
type IncomingMessage,
|
|
37
|
+
type Server as HttpServer,
|
|
38
|
+
type ServerResponse,
|
|
39
|
+
} from "node:http";
|
|
40
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
41
|
+
import {
|
|
42
|
+
createServer as createNetServer,
|
|
43
|
+
type Server as NetServer,
|
|
44
|
+
type Socket,
|
|
45
|
+
} from "node:net";
|
|
46
|
+
import { Readable, pipeline } from "node:stream";
|
|
47
|
+
import { resolve } from "node:path";
|
|
48
|
+
|
|
49
|
+
import { env } from "./env";
|
|
50
|
+
import {
|
|
51
|
+
appleContainerRuntimeBinding,
|
|
52
|
+
pinnedContainerRuntimeProvider,
|
|
53
|
+
} from "./container-runtime";
|
|
54
|
+
import {
|
|
55
|
+
taskAppContainerName,
|
|
56
|
+
taskContainerBackend,
|
|
57
|
+
taskContainerCli,
|
|
58
|
+
} from "./task-container-cli";
|
|
59
|
+
import { MCP_CONFIG_LOCK_PATH } from "./mcp-config-lock";
|
|
60
|
+
import {
|
|
61
|
+
RUNTIME_AUTHORITY_ENV,
|
|
62
|
+
runtimeAuthorityDockerExecArgs,
|
|
63
|
+
} from "./runtime-authority";
|
|
64
|
+
import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
65
|
+
import { LeftOwnCredentialsError, onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
|
|
66
|
+
import { authHeaderFor, getConnection } from "./mcp-connections";
|
|
67
|
+
import {
|
|
68
|
+
forgetLeasedMcp,
|
|
69
|
+
holdLeasedMcp,
|
|
70
|
+
leasedMcpFor,
|
|
71
|
+
type McpRequestHold,
|
|
72
|
+
type LeasedMcpConnection,
|
|
73
|
+
} from "./credential-consumers/mcp";
|
|
74
|
+
import { safeRequest, type SafeHttpDeps, type SafeHttpResponse } from "./safe-http";
|
|
75
|
+
import { agentClisReady } from "./standard-image";
|
|
76
|
+
import { isSafeHostTaskId, assertSafeHostTaskId } from "./task-identity";
|
|
77
|
+
import type { McpGatewayCapability } from "../src/protocol";
|
|
78
|
+
import { isEnvironmentTransportProvider } from "./task-environment/types";
|
|
79
|
+
import { sidecarTaskId } from "./sidecar";
|
|
80
|
+
import {
|
|
81
|
+
TASK_FORWARDER_ACCEPT_MAGIC,
|
|
82
|
+
TASK_FORWARDER_MAX_PREAMBLE_BYTES,
|
|
83
|
+
TASK_FORWARDER_PREAMBLE_HEADER_BYTES,
|
|
84
|
+
TASK_FORWARDER_RESPONSE_BYTES,
|
|
85
|
+
decodeTaskForwarderPreamble,
|
|
86
|
+
decodeTaskForwarderResponse,
|
|
87
|
+
encodeTaskForwarderChallenge,
|
|
88
|
+
taskForwarderPreambleLength,
|
|
89
|
+
type TaskForwarderIdentity,
|
|
90
|
+
} from "./task-gateway-protocol";
|
|
91
|
+
import {
|
|
92
|
+
currentTaskGatewayRegistration,
|
|
93
|
+
isTaskGatewayGenerationCurrent,
|
|
94
|
+
resolveTaskGatewayEnv,
|
|
95
|
+
subscribeTaskGatewayRevocations,
|
|
96
|
+
verifyTaskGatewayAuthentication,
|
|
97
|
+
} from "./task-gateway-state";
|
|
98
|
+
import { MCP_GATEWAY_PORT } from "./task-gateway-port";
|
|
99
|
+
import { MAX_MANIFEST_MCP } from "../src/cloud-credentials";
|
|
100
|
+
|
|
101
|
+
export type { McpGatewayCapability } from "../src/protocol";
|
|
102
|
+
|
|
103
|
+
export { MCP_GATEWAY_PORT };
|
|
104
|
+
|
|
105
|
+
/** Loopback works only where the container runtime forwards
|
|
106
|
+
* host.docker.internal INTO the host loopback — macOS Docker
|
|
107
|
+
* (Desktop/OrbStack). Native Linux docker resolves host.docker.internal to
|
|
108
|
+
* the bridge gateway IP, where a loopback bind is unreachable: every task's
|
|
109
|
+
* MCP config carried a dead URL and the gateway showed connection-refused
|
|
110
|
+
* from inside containers (live 2026-08-24, first Linux host with an MCP
|
|
111
|
+
* connection). Apple vmnet guests cannot reach host loopback either. Both
|
|
112
|
+
* non-mac-docker cases therefore bind all interfaces — the unguessable
|
|
113
|
+
* per-task path token remains the auth (same posture the operator override
|
|
114
|
+
* has always allowed). Resolved per call: the runtime pin lands before the
|
|
115
|
+
* production listener starts, and a pin never changes within a process. */
|
|
116
|
+
function gatewayBindAddress(): string {
|
|
117
|
+
const configured = process.env.UAI_MCP_GATEWAY_BIND;
|
|
118
|
+
if (configured) return configured;
|
|
119
|
+
// The pin, not the ready-gated identity: the production listener starts
|
|
120
|
+
// during boot while activation still reports `checking`, and a loopback
|
|
121
|
+
// bind chosen then would strand every vmnet guest.
|
|
122
|
+
if (pinnedContainerRuntimeProvider() === "apple-container") return "0.0.0.0";
|
|
123
|
+
return process.platform === "darwin" ? "127.0.0.1" : "0.0.0.0";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Host address a task container dials to reach this gateway. Docker guests
|
|
127
|
+
* resolve host.docker.internal; Apple vmnet guests dial the vmnet gateway IP,
|
|
128
|
+
* read once per process from the bundled CLI's network inspection (the
|
|
129
|
+
* builtin `default` network the app containers join). A resolution failure
|
|
130
|
+
* throws so the config write fails visibly and is retried on the next session
|
|
131
|
+
* ensure, rather than baking a dead URL into the task. */
|
|
132
|
+
let cachedAppleGatewayHost: string | null = null;
|
|
133
|
+
export async function taskGatewayHost(): Promise<string> {
|
|
134
|
+
if (pinnedContainerRuntimeProvider() !== "apple-container") {
|
|
135
|
+
return "host.docker.internal";
|
|
136
|
+
}
|
|
137
|
+
if (cachedAppleGatewayHost !== null) return cachedAppleGatewayHost;
|
|
138
|
+
const binding = appleContainerRuntimeBinding();
|
|
139
|
+
if (binding === null) {
|
|
140
|
+
throw new Error("apple-container selected without a bundled CLI binding");
|
|
141
|
+
}
|
|
142
|
+
const { execFile } = await import("node:child_process");
|
|
143
|
+
const stdout = await new Promise<string>((resolveExec, rejectExec) => {
|
|
144
|
+
execFile(
|
|
145
|
+
binding.containerCliPath,
|
|
146
|
+
["network", "inspect", "default"],
|
|
147
|
+
{ timeout: 15_000, maxBuffer: 256 * 1024 },
|
|
148
|
+
(error, out) => (error ? rejectExec(error) : resolveExec(out)),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
const parsed = JSON.parse(stdout) as Array<{
|
|
152
|
+
status?: { ipv4Gateway?: unknown };
|
|
153
|
+
}>;
|
|
154
|
+
const gateway = parsed[0]?.status?.ipv4Gateway;
|
|
155
|
+
if (
|
|
156
|
+
typeof gateway !== "string" ||
|
|
157
|
+
!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(gateway)
|
|
158
|
+
) {
|
|
159
|
+
throw new Error("could not resolve the vmnet gateway address");
|
|
160
|
+
}
|
|
161
|
+
cachedAppleGatewayHost = gateway;
|
|
162
|
+
return gateway;
|
|
163
|
+
}
|
|
164
|
+
/** MCP request bodies are small JSON-RPC frames; cap so audit parsing (and a
|
|
165
|
+
* hostile container) can't balloon host memory. Responses stream freely. */
|
|
166
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
167
|
+
/** Bounds the upstream CONNECT (until response headers) only. The response
|
|
168
|
+
* BODY must never be time-bounded: MCP streamable-HTTP clients hold SSE
|
|
169
|
+
* streams open indefinitely, and an abort mid-pipe once took the whole host
|
|
170
|
+
* process down as an unhandled Readable 'error' (2026-07-13). */
|
|
171
|
+
const UPSTREAM_CONNECT_TIMEOUT_MS = 60_000;
|
|
172
|
+
const GATEWAY_RETRY_INITIAL_DELAY_MS = 1_000;
|
|
173
|
+
const GATEWAY_RETRY_MAX_DELAY_MS = 15_000;
|
|
174
|
+
const GATEWAY_RETRY_MIN_JITTER = 0.8;
|
|
175
|
+
const GATEWAY_RETRY_JITTER_SPAN = 0.4;
|
|
176
|
+
const TASK_GATEWAY_PREAUTH_TIMEOUT_MS = 3_000;
|
|
177
|
+
const TASK_GATEWAY_MAX_PENDING_PREAUTH = 128;
|
|
178
|
+
const TASK_GATEWAY_MAX_PENDING_PREAUTH_PER_PEER = 16;
|
|
179
|
+
const TASK_GATEWAY_MAX_PENDING_PREAUTH_PER_TASK = 16;
|
|
180
|
+
// MCP streamable-HTTP/SSE and proxied WebSockets can legitimately hold a
|
|
181
|
+
// connection for the life of a session, so active limits are deliberately
|
|
182
|
+
// separate from the tight three-second handshake limits above. They are still
|
|
183
|
+
// finite: once a socket has supplied its first application byte, HTTP's header
|
|
184
|
+
// and request deadlines bound time, while these counters bound cardinality.
|
|
185
|
+
const TASK_GATEWAY_MAX_ACTIVE_CONNECTIONS = 2_048;
|
|
186
|
+
// Direct sockets have not proved which task they belong to yet. Treat this as
|
|
187
|
+
// a small, evictable staging allowance; tokenized MCP moves to the task quota
|
|
188
|
+
// as soon as Node parses and validates the request path.
|
|
189
|
+
const TASK_GATEWAY_MAX_DIRECT_STAGING_CONNECTIONS_PER_PEER = 16;
|
|
190
|
+
const TASK_GATEWAY_MAX_ACTIVE_CONNECTIONS_PER_TASK = 256;
|
|
191
|
+
|
|
192
|
+
export interface TaskMcpConnection {
|
|
193
|
+
id: string;
|
|
194
|
+
slug: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface TaskAcl {
|
|
198
|
+
token: string;
|
|
199
|
+
connections: TaskMcpConnection[];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function aclDir(): string {
|
|
203
|
+
return resolve(env.dataDir, "mcp-gateway");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function aclPath(taskId: string): string {
|
|
207
|
+
return resolve(aclDir(), `${taskId}.json`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function readAcl(taskId: string): TaskAcl | null {
|
|
211
|
+
try {
|
|
212
|
+
return JSON.parse(readFileSync(aclPath(taskId), "utf8")) as TaskAcl;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Write/refresh the task's gateway ACL (the connection set snapshots the
|
|
220
|
+
* cloud's latest ensure input). The token is minted once per task and
|
|
221
|
+
* survives re-ensures so already-written .mcp.json files stay valid.
|
|
222
|
+
*/
|
|
223
|
+
export function ensureTaskGatewayAcl(
|
|
224
|
+
taskId: string,
|
|
225
|
+
connections: TaskMcpConnection[],
|
|
226
|
+
): TaskAcl {
|
|
227
|
+
const existing = readAcl(taskId);
|
|
228
|
+
const acl: TaskAcl = {
|
|
229
|
+
token: existing?.token ?? `${taskId}.${randomBytes(24).toString("base64url")}`,
|
|
230
|
+
connections,
|
|
231
|
+
};
|
|
232
|
+
mkdirSync(aclDir(), { recursive: true, mode: 0o700 });
|
|
233
|
+
writeFileSync(aclPath(taskId), JSON.stringify(acl), { mode: 0o600 });
|
|
234
|
+
return acl;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function clearTaskGatewayAcl(taskId: string): void {
|
|
238
|
+
// Defense in depth: the id becomes a filesystem path component, and some
|
|
239
|
+
// callers derive it from container-influenced data (labels). A traversal
|
|
240
|
+
// like `../container-runtime-provider` must never resolve outside the ACL
|
|
241
|
+
// directory, so an unsafe id is a no-op here rather than a deletion.
|
|
242
|
+
if (!isSafeHostTaskId(taskId)) return;
|
|
243
|
+
forgetLeasedMcp(taskId);
|
|
244
|
+
try {
|
|
245
|
+
rmSync(aclPath(taskId));
|
|
246
|
+
} catch {
|
|
247
|
+
// already gone
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Strict variant used only when acknowledging orphan-GC completion. */
|
|
252
|
+
export function clearTaskGatewayAclStrict(taskId: string): void {
|
|
253
|
+
assertSafeHostTaskId(taskId);
|
|
254
|
+
const path = aclPath(taskId);
|
|
255
|
+
rmSync(path, { force: true });
|
|
256
|
+
try {
|
|
257
|
+
lstatSync(path);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
throw new Error("task gateway ACL remains after removal");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function audit(entry: Record<string, unknown>): void {
|
|
266
|
+
try {
|
|
267
|
+
const dir = resolve(env.dataDir, "logs");
|
|
268
|
+
mkdirSync(dir, { recursive: true });
|
|
269
|
+
appendFileSync(
|
|
270
|
+
resolve(dir, "mcp-audit.jsonl"),
|
|
271
|
+
`${JSON.stringify({ ts: Date.now(), ...entry })}\n`,
|
|
272
|
+
);
|
|
273
|
+
} catch {
|
|
274
|
+
// audit is best-effort
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function deny(res: ServerResponse, status: number, message: string): void {
|
|
279
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
280
|
+
res.end(JSON.stringify({ error: message }));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The request's body, or null past MAX_BODY_BYTES. With `signal`, an abort
|
|
284
|
+
* ends the request where it is (the read rejects): a task that stops
|
|
285
|
+
* sending mid-body never holds what waits for its request to end. */
|
|
286
|
+
async function readBody(req: IncomingMessage, signal?: AbortSignal): Promise<Buffer | null> {
|
|
287
|
+
const stop = (): void => {
|
|
288
|
+
req.destroy(new Error("request ended"));
|
|
289
|
+
};
|
|
290
|
+
if (signal?.aborted) stop();
|
|
291
|
+
else signal?.addEventListener("abort", stop, { once: true });
|
|
292
|
+
try {
|
|
293
|
+
const chunks: Buffer[] = [];
|
|
294
|
+
let size = 0;
|
|
295
|
+
for await (const chunk of req) {
|
|
296
|
+
const buf = chunk as Buffer;
|
|
297
|
+
size += buf.length;
|
|
298
|
+
if (size > MAX_BODY_BYTES) return null;
|
|
299
|
+
chunks.push(buf);
|
|
300
|
+
}
|
|
301
|
+
return Buffer.concat(chunks);
|
|
302
|
+
} finally {
|
|
303
|
+
signal?.removeEventListener("abort", stop);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** JSON-RPC method (+ tool name for tools/call) out of a request body. */
|
|
308
|
+
function rpcSummary(body: Buffer): { method?: string; tool?: string } {
|
|
309
|
+
try {
|
|
310
|
+
const json = JSON.parse(body.toString("utf8")) as {
|
|
311
|
+
method?: string;
|
|
312
|
+
params?: { name?: string };
|
|
313
|
+
};
|
|
314
|
+
return {
|
|
315
|
+
method: typeof json.method === "string" ? json.method : undefined,
|
|
316
|
+
tool:
|
|
317
|
+
json.method === "tools/call" && typeof json.params?.name === "string"
|
|
318
|
+
? json.params.name
|
|
319
|
+
: undefined,
|
|
320
|
+
};
|
|
321
|
+
} catch {
|
|
322
|
+
return {};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const FORWARD_REQ_HEADERS = [
|
|
327
|
+
"content-type",
|
|
328
|
+
"accept",
|
|
329
|
+
"mcp-session-id",
|
|
330
|
+
"last-event-id",
|
|
331
|
+
"mcp-protocol-version",
|
|
332
|
+
];
|
|
333
|
+
const FORWARD_RES_HEADERS = ["content-type", "mcp-session-id"];
|
|
334
|
+
|
|
335
|
+
export interface AuthenticatedTaskGatewayConnection
|
|
336
|
+
extends TaskForwarderIdentity {
|
|
337
|
+
readonly socket: Socket;
|
|
338
|
+
/** Re-check immediately before using task authority. */
|
|
339
|
+
readonly isCurrent: () => boolean;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export type TaskGatewayTlsHandler = (
|
|
343
|
+
connection: AuthenticatedTaskGatewayConnection,
|
|
344
|
+
) => void;
|
|
345
|
+
|
|
346
|
+
let taskGatewayTlsHandler: TaskGatewayTlsHandler | null = null;
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Register the TLS consumer used by the ADR-153 secrets proxy. There is one
|
|
350
|
+
* gateway and therefore one consumer; a second registration is a wiring bug.
|
|
351
|
+
*/
|
|
352
|
+
export function registerTaskGatewayTlsHandler(
|
|
353
|
+
handler: TaskGatewayTlsHandler,
|
|
354
|
+
): () => void {
|
|
355
|
+
if (taskGatewayTlsHandler !== null) {
|
|
356
|
+
throw new Error("task gateway TLS handler is already registered");
|
|
357
|
+
}
|
|
358
|
+
taskGatewayTlsHandler = handler;
|
|
359
|
+
return () => {
|
|
360
|
+
if (taskGatewayTlsHandler === handler) taskGatewayTlsHandler = null;
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function loopbackPeer(socket: Socket): boolean {
|
|
365
|
+
const address = socket.remoteAddress ?? "";
|
|
366
|
+
return (
|
|
367
|
+
address === "::1" ||
|
|
368
|
+
/^127(?:\.\d{1,3}){3}$/.test(address) ||
|
|
369
|
+
/^::ffff:127(?:\.\d{1,3}){3}$/i.test(address)
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function handleEnvRequest(
|
|
374
|
+
req: IncomingMessage,
|
|
375
|
+
res: ServerResponse,
|
|
376
|
+
identity: TaskForwarderIdentity | null,
|
|
377
|
+
): Promise<void> {
|
|
378
|
+
// Keep the task hook's deliberately tiny response grammar stable. Node adds
|
|
379
|
+
// Date by default, but the response is local, non-cacheable, and has no use
|
|
380
|
+
// for a wall-clock header.
|
|
381
|
+
res.sendDate = false;
|
|
382
|
+
if (identity === null) return deny(res, 404, "not found");
|
|
383
|
+
if (req.method !== "GET") {
|
|
384
|
+
res.setHeader("allow", "GET");
|
|
385
|
+
return deny(res, 405, "method not allowed");
|
|
386
|
+
}
|
|
387
|
+
const snapshot = resolveTaskGatewayEnv(identity);
|
|
388
|
+
if (snapshot === null) return deny(res, 503, "task env unavailable");
|
|
389
|
+
const headers = {
|
|
390
|
+
etag: snapshot.etag,
|
|
391
|
+
"cache-control": "no-store",
|
|
392
|
+
};
|
|
393
|
+
if (req.headers["if-none-match"] === snapshot.etag) {
|
|
394
|
+
res.writeHead(304, { ...headers, "content-length": "0" });
|
|
395
|
+
res.end();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
res.writeHead(200, {
|
|
399
|
+
...headers,
|
|
400
|
+
"content-type": "text/plain; charset=utf-8",
|
|
401
|
+
"content-length": String(snapshot.body.length),
|
|
402
|
+
});
|
|
403
|
+
res.end(snapshot.body);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function handleMcpRequest(
|
|
407
|
+
req: IncomingMessage,
|
|
408
|
+
res: ServerResponse,
|
|
409
|
+
claimTaskQuota: (socket: Socket, taskId: string) => boolean,
|
|
410
|
+
): Promise<void> {
|
|
411
|
+
const match = /^\/t\/([^/]+)\/([^/?]+)\/?(?:\?.*)?$/.exec(req.url ?? "");
|
|
412
|
+
if (!match) return deny(res, 404, "not found");
|
|
413
|
+
const [, token, slug] = match as unknown as [string, string, string];
|
|
414
|
+
|
|
415
|
+
const taskId = token.split(".")[0] ?? "";
|
|
416
|
+
const acl = readAcl(taskId);
|
|
417
|
+
const expected = acl ? Buffer.from(acl.token) : null;
|
|
418
|
+
const provided = Buffer.from(token);
|
|
419
|
+
if (
|
|
420
|
+
!acl ||
|
|
421
|
+
!expected ||
|
|
422
|
+
expected.length !== provided.length ||
|
|
423
|
+
!timingSafeEqual(expected, provided)
|
|
424
|
+
) {
|
|
425
|
+
return deny(res, 401, "unknown task token");
|
|
426
|
+
}
|
|
427
|
+
// A direct connection is admitted only to an evictable peer staging pool.
|
|
428
|
+
// Once its unguessable ACL token proves the task, move the close-scoped
|
|
429
|
+
// reservation into that task's quota before using any task authority. An
|
|
430
|
+
// authenticated forwarder is already in this quota, and must name the same
|
|
431
|
+
// task if it sends an MCP request over its connection.
|
|
432
|
+
if (!claimTaskQuota(req.socket, taskId)) {
|
|
433
|
+
res.shouldKeepAlive = false;
|
|
434
|
+
res.setHeader("connection", "close");
|
|
435
|
+
return deny(res, 429, "task connection limit reached");
|
|
436
|
+
}
|
|
437
|
+
// ADR-154 §11: a task on cloud credentials is served from its lease.
|
|
438
|
+
const lease = leasedMcpFor(taskId);
|
|
439
|
+
if (lease || leaseOnly) {
|
|
440
|
+
if (!lease?.open) return deny(res, 503, "credentials unavailable");
|
|
441
|
+
const leased = lease.connections.get(slug);
|
|
442
|
+
if (!leased) return deny(res, 404, `no connection "${slug}" for this task`);
|
|
443
|
+
// Named but unreadable: unavailable, never "no such connection".
|
|
444
|
+
const hold = "unavailable" in leased ? null : holdLeasedMcp(taskId, slug);
|
|
445
|
+
if (!hold) return deny(res, 503, "credentials unavailable");
|
|
446
|
+
return serveLeasedConnection(req, res, taskId, hold);
|
|
447
|
+
}
|
|
448
|
+
// ADR-154: the runner's own connections only while it is on its own
|
|
449
|
+
// credentials; from the moment it begins leaving them, none is read.
|
|
450
|
+
if (!onOwnCredentials()) return deny(res, 503, "credentials unavailable");
|
|
451
|
+
const entry = acl.connections.find((c) => c.slug === slug);
|
|
452
|
+
if (!entry) return deny(res, 404, `no connection "${slug}" for this task`);
|
|
453
|
+
const conn = getConnection(entry.id);
|
|
454
|
+
if (!conn || conn.status !== "connected") {
|
|
455
|
+
// Disconnected since the task started — the kill switch answering.
|
|
456
|
+
return deny(res, 401, `connection "${slug}" is no longer available`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const body =
|
|
460
|
+
req.method === "POST" || req.method === "PUT" ? await readBody(req) : null;
|
|
461
|
+
if ((req.method === "POST" || req.method === "PUT") && body === null) {
|
|
462
|
+
return deny(res, 413, "request body too large");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const headers: Record<string, string> = {};
|
|
466
|
+
for (const name of FORWARD_REQ_HEADERS) {
|
|
467
|
+
const v = req.headers[name];
|
|
468
|
+
if (typeof v === "string") headers[name] = v;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// The controller lives for the whole exchange: the timer only guards the
|
|
472
|
+
// connect phase (cleared once headers land), and a client hang-up reaps the
|
|
473
|
+
// upstream so dead streams don't accumulate.
|
|
474
|
+
const controller = new AbortController();
|
|
475
|
+
res.on("close", () => controller.abort());
|
|
476
|
+
|
|
477
|
+
const attempt = async (forceRefresh: boolean): Promise<Response> => {
|
|
478
|
+
// Counted (leaving waits for a refresh under way), and never a local
|
|
479
|
+
// header sent once leaving has begun, however long the read took.
|
|
480
|
+
const reading = whileOnOwnCredentials(() => authHeaderFor(conn, forceRefresh));
|
|
481
|
+
if (reading === null) throw new LeftOwnCredentialsError();
|
|
482
|
+
const auth = await reading;
|
|
483
|
+
if (!onOwnCredentials()) throw new LeftOwnCredentialsError();
|
|
484
|
+
if (auth) headers[auth.name] = auth.value;
|
|
485
|
+
const connectTimer = setTimeout(
|
|
486
|
+
() => controller.abort(),
|
|
487
|
+
UPSTREAM_CONNECT_TIMEOUT_MS,
|
|
488
|
+
);
|
|
489
|
+
try {
|
|
490
|
+
return await fetch(conn.url, {
|
|
491
|
+
method: req.method,
|
|
492
|
+
headers,
|
|
493
|
+
body: body ?? undefined,
|
|
494
|
+
signal: controller.signal,
|
|
495
|
+
});
|
|
496
|
+
} finally {
|
|
497
|
+
clearTimeout(connectTimer);
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
let upstream: Response;
|
|
502
|
+
try {
|
|
503
|
+
upstream = await attempt(false);
|
|
504
|
+
if (upstream.status === 401 && conn.authKind === "oauth") {
|
|
505
|
+
upstream = await attempt(true);
|
|
506
|
+
}
|
|
507
|
+
} catch (err) {
|
|
508
|
+
if (err instanceof LeftOwnCredentialsError) return deny(res, 503, "credentials unavailable");
|
|
509
|
+
audit({ taskId, slug, method: req.method, error: String(err) });
|
|
510
|
+
return deny(res, 502, "upstream unreachable");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const summary = body ? rpcSummary(body) : {};
|
|
514
|
+
audit({
|
|
515
|
+
taskId,
|
|
516
|
+
slug,
|
|
517
|
+
method: req.method,
|
|
518
|
+
rpc: summary.method,
|
|
519
|
+
tool: summary.tool,
|
|
520
|
+
status: upstream.status,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const resHeaders: Record<string, string> = {};
|
|
524
|
+
for (const name of FORWARD_RES_HEADERS) {
|
|
525
|
+
const v = upstream.headers.get(name);
|
|
526
|
+
if (v) resHeaders[name] = v;
|
|
527
|
+
}
|
|
528
|
+
res.writeHead(upstream.status, resHeaders);
|
|
529
|
+
if (upstream.body) {
|
|
530
|
+
// pipeline (NOT .pipe) so a mid-stream error — client gone, upstream
|
|
531
|
+
// reset, abort — tears both ends down instead of crashing the process
|
|
532
|
+
// as an unhandled Readable 'error'.
|
|
533
|
+
pipeline(
|
|
534
|
+
Readable.fromWeb(upstream.body as Parameters<typeof Readable.fromWeb>[0]),
|
|
535
|
+
res,
|
|
536
|
+
(err) => {
|
|
537
|
+
if (err && err.name !== "AbortError") {
|
|
538
|
+
audit({ taskId, slug, stream: "ended", error: err.name });
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
} else {
|
|
543
|
+
res.end();
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** ADR-154 §13: a runner past its credentials floor serves MCP only from
|
|
548
|
+
* leases; a task without one is answered 503, never from the local store. */
|
|
549
|
+
let leaseOnly = false;
|
|
550
|
+
|
|
551
|
+
export function setMcpGatewayLeaseOnly(on: boolean): void {
|
|
552
|
+
leaseOnly = on;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Tests only: the SSRF rule's injectable resolver and trust roots. */
|
|
556
|
+
let leasedSafeHttpDeps: SafeHttpDeps = {};
|
|
557
|
+
export function setLeasedMcpSafeHttpDepsForTests(deps: SafeHttpDeps): void {
|
|
558
|
+
leasedSafeHttpDeps = deps;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const LEASED_METHODS = new Set(["GET", "POST", "DELETE"]);
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* One request for a leased connection: attach its header, send it under the
|
|
565
|
+
* SSRF rule, and stream the answer back. The request is held on its task: the
|
|
566
|
+
* gate closing, a cleanup, or the task hanging up ends it wherever it is,
|
|
567
|
+
* connecting, waiting for headers or streaming. An OAuth token past its
|
|
568
|
+
* expiry is refreshed first and never sent; a 401 is retried once, only with
|
|
569
|
+
* a newer token for the very connection the request began with.
|
|
570
|
+
*/
|
|
571
|
+
async function serveLeasedConnection(
|
|
572
|
+
req: IncomingMessage,
|
|
573
|
+
res: ServerResponse,
|
|
574
|
+
taskId: string,
|
|
575
|
+
hold: McpRequestHold,
|
|
576
|
+
): Promise<void> {
|
|
577
|
+
const slug = hold.connection.slug;
|
|
578
|
+
// From the start: a hang-up ends the request before headers too.
|
|
579
|
+
const client = new AbortController();
|
|
580
|
+
res.once("close", () => {
|
|
581
|
+
client.abort();
|
|
582
|
+
hold.end();
|
|
583
|
+
});
|
|
584
|
+
const signal = AbortSignal.any([hold.signal, client.signal]);
|
|
585
|
+
const method = req.method ?? "GET";
|
|
586
|
+
if (!LEASED_METHODS.has(method)) return deny(res, 405, "method not allowed");
|
|
587
|
+
let body: Buffer | null = null;
|
|
588
|
+
if (method === "POST") {
|
|
589
|
+
try {
|
|
590
|
+
body = await readBody(req, signal);
|
|
591
|
+
} catch {
|
|
592
|
+
// Ended before its body arrived: the gate closed, or the task went.
|
|
593
|
+
if (!res.destroyed && !res.headersSent) deny(res, 503, "credentials unavailable");
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (body === null) return deny(res, 413, "request body too large");
|
|
597
|
+
}
|
|
598
|
+
const forwarded: Record<string, string> = {};
|
|
599
|
+
for (const name of FORWARD_REQ_HEADERS) {
|
|
600
|
+
const v = req.headers[name];
|
|
601
|
+
if (typeof v === "string") forwarded[name] = v;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
let connection = hold.current();
|
|
605
|
+
if (!connection) return deny(res, 503, "credentials unavailable");
|
|
606
|
+
if (connection.authKind === "oauth" && connection.expiresAt !== null && connection.expiresAt <= Date.now()) {
|
|
607
|
+
connection = await hold.refresh(connection);
|
|
608
|
+
if (!connection) return deny(res, 503, "credentials unavailable");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const attempt = (current: LeasedMcpConnection): Promise<SafeHttpResponse> =>
|
|
612
|
+
safeRequest(
|
|
613
|
+
{
|
|
614
|
+
url: current.url,
|
|
615
|
+
method: method as "GET" | "POST" | "DELETE",
|
|
616
|
+
headers: current.header ? { ...forwarded, [current.header.name]: current.header.value } : forwarded,
|
|
617
|
+
...(body ? { body } : {}),
|
|
618
|
+
signal,
|
|
619
|
+
},
|
|
620
|
+
undefined,
|
|
621
|
+
leasedSafeHttpDeps,
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
let upstream: SafeHttpResponse;
|
|
625
|
+
try {
|
|
626
|
+
upstream = await attempt(connection);
|
|
627
|
+
if (upstream.status === 401 && connection.authKind === "oauth") {
|
|
628
|
+
upstream.body.destroy();
|
|
629
|
+
const refreshed = await hold.refresh(connection);
|
|
630
|
+
if (!refreshed) return deny(res, 401, `connection "${slug}" needs a new sign-in`);
|
|
631
|
+
connection = refreshed;
|
|
632
|
+
upstream = await attempt(connection);
|
|
633
|
+
}
|
|
634
|
+
} catch (err) {
|
|
635
|
+
if (signal.aborted) return deny(res, 503, "credentials unavailable");
|
|
636
|
+
audit({ taskId, slug, method, error: err instanceof Error ? err.message : String(err) });
|
|
637
|
+
return deny(res, 502, "upstream unreachable");
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const summary = body ? rpcSummary(body) : {};
|
|
641
|
+
audit({ taskId, slug, method, rpc: summary.method, tool: summary.tool, status: upstream.status });
|
|
642
|
+
const resHeaders: Record<string, string> = {};
|
|
643
|
+
for (const name of FORWARD_RES_HEADERS) {
|
|
644
|
+
const v = upstream.headers[name];
|
|
645
|
+
if (typeof v === "string") resHeaders[name] = v;
|
|
646
|
+
}
|
|
647
|
+
res.writeHead(upstream.status, resHeaders);
|
|
648
|
+
// A stream ends with its task's gate or its client: the signal destroys the
|
|
649
|
+
// upstream body, and the pipeline tears down both ends.
|
|
650
|
+
pipeline(upstream.body, res, (err) => {
|
|
651
|
+
if (err && err.name !== "AbortError" && !signal.aborted) {
|
|
652
|
+
audit({ taskId, slug, stream: "ended", error: err.name });
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function handleGatewayRequest(
|
|
658
|
+
req: IncomingMessage,
|
|
659
|
+
res: ServerResponse,
|
|
660
|
+
authenticatedSockets: WeakMap<Socket, TaskForwarderIdentity>,
|
|
661
|
+
claimTaskQuota: (socket: Socket, taskId: string) => boolean,
|
|
662
|
+
): Promise<void> {
|
|
663
|
+
const pathname = (() => {
|
|
664
|
+
try {
|
|
665
|
+
return new URL(req.url ?? "", "http://uai.invalid").pathname;
|
|
666
|
+
} catch {
|
|
667
|
+
return "";
|
|
668
|
+
}
|
|
669
|
+
})();
|
|
670
|
+
if (pathname === "/env" && req.url === "/env") {
|
|
671
|
+
const socket = req.socket;
|
|
672
|
+
let identity = authenticatedSockets.get(socket) ?? null;
|
|
673
|
+
if (identity === null) {
|
|
674
|
+
const fixedTaskId = sidecarTaskId();
|
|
675
|
+
if (fixedTaskId !== null && loopbackPeer(socket)) {
|
|
676
|
+
identity = currentTaskGatewayRegistration(fixedTaskId);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
await handleEnvRequest(req, res, identity);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
await handleMcpRequest(req, res, claimTaskQuota);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
type GatewayTransportServer = NetServer & {
|
|
686
|
+
closeAllConnections?: () => void;
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
function readSocketBytes(
|
|
690
|
+
socket: Socket,
|
|
691
|
+
length: number,
|
|
692
|
+
deadline: number,
|
|
693
|
+
): Promise<Buffer> {
|
|
694
|
+
return new Promise((resolveRead, rejectRead) => {
|
|
695
|
+
let settled = false;
|
|
696
|
+
const finish = (error: Error | null, bytes?: Buffer): void => {
|
|
697
|
+
if (settled) return;
|
|
698
|
+
settled = true;
|
|
699
|
+
clearTimeout(timer);
|
|
700
|
+
socket.off("readable", onReadable);
|
|
701
|
+
socket.off("error", onError);
|
|
702
|
+
socket.off("end", onEnd);
|
|
703
|
+
socket.off("close", onClose);
|
|
704
|
+
if (error) rejectRead(error);
|
|
705
|
+
else if (bytes) resolveRead(bytes);
|
|
706
|
+
else rejectRead(new Error("task gateway stream ended"));
|
|
707
|
+
};
|
|
708
|
+
const tryRead = (): void => {
|
|
709
|
+
const bytes = socket.read(length) as Buffer | null;
|
|
710
|
+
if (bytes === null) return;
|
|
711
|
+
if (bytes.length !== length) {
|
|
712
|
+
finish(new Error("truncated task gateway frame"));
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
finish(null, bytes);
|
|
716
|
+
};
|
|
717
|
+
const onReadable = (): void => tryRead();
|
|
718
|
+
const onError = (): void => finish(new Error("task gateway socket error"));
|
|
719
|
+
const onEnd = (): void => {
|
|
720
|
+
tryRead();
|
|
721
|
+
if (!settled) finish(new Error("truncated task gateway frame"));
|
|
722
|
+
};
|
|
723
|
+
const onClose = (): void => finish(new Error("task gateway socket closed"));
|
|
724
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
725
|
+
const timer = setTimeout(
|
|
726
|
+
() => finish(new Error("task gateway pre-auth timed out")),
|
|
727
|
+
remainingMs,
|
|
728
|
+
);
|
|
729
|
+
timer.unref();
|
|
730
|
+
socket.on("readable", onReadable);
|
|
731
|
+
socket.once("error", onError);
|
|
732
|
+
socket.once("end", onEnd);
|
|
733
|
+
socket.once("close", onClose);
|
|
734
|
+
tryRead();
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
interface TaskGatewayDemuxOptions {
|
|
739
|
+
readonly httpServer: HttpServer;
|
|
740
|
+
readonly authenticatedSockets: WeakMap<Socket, TaskForwarderIdentity>;
|
|
741
|
+
readonly authenticatedConnections: Map<Socket, TaskForwarderIdentity>;
|
|
742
|
+
readonly preauthTimeoutMs: number;
|
|
743
|
+
readonly maxPendingPreauth: number;
|
|
744
|
+
readonly maxPendingPreauthPerPeer: number;
|
|
745
|
+
readonly maxPendingPreauthPerTask: number;
|
|
746
|
+
readonly maxActiveConnections: number;
|
|
747
|
+
readonly maxDirectStagingConnectionsPerPeer: number;
|
|
748
|
+
readonly maxActiveConnectionsPerTask: number;
|
|
749
|
+
readonly nonce: () => Buffer;
|
|
750
|
+
readonly tlsHandler: () => TaskGatewayTlsHandler | null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
interface AuthenticatedTaskForwarder {
|
|
754
|
+
readonly identity: TaskForwarderIdentity;
|
|
755
|
+
/** Release the task-scoped pre-auth reservation exactly once. */
|
|
756
|
+
readonly release: () => void;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function createTaskGatewayDemux(options: TaskGatewayDemuxOptions): {
|
|
760
|
+
accept: (socket: Socket) => void;
|
|
761
|
+
claimTaskQuota: (socket: Socket, taskId: string) => boolean;
|
|
762
|
+
} {
|
|
763
|
+
let pendingPreauth = 0;
|
|
764
|
+
const pendingPeerBySocket = new Map<Socket, string>();
|
|
765
|
+
const pendingSocketsByPeer = new Map<string, Set<Socket>>();
|
|
766
|
+
const pendingByTask = new Map<string, number>();
|
|
767
|
+
let activeConnections = 0;
|
|
768
|
+
const activeScopeBySocket = new Map<
|
|
769
|
+
Socket,
|
|
770
|
+
{ kind: "peer" | "task"; key: string }
|
|
771
|
+
>();
|
|
772
|
+
const activeSocketsByPeer = new Map<string, Set<Socket>>();
|
|
773
|
+
const activeSocketsByTask = new Map<string, Set<Socket>>();
|
|
774
|
+
const activeUnauthenticatedSockets = new Set<Socket>();
|
|
775
|
+
|
|
776
|
+
const peerKey = (socket: Socket): string => {
|
|
777
|
+
if (loopbackPeer(socket)) return "loopback";
|
|
778
|
+
const address = (socket.remoteAddress ?? "unknown").toLowerCase();
|
|
779
|
+
return address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
const releasePreauth = (socket: Socket): void => {
|
|
783
|
+
const peer = pendingPeerBySocket.get(socket);
|
|
784
|
+
if (peer === undefined) return;
|
|
785
|
+
pendingPeerBySocket.delete(socket);
|
|
786
|
+
pendingPreauth -= 1;
|
|
787
|
+
const peerSockets = pendingSocketsByPeer.get(peer);
|
|
788
|
+
peerSockets?.delete(socket);
|
|
789
|
+
if (peerSockets?.size === 0) pendingSocketsByPeer.delete(peer);
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
const reservePreauth = (socket: Socket, peer: string): void => {
|
|
793
|
+
pendingPreauth += 1;
|
|
794
|
+
pendingPeerBySocket.set(socket, peer);
|
|
795
|
+
const peerSockets = pendingSocketsByPeer.get(peer) ?? new Set<Socket>();
|
|
796
|
+
peerSockets.add(socket);
|
|
797
|
+
pendingSocketsByPeer.set(peer, peerSockets);
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
const releaseActive = (socket: Socket): void => {
|
|
801
|
+
const scope = activeScopeBySocket.get(socket);
|
|
802
|
+
if (scope === undefined) return;
|
|
803
|
+
activeScopeBySocket.delete(socket);
|
|
804
|
+
activeConnections -= 1;
|
|
805
|
+
const scopedSockets =
|
|
806
|
+
scope.kind === "task"
|
|
807
|
+
? activeSocketsByTask.get(scope.key)
|
|
808
|
+
: activeSocketsByPeer.get(scope.key);
|
|
809
|
+
scopedSockets?.delete(socket);
|
|
810
|
+
if (scope.kind === "peer") activeUnauthenticatedSockets.delete(socket);
|
|
811
|
+
if (scopedSockets?.size === 0) {
|
|
812
|
+
if (scope.kind === "task") activeSocketsByTask.delete(scope.key);
|
|
813
|
+
else activeSocketsByPeer.delete(scope.key);
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
const evictUnauthenticated = (socket: Socket): void => {
|
|
818
|
+
// Release synchronously: Socket's close event is asynchronous and the
|
|
819
|
+
// replacement must be able to reserve the slot in this turn.
|
|
820
|
+
releaseActive(socket);
|
|
821
|
+
socket.destroy();
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
/** Transfer one fully-demultiplexed socket into a close-scoped quota.
|
|
825
|
+
* Forwarder-authenticated traffic and fixed-sidecar traffic are charged to
|
|
826
|
+
* their proved task. Direct HTTP starts in an evictable peer staging pool;
|
|
827
|
+
* a valid MCP token moves it to the non-evictable task pool below. */
|
|
828
|
+
const reserveActive = (
|
|
829
|
+
socket: Socket,
|
|
830
|
+
scope: { kind: "peer" | "task"; key: string },
|
|
831
|
+
): boolean => {
|
|
832
|
+
if (activeScopeBySocket.has(socket)) return true;
|
|
833
|
+
const scopedSockets =
|
|
834
|
+
scope.kind === "task"
|
|
835
|
+
? activeSocketsByTask.get(scope.key)
|
|
836
|
+
: activeSocketsByPeer.get(scope.key);
|
|
837
|
+
const scopedLimit =
|
|
838
|
+
scope.kind === "task"
|
|
839
|
+
? options.maxActiveConnectionsPerTask
|
|
840
|
+
: options.maxDirectStagingConnectionsPerPeer;
|
|
841
|
+
if ((scopedSockets?.size ?? 0) >= scopedLimit) {
|
|
842
|
+
if (scope.kind === "task") return false;
|
|
843
|
+
const oldest = scopedSockets?.values().next().value as Socket | undefined;
|
|
844
|
+
if (oldest) evictUnauthenticated(oldest);
|
|
845
|
+
}
|
|
846
|
+
if (activeConnections >= options.maxActiveConnections) {
|
|
847
|
+
// Never evict a proved task socket: it may be a long-lived MCP SSE stream
|
|
848
|
+
// or secrets-proxy tunnel. Unproved direct traffic is replaceable, which
|
|
849
|
+
// keeps a handful of peers from filling the global pool permanently.
|
|
850
|
+
const oldest = activeUnauthenticatedSockets.values().next().value as
|
|
851
|
+
| Socket
|
|
852
|
+
| undefined;
|
|
853
|
+
if (!oldest) return false;
|
|
854
|
+
evictUnauthenticated(oldest);
|
|
855
|
+
}
|
|
856
|
+
const currentScopedSockets =
|
|
857
|
+
scope.kind === "task"
|
|
858
|
+
? activeSocketsByTask.get(scope.key)
|
|
859
|
+
: activeSocketsByPeer.get(scope.key);
|
|
860
|
+
const nextScopedSockets = currentScopedSockets ?? new Set<Socket>();
|
|
861
|
+
nextScopedSockets.add(socket);
|
|
862
|
+
if (scope.kind === "task") {
|
|
863
|
+
activeSocketsByTask.set(scope.key, nextScopedSockets);
|
|
864
|
+
} else {
|
|
865
|
+
activeSocketsByPeer.set(scope.key, nextScopedSockets);
|
|
866
|
+
activeUnauthenticatedSockets.add(socket);
|
|
867
|
+
}
|
|
868
|
+
activeScopeBySocket.set(socket, scope);
|
|
869
|
+
activeConnections += 1;
|
|
870
|
+
socket.once("close", () => releaseActive(socket));
|
|
871
|
+
return true;
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
const claimTaskQuota = (socket: Socket, taskId: string): boolean => {
|
|
875
|
+
const current = activeScopeBySocket.get(socket);
|
|
876
|
+
if (current === undefined) return false;
|
|
877
|
+
if (current.kind === "task") return current.key === taskId;
|
|
878
|
+
|
|
879
|
+
const taskSockets = activeSocketsByTask.get(taskId);
|
|
880
|
+
if (
|
|
881
|
+
(taskSockets?.size ?? 0) >= options.maxActiveConnectionsPerTask
|
|
882
|
+
) {
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const peerSockets = activeSocketsByPeer.get(current.key);
|
|
887
|
+
peerSockets?.delete(socket);
|
|
888
|
+
if (peerSockets?.size === 0) activeSocketsByPeer.delete(current.key);
|
|
889
|
+
activeUnauthenticatedSockets.delete(socket);
|
|
890
|
+
|
|
891
|
+
const nextTaskSockets = taskSockets ?? new Set<Socket>();
|
|
892
|
+
nextTaskSockets.add(socket);
|
|
893
|
+
activeSocketsByTask.set(taskId, nextTaskSockets);
|
|
894
|
+
activeScopeBySocket.set(socket, { kind: "task", key: taskId });
|
|
895
|
+
return true;
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
const rememberAuthenticated = (
|
|
899
|
+
socket: Socket,
|
|
900
|
+
identity: TaskForwarderIdentity,
|
|
901
|
+
): void => {
|
|
902
|
+
if (options.authenticatedConnections.has(socket)) return;
|
|
903
|
+
options.authenticatedSockets.set(socket, identity);
|
|
904
|
+
options.authenticatedConnections.set(socket, identity);
|
|
905
|
+
socket.once("close", () => {
|
|
906
|
+
options.authenticatedConnections.delete(socket);
|
|
907
|
+
});
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
const dispatchHttp = (
|
|
911
|
+
socket: Socket,
|
|
912
|
+
firstByte: Buffer,
|
|
913
|
+
identity: TaskForwarderIdentity | null,
|
|
914
|
+
activeScope: { kind: "peer" | "task"; key: string },
|
|
915
|
+
): void => {
|
|
916
|
+
if (!reserveActive(socket, activeScope)) {
|
|
917
|
+
socket.destroy();
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
if (identity) rememberAuthenticated(socket, identity);
|
|
921
|
+
socket.unshift(firstByte);
|
|
922
|
+
options.httpServer.emit("connection", socket);
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
const dispatchTls = (
|
|
926
|
+
socket: Socket,
|
|
927
|
+
firstByte: Buffer,
|
|
928
|
+
identity: TaskForwarderIdentity,
|
|
929
|
+
): void => {
|
|
930
|
+
const handler = options.tlsHandler();
|
|
931
|
+
if (
|
|
932
|
+
!handler ||
|
|
933
|
+
!isTaskGatewayGenerationCurrent(identity) ||
|
|
934
|
+
!reserveActive(socket, { kind: "task", key: identity.taskId })
|
|
935
|
+
) {
|
|
936
|
+
socket.destroy();
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
rememberAuthenticated(socket, identity);
|
|
940
|
+
socket.unshift(firstByte);
|
|
941
|
+
try {
|
|
942
|
+
handler({
|
|
943
|
+
...identity,
|
|
944
|
+
socket,
|
|
945
|
+
isCurrent: () => isTaskGatewayGenerationCurrent(identity),
|
|
946
|
+
});
|
|
947
|
+
} catch {
|
|
948
|
+
socket.destroy();
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
const authenticate = async (
|
|
953
|
+
socket: Socket,
|
|
954
|
+
firstByte: Buffer,
|
|
955
|
+
deadline: number,
|
|
956
|
+
): Promise<AuthenticatedTaskForwarder | null> => {
|
|
957
|
+
const headerTail = await readSocketBytes(
|
|
958
|
+
socket,
|
|
959
|
+
TASK_FORWARDER_PREAMBLE_HEADER_BYTES - 1,
|
|
960
|
+
deadline,
|
|
961
|
+
);
|
|
962
|
+
const header = Buffer.concat([firstByte, headerTail]);
|
|
963
|
+
const preambleLength = taskForwarderPreambleLength(header);
|
|
964
|
+
if (
|
|
965
|
+
preambleLength === null ||
|
|
966
|
+
preambleLength > TASK_FORWARDER_MAX_PREAMBLE_BYTES
|
|
967
|
+
) {
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
const payload = await readSocketBytes(
|
|
971
|
+
socket,
|
|
972
|
+
preambleLength - header.length,
|
|
973
|
+
deadline,
|
|
974
|
+
);
|
|
975
|
+
const identity = decodeTaskForwarderPreamble(
|
|
976
|
+
Buffer.concat([header, payload], preambleLength),
|
|
977
|
+
);
|
|
978
|
+
if (identity === null || !isSafeHostTaskId(identity.taskId)) return null;
|
|
979
|
+
|
|
980
|
+
const nonce = options.nonce();
|
|
981
|
+
socket.write(encodeTaskForwarderChallenge(nonce));
|
|
982
|
+
const response = await readSocketBytes(
|
|
983
|
+
socket,
|
|
984
|
+
TASK_FORWARDER_RESPONSE_BYTES,
|
|
985
|
+
deadline,
|
|
986
|
+
);
|
|
987
|
+
const mac = decodeTaskForwarderResponse(response);
|
|
988
|
+
if (
|
|
989
|
+
mac === null ||
|
|
990
|
+
!verifyTaskGatewayAuthentication(identity, nonce, mac)
|
|
991
|
+
) {
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// A claimed task id is untrusted until its MAC verifies. Charging its
|
|
996
|
+
// reservation earlier lets a peer lock out a real task with only the
|
|
997
|
+
// public task id.
|
|
998
|
+
const taskPending = pendingByTask.get(identity.taskId) ?? 0;
|
|
999
|
+
if (taskPending >= options.maxPendingPreauthPerTask) return null;
|
|
1000
|
+
pendingByTask.set(identity.taskId, taskPending + 1);
|
|
1001
|
+
let released = false;
|
|
1002
|
+
const release = (): void => {
|
|
1003
|
+
if (released) return;
|
|
1004
|
+
released = true;
|
|
1005
|
+
const next = (pendingByTask.get(identity.taskId) ?? 1) - 1;
|
|
1006
|
+
if (next > 0) pendingByTask.set(identity.taskId, next);
|
|
1007
|
+
else pendingByTask.delete(identity.taskId);
|
|
1008
|
+
};
|
|
1009
|
+
if (!reserveActive(socket, { kind: "task", key: identity.taskId })) {
|
|
1010
|
+
release();
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
let retained = false;
|
|
1014
|
+
try {
|
|
1015
|
+
socket.write(TASK_FORWARDER_ACCEPT_MAGIC);
|
|
1016
|
+
retained = true;
|
|
1017
|
+
return { identity, release };
|
|
1018
|
+
} finally {
|
|
1019
|
+
// A synchronous write failure must not permanently consume this task's
|
|
1020
|
+
// post-MAC quotas. Once returned, the caller owns the pending release;
|
|
1021
|
+
// the active reservation remains close-scoped.
|
|
1022
|
+
if (!retained) {
|
|
1023
|
+
release();
|
|
1024
|
+
releaseActive(socket);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
return {
|
|
1030
|
+
claimTaskQuota,
|
|
1031
|
+
accept: (socket) => {
|
|
1032
|
+
socket.pause();
|
|
1033
|
+
socket.setNoDelay(true);
|
|
1034
|
+
const peer = peerKey(socket);
|
|
1035
|
+
const peerSockets = pendingSocketsByPeer.get(peer);
|
|
1036
|
+
if (
|
|
1037
|
+
peerSockets &&
|
|
1038
|
+
peerSockets.size >= options.maxPendingPreauthPerPeer
|
|
1039
|
+
) {
|
|
1040
|
+
// Prefer the newest connection instead of letting a peer reserve its
|
|
1041
|
+
// own entire quota with idle sockets. A real hook/forwarder sends its
|
|
1042
|
+
// first frame immediately, so replacing the oldest pending socket
|
|
1043
|
+
// preserves progress even when several task runtimes share one NATed
|
|
1044
|
+
// source address (notably desktop container VMs).
|
|
1045
|
+
const oldest = peerSockets.values().next().value as Socket | undefined;
|
|
1046
|
+
if (oldest) {
|
|
1047
|
+
releasePreauth(oldest);
|
|
1048
|
+
oldest.destroy();
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
if (pendingPreauth >= options.maxPendingPreauth) {
|
|
1052
|
+
// Pending sockets carry no authority and have a short deadline. Drop
|
|
1053
|
+
// the oldest globally so several peers cannot permanently consume all
|
|
1054
|
+
// admission slots before a real forwarder gets a turn.
|
|
1055
|
+
const oldest = pendingPeerBySocket.keys().next().value as
|
|
1056
|
+
| Socket
|
|
1057
|
+
| undefined;
|
|
1058
|
+
if (oldest) {
|
|
1059
|
+
releasePreauth(oldest);
|
|
1060
|
+
oldest.destroy();
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
reservePreauth(socket, peer);
|
|
1064
|
+
const deadline = Date.now() + options.preauthTimeoutMs;
|
|
1065
|
+
void (async () => {
|
|
1066
|
+
try {
|
|
1067
|
+
const firstByte = await readSocketBytes(socket, 1, deadline);
|
|
1068
|
+
if (firstByte[0] === 0x00) {
|
|
1069
|
+
const authenticated = await authenticate(socket, firstByte, deadline);
|
|
1070
|
+
if (authenticated === null) {
|
|
1071
|
+
socket.destroy();
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
const { identity } = authenticated;
|
|
1075
|
+
try {
|
|
1076
|
+
rememberAuthenticated(socket, identity);
|
|
1077
|
+
if (!isTaskGatewayGenerationCurrent(identity)) {
|
|
1078
|
+
socket.destroy();
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
// The forwarder does not relay task bytes until it receives the
|
|
1082
|
+
// accept frame, so this byte cannot be unauthenticated pipelining.
|
|
1083
|
+
const applicationByte = await readSocketBytes(socket, 1, deadline);
|
|
1084
|
+
if (applicationByte[0] === 0x16) {
|
|
1085
|
+
dispatchTls(socket, applicationByte, identity);
|
|
1086
|
+
} else {
|
|
1087
|
+
dispatchHttp(socket, applicationByte, identity, {
|
|
1088
|
+
kind: "task",
|
|
1089
|
+
key: identity.taskId,
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
} finally {
|
|
1093
|
+
authenticated.release();
|
|
1094
|
+
}
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const fixedTaskId = sidecarTaskId();
|
|
1099
|
+
const sidecarIdentity =
|
|
1100
|
+
fixedTaskId !== null && loopbackPeer(socket)
|
|
1101
|
+
? currentTaskGatewayRegistration(fixedTaskId)
|
|
1102
|
+
: null;
|
|
1103
|
+
if (firstByte[0] === 0x16) {
|
|
1104
|
+
// Direct TLS is valid only for the fixed one-task sidecar. Docker
|
|
1105
|
+
// and Apple must prove identity through the forwarder first.
|
|
1106
|
+
if (sidecarIdentity) dispatchTls(socket, firstByte, sidecarIdentity);
|
|
1107
|
+
else socket.destroy();
|
|
1108
|
+
} else {
|
|
1109
|
+
dispatchHttp(
|
|
1110
|
+
socket,
|
|
1111
|
+
firstByte,
|
|
1112
|
+
sidecarIdentity,
|
|
1113
|
+
sidecarIdentity
|
|
1114
|
+
? { kind: "task", key: sidecarIdentity.taskId }
|
|
1115
|
+
: { kind: "peer", key: peer },
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
} catch {
|
|
1119
|
+
socket.destroy();
|
|
1120
|
+
} finally {
|
|
1121
|
+
releasePreauth(socket);
|
|
1122
|
+
}
|
|
1123
|
+
})();
|
|
1124
|
+
},
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
export interface McpGatewayController {
|
|
1129
|
+
/** Current bounded capability snapshot. Never contains a raw listener error. */
|
|
1130
|
+
state(): McpGatewayCapability;
|
|
1131
|
+
/** Resolve only after this listener has positively bound its configured port. */
|
|
1132
|
+
waitUntilReady(timeoutMs?: number): Promise<void>;
|
|
1133
|
+
/** Subscribe to semantic state changes. The current snapshot is read via state(). */
|
|
1134
|
+
subscribe(
|
|
1135
|
+
listener: (state: McpGatewayCapability) => void,
|
|
1136
|
+
): () => void;
|
|
1137
|
+
/** Permanently stop this controller and release its listener/timer. */
|
|
1138
|
+
stop(): Promise<void>;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
export interface McpGatewayListener extends McpGatewayController {
|
|
1142
|
+
/** Start once; repeated calls before stop are idempotent. */
|
|
1143
|
+
start(): McpGatewayController;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
export interface McpGatewayListenerOptions {
|
|
1147
|
+
port?: number;
|
|
1148
|
+
bind?: string;
|
|
1149
|
+
retryInitialDelayMs?: number;
|
|
1150
|
+
retryMaxDelayMs?: number;
|
|
1151
|
+
serverFactory?: (
|
|
1152
|
+
connectionListener: (socket: Socket) => void,
|
|
1153
|
+
) => GatewayTransportServer;
|
|
1154
|
+
httpServerFactory?: (
|
|
1155
|
+
requestListener: (req: IncomingMessage, res: ServerResponse) => void,
|
|
1156
|
+
) => HttpServer;
|
|
1157
|
+
tlsHandler?: TaskGatewayTlsHandler;
|
|
1158
|
+
preauthTimeoutMs?: number;
|
|
1159
|
+
maxPendingPreauth?: number;
|
|
1160
|
+
maxPendingPreauthPerPeer?: number;
|
|
1161
|
+
maxPendingPreauthPerTask?: number;
|
|
1162
|
+
maxActiveConnections?: number;
|
|
1163
|
+
maxDirectStagingConnectionsPerPeer?: number;
|
|
1164
|
+
maxActiveConnectionsPerTask?: number;
|
|
1165
|
+
nonce?: () => Buffer;
|
|
1166
|
+
setRetryTimeout?: (
|
|
1167
|
+
callback: () => void,
|
|
1168
|
+
delayMs: number,
|
|
1169
|
+
) => NodeJS.Timeout;
|
|
1170
|
+
clearRetryTimeout?: (timer: NodeJS.Timeout) => void;
|
|
1171
|
+
random?: () => number;
|
|
1172
|
+
logger?: Pick<Console, "log" | "warn">;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function listenerFailureReason(
|
|
1176
|
+
error: unknown,
|
|
1177
|
+
): "port_in_use" | "bind_failed" {
|
|
1178
|
+
return (error as NodeJS.ErrnoException | null)?.code === "EADDRINUSE"
|
|
1179
|
+
? "port_in_use"
|
|
1180
|
+
: "bind_failed";
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function positiveInteger(value: number | undefined, fallback: number): number {
|
|
1184
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 1
|
|
1185
|
+
? Math.floor(value)
|
|
1186
|
+
: fallback;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function sameGatewayState(
|
|
1190
|
+
left: McpGatewayCapability,
|
|
1191
|
+
right: McpGatewayCapability,
|
|
1192
|
+
): boolean {
|
|
1193
|
+
return (
|
|
1194
|
+
left.status === right.status &&
|
|
1195
|
+
left.port === right.port &&
|
|
1196
|
+
(left.status !== "down" ||
|
|
1197
|
+
(right.status === "down" && left.reason === right.reason))
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Construct one restart-safe MCP gateway listener. Exported so listener
|
|
1203
|
+
* lifecycle tests can use deterministic fake servers without opening a real
|
|
1204
|
+
* host port; production uses the singleton wrapper below.
|
|
1205
|
+
*/
|
|
1206
|
+
export function createMcpGatewayListener(
|
|
1207
|
+
options: McpGatewayListenerOptions = {},
|
|
1208
|
+
): McpGatewayListener {
|
|
1209
|
+
const port =
|
|
1210
|
+
Number.isSafeInteger(options.port) &&
|
|
1211
|
+
(options.port ?? 0) >= 1 &&
|
|
1212
|
+
(options.port ?? 0) <= 65_535
|
|
1213
|
+
? (options.port as number)
|
|
1214
|
+
: MCP_GATEWAY_PORT;
|
|
1215
|
+
const bind = options.bind ?? gatewayBindAddress();
|
|
1216
|
+
const retryInitialDelayMs = positiveInteger(
|
|
1217
|
+
options.retryInitialDelayMs,
|
|
1218
|
+
GATEWAY_RETRY_INITIAL_DELAY_MS,
|
|
1219
|
+
);
|
|
1220
|
+
const retryMaxDelayMs = Math.max(
|
|
1221
|
+
retryInitialDelayMs,
|
|
1222
|
+
positiveInteger(
|
|
1223
|
+
options.retryMaxDelayMs,
|
|
1224
|
+
GATEWAY_RETRY_MAX_DELAY_MS,
|
|
1225
|
+
),
|
|
1226
|
+
);
|
|
1227
|
+
const serverFactory =
|
|
1228
|
+
options.serverFactory ??
|
|
1229
|
+
((connectionListener: (socket: Socket) => void) =>
|
|
1230
|
+
createNetServer({ pauseOnConnect: true }, connectionListener));
|
|
1231
|
+
const httpServerFactory = options.httpServerFactory ?? createHttpServer;
|
|
1232
|
+
const preauthTimeoutMs = positiveInteger(
|
|
1233
|
+
options.preauthTimeoutMs,
|
|
1234
|
+
TASK_GATEWAY_PREAUTH_TIMEOUT_MS,
|
|
1235
|
+
);
|
|
1236
|
+
const maxPendingPreauth = positiveInteger(
|
|
1237
|
+
options.maxPendingPreauth,
|
|
1238
|
+
TASK_GATEWAY_MAX_PENDING_PREAUTH,
|
|
1239
|
+
);
|
|
1240
|
+
const maxPendingPreauthPerPeer = positiveInteger(
|
|
1241
|
+
options.maxPendingPreauthPerPeer,
|
|
1242
|
+
TASK_GATEWAY_MAX_PENDING_PREAUTH_PER_PEER,
|
|
1243
|
+
);
|
|
1244
|
+
const maxPendingPreauthPerTask = positiveInteger(
|
|
1245
|
+
options.maxPendingPreauthPerTask,
|
|
1246
|
+
TASK_GATEWAY_MAX_PENDING_PREAUTH_PER_TASK,
|
|
1247
|
+
);
|
|
1248
|
+
const maxActiveConnections = positiveInteger(
|
|
1249
|
+
options.maxActiveConnections,
|
|
1250
|
+
TASK_GATEWAY_MAX_ACTIVE_CONNECTIONS,
|
|
1251
|
+
);
|
|
1252
|
+
const maxDirectStagingConnectionsPerPeer = positiveInteger(
|
|
1253
|
+
options.maxDirectStagingConnectionsPerPeer,
|
|
1254
|
+
TASK_GATEWAY_MAX_DIRECT_STAGING_CONNECTIONS_PER_PEER,
|
|
1255
|
+
);
|
|
1256
|
+
const maxActiveConnectionsPerTask = positiveInteger(
|
|
1257
|
+
options.maxActiveConnectionsPerTask,
|
|
1258
|
+
TASK_GATEWAY_MAX_ACTIVE_CONNECTIONS_PER_TASK,
|
|
1259
|
+
);
|
|
1260
|
+
const nonce = options.nonce ?? (() => randomBytes(32));
|
|
1261
|
+
const setRetryTimeout =
|
|
1262
|
+
options.setRetryTimeout ??
|
|
1263
|
+
((callback: () => void, delayMs: number) =>
|
|
1264
|
+
setTimeout(callback, delayMs));
|
|
1265
|
+
const clearRetryTimeout = options.clearRetryTimeout ?? clearTimeout;
|
|
1266
|
+
const random = options.random ?? Math.random;
|
|
1267
|
+
const logger = options.logger ?? console;
|
|
1268
|
+
const subscribers = new Set<
|
|
1269
|
+
(state: McpGatewayCapability) => void
|
|
1270
|
+
>();
|
|
1271
|
+
const authenticatedSockets = new WeakMap<Socket, TaskForwarderIdentity>();
|
|
1272
|
+
const authenticatedConnections = new Map<Socket, TaskForwarderIdentity>();
|
|
1273
|
+
let claimTaskQuota = (_socket: Socket, _taskId: string): boolean => false;
|
|
1274
|
+
const httpServer = httpServerFactory((req, res) => {
|
|
1275
|
+
void handleGatewayRequest(
|
|
1276
|
+
req,
|
|
1277
|
+
res,
|
|
1278
|
+
authenticatedSockets,
|
|
1279
|
+
claimTaskQuota,
|
|
1280
|
+
).catch(() => deny(res, 500, "gateway error"));
|
|
1281
|
+
});
|
|
1282
|
+
httpServer.on("clientError", (_error, socket) => socket.destroy());
|
|
1283
|
+
// This HTTP server never owns the TCP listener: authenticated sockets are
|
|
1284
|
+
// demultiplexed into it. Node starts its headers/request timeout checker on
|
|
1285
|
+
// the `listening` event, so emit that lifecycle event for this hidden
|
|
1286
|
+
// server too or a peer can drip one byte forever after pre-authentication.
|
|
1287
|
+
httpServer.emit("listening");
|
|
1288
|
+
const demux = createTaskGatewayDemux({
|
|
1289
|
+
httpServer,
|
|
1290
|
+
authenticatedSockets,
|
|
1291
|
+
authenticatedConnections,
|
|
1292
|
+
preauthTimeoutMs,
|
|
1293
|
+
maxPendingPreauth,
|
|
1294
|
+
maxPendingPreauthPerPeer,
|
|
1295
|
+
maxPendingPreauthPerTask,
|
|
1296
|
+
maxActiveConnections,
|
|
1297
|
+
maxDirectStagingConnectionsPerPeer,
|
|
1298
|
+
maxActiveConnectionsPerTask,
|
|
1299
|
+
nonce,
|
|
1300
|
+
tlsHandler: () => options.tlsHandler ?? taskGatewayTlsHandler,
|
|
1301
|
+
});
|
|
1302
|
+
claimTaskQuota = demux.claimTaskQuota;
|
|
1303
|
+
const socketsByServer = new Map<GatewayTransportServer, Set<Socket>>();
|
|
1304
|
+
const unsubscribeRevocations = subscribeTaskGatewayRevocations((revoked) => {
|
|
1305
|
+
for (const [socket, identity] of authenticatedConnections) {
|
|
1306
|
+
if (
|
|
1307
|
+
identity.taskId === revoked.taskId &&
|
|
1308
|
+
identity.generation === revoked.generation
|
|
1309
|
+
) {
|
|
1310
|
+
socket.destroy();
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
let capability: McpGatewayCapability = Object.freeze({
|
|
1316
|
+
status: "starting",
|
|
1317
|
+
port,
|
|
1318
|
+
});
|
|
1319
|
+
let server: GatewayTransportServer | null = null;
|
|
1320
|
+
let retryTimer: NodeJS.Timeout | null = null;
|
|
1321
|
+
let consecutiveFailures = 0;
|
|
1322
|
+
let started = false;
|
|
1323
|
+
let stopped = false;
|
|
1324
|
+
let stopPromise: Promise<void> | null = null;
|
|
1325
|
+
|
|
1326
|
+
const setState = (next: McpGatewayCapability): void => {
|
|
1327
|
+
const frozen = Object.freeze(next);
|
|
1328
|
+
if (sameGatewayState(capability, frozen)) return;
|
|
1329
|
+
capability = frozen;
|
|
1330
|
+
for (const subscriber of subscribers) {
|
|
1331
|
+
try {
|
|
1332
|
+
subscriber(capability);
|
|
1333
|
+
} catch {
|
|
1334
|
+
// Capability observers must never take down the host listener.
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
const closeServer = (target: GatewayTransportServer): void => {
|
|
1340
|
+
const sockets = socketsByServer.get(target);
|
|
1341
|
+
socketsByServer.delete(target);
|
|
1342
|
+
if (sockets) {
|
|
1343
|
+
for (const socket of sockets) socket.destroy();
|
|
1344
|
+
sockets.clear();
|
|
1345
|
+
}
|
|
1346
|
+
try {
|
|
1347
|
+
target.close(() => undefined);
|
|
1348
|
+
target.closeAllConnections?.();
|
|
1349
|
+
} catch {
|
|
1350
|
+
// A listener that failed before binding has nothing left to close.
|
|
1351
|
+
}
|
|
1352
|
+
};
|
|
1353
|
+
|
|
1354
|
+
const retryDelay = (): number => {
|
|
1355
|
+
const exponent = Math.min(Math.max(consecutiveFailures - 1, 0), 30);
|
|
1356
|
+
const baseDelayMs = Math.min(
|
|
1357
|
+
retryMaxDelayMs,
|
|
1358
|
+
retryInitialDelayMs * 2 ** exponent,
|
|
1359
|
+
);
|
|
1360
|
+
const sample = random();
|
|
1361
|
+
const boundedSample = Number.isFinite(sample)
|
|
1362
|
+
? Math.min(1, Math.max(0, sample))
|
|
1363
|
+
: 0.5;
|
|
1364
|
+
return Math.min(
|
|
1365
|
+
retryMaxDelayMs,
|
|
1366
|
+
Math.max(
|
|
1367
|
+
1,
|
|
1368
|
+
Math.round(
|
|
1369
|
+
baseDelayMs *
|
|
1370
|
+
(GATEWAY_RETRY_MIN_JITTER +
|
|
1371
|
+
GATEWAY_RETRY_JITTER_SPAN * boundedSample),
|
|
1372
|
+
),
|
|
1373
|
+
),
|
|
1374
|
+
);
|
|
1375
|
+
};
|
|
1376
|
+
|
|
1377
|
+
let beginAttempt: () => void;
|
|
1378
|
+
|
|
1379
|
+
const scheduleRetry = (
|
|
1380
|
+
reason: "port_in_use" | "bind_failed",
|
|
1381
|
+
): void => {
|
|
1382
|
+
if (stopped || retryTimer !== null) return;
|
|
1383
|
+
consecutiveFailures += 1;
|
|
1384
|
+
setState({ status: "down", port, reason });
|
|
1385
|
+
const delayMs = retryDelay();
|
|
1386
|
+
logger.warn(
|
|
1387
|
+
`[mcp-gateway] listener down (${reason}); retrying in ${delayMs}ms`,
|
|
1388
|
+
);
|
|
1389
|
+
retryTimer = setRetryTimeout(() => {
|
|
1390
|
+
retryTimer = null;
|
|
1391
|
+
beginAttempt();
|
|
1392
|
+
}, delayMs);
|
|
1393
|
+
retryTimer.unref();
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
const listenerFailed = (
|
|
1397
|
+
target: GatewayTransportServer,
|
|
1398
|
+
error: unknown,
|
|
1399
|
+
): void => {
|
|
1400
|
+
if (stopped || server !== target) return;
|
|
1401
|
+
server = null;
|
|
1402
|
+
closeServer(target);
|
|
1403
|
+
scheduleRetry(listenerFailureReason(error));
|
|
1404
|
+
};
|
|
1405
|
+
|
|
1406
|
+
beginAttempt = (): void => {
|
|
1407
|
+
if (stopped || server !== null || retryTimer !== null) return;
|
|
1408
|
+
|
|
1409
|
+
let nextServer: GatewayTransportServer;
|
|
1410
|
+
const attemptSockets = new Set<Socket>();
|
|
1411
|
+
try {
|
|
1412
|
+
nextServer = serverFactory((socket) => {
|
|
1413
|
+
attemptSockets.add(socket);
|
|
1414
|
+
socket.once("close", () => attemptSockets.delete(socket));
|
|
1415
|
+
demux.accept(socket);
|
|
1416
|
+
});
|
|
1417
|
+
} catch {
|
|
1418
|
+
scheduleRetry("bind_failed");
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
socketsByServer.set(nextServer, attemptSockets);
|
|
1422
|
+
server = nextServer;
|
|
1423
|
+
|
|
1424
|
+
nextServer.on("error", (error) => {
|
|
1425
|
+
listenerFailed(nextServer, error);
|
|
1426
|
+
});
|
|
1427
|
+
nextServer.on("listening", () => {
|
|
1428
|
+
if (stopped || server !== nextServer) {
|
|
1429
|
+
closeServer(nextServer);
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
consecutiveFailures = 0;
|
|
1433
|
+
setState({ status: "ready", port });
|
|
1434
|
+
logger.log(`[mcp-gateway] listening on ${bind}:${port}`);
|
|
1435
|
+
});
|
|
1436
|
+
nextServer.on("close", () => {
|
|
1437
|
+
socketsByServer.delete(nextServer);
|
|
1438
|
+
if (stopped || server !== nextServer) return;
|
|
1439
|
+
server = null;
|
|
1440
|
+
scheduleRetry("bind_failed");
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
try {
|
|
1444
|
+
nextServer.listen(port, bind);
|
|
1445
|
+
} catch (error) {
|
|
1446
|
+
listenerFailed(nextServer, error);
|
|
1447
|
+
}
|
|
1448
|
+
};
|
|
1449
|
+
|
|
1450
|
+
const controller: McpGatewayListener = {
|
|
1451
|
+
state: () => capability,
|
|
1452
|
+
waitUntilReady: (timeoutMs = 5_000) => {
|
|
1453
|
+
if (capability.status === "ready") return Promise.resolve();
|
|
1454
|
+
if (capability.status === "down") {
|
|
1455
|
+
return Promise.reject(
|
|
1456
|
+
new Error(`MCP gateway listener is down (${capability.reason})`),
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
if (stopped) {
|
|
1460
|
+
return Promise.reject(new Error("MCP gateway listener is stopped"));
|
|
1461
|
+
}
|
|
1462
|
+
const boundedTimeoutMs =
|
|
1463
|
+
Number.isFinite(timeoutMs) && timeoutMs >= 1
|
|
1464
|
+
? Math.floor(timeoutMs)
|
|
1465
|
+
: 5_000;
|
|
1466
|
+
return new Promise<void>((resolveReady, rejectReady) => {
|
|
1467
|
+
let settled = false;
|
|
1468
|
+
let unsubscribe = (): void => undefined;
|
|
1469
|
+
const finish = (error?: Error): void => {
|
|
1470
|
+
if (settled) return;
|
|
1471
|
+
settled = true;
|
|
1472
|
+
clearTimeout(timer);
|
|
1473
|
+
unsubscribe();
|
|
1474
|
+
if (error) rejectReady(error);
|
|
1475
|
+
else resolveReady();
|
|
1476
|
+
};
|
|
1477
|
+
const timer = setTimeout(
|
|
1478
|
+
() =>
|
|
1479
|
+
finish(
|
|
1480
|
+
new Error(
|
|
1481
|
+
`MCP gateway listener did not become ready within ${boundedTimeoutMs}ms`,
|
|
1482
|
+
),
|
|
1483
|
+
),
|
|
1484
|
+
boundedTimeoutMs,
|
|
1485
|
+
);
|
|
1486
|
+
timer.unref();
|
|
1487
|
+
const observe = (state: McpGatewayCapability): void => {
|
|
1488
|
+
if (state.status === "ready") finish();
|
|
1489
|
+
else if (state.status === "down") {
|
|
1490
|
+
finish(new Error(`MCP gateway listener is down (${state.reason})`));
|
|
1491
|
+
}
|
|
1492
|
+
};
|
|
1493
|
+
unsubscribe = controller.subscribe(observe);
|
|
1494
|
+
// Close the state-change race between the initial check and subscribe.
|
|
1495
|
+
observe(capability);
|
|
1496
|
+
});
|
|
1497
|
+
},
|
|
1498
|
+
subscribe: (listener) => {
|
|
1499
|
+
subscribers.add(listener);
|
|
1500
|
+
return () => subscribers.delete(listener);
|
|
1501
|
+
},
|
|
1502
|
+
start: () => {
|
|
1503
|
+
if (stopped) {
|
|
1504
|
+
throw new Error("MCP gateway listener cannot restart after stop");
|
|
1505
|
+
}
|
|
1506
|
+
if (!started) {
|
|
1507
|
+
started = true;
|
|
1508
|
+
beginAttempt();
|
|
1509
|
+
}
|
|
1510
|
+
return controller;
|
|
1511
|
+
},
|
|
1512
|
+
stop: () => {
|
|
1513
|
+
if (stopPromise) return stopPromise;
|
|
1514
|
+
stopped = true;
|
|
1515
|
+
if (retryTimer !== null) {
|
|
1516
|
+
clearRetryTimeout(retryTimer);
|
|
1517
|
+
retryTimer = null;
|
|
1518
|
+
}
|
|
1519
|
+
const activeServer = server;
|
|
1520
|
+
server = null;
|
|
1521
|
+
stopPromise = new Promise<void>((resolveStop) => {
|
|
1522
|
+
if (activeServer === null) {
|
|
1523
|
+
resolveStop();
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
let resolved = false;
|
|
1527
|
+
const finish = (): void => {
|
|
1528
|
+
if (resolved) return;
|
|
1529
|
+
resolved = true;
|
|
1530
|
+
resolveStop();
|
|
1531
|
+
};
|
|
1532
|
+
activeServer.once("close", finish);
|
|
1533
|
+
try {
|
|
1534
|
+
const sockets = socketsByServer.get(activeServer);
|
|
1535
|
+
socketsByServer.delete(activeServer);
|
|
1536
|
+
if (sockets) {
|
|
1537
|
+
for (const socket of sockets) socket.destroy();
|
|
1538
|
+
}
|
|
1539
|
+
activeServer.close(finish);
|
|
1540
|
+
activeServer.closeAllConnections?.();
|
|
1541
|
+
if (!activeServer.listening) queueMicrotask(finish);
|
|
1542
|
+
} catch {
|
|
1543
|
+
finish();
|
|
1544
|
+
}
|
|
1545
|
+
}).finally(() => {
|
|
1546
|
+
unsubscribeRevocations();
|
|
1547
|
+
for (const socket of authenticatedConnections.keys()) socket.destroy();
|
|
1548
|
+
authenticatedConnections.clear();
|
|
1549
|
+
httpServer.closeAllConnections();
|
|
1550
|
+
// Clears the hidden server's timeout-check interval. It has no own
|
|
1551
|
+
// listening handle, so close is otherwise a no-op.
|
|
1552
|
+
httpServer.close();
|
|
1553
|
+
subscribers.clear();
|
|
1554
|
+
});
|
|
1555
|
+
return stopPromise;
|
|
1556
|
+
},
|
|
1557
|
+
};
|
|
1558
|
+
return controller;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
let gatewayController: McpGatewayListener | null = null;
|
|
1562
|
+
|
|
1563
|
+
/** Start (or return) the process-wide gateway listener. */
|
|
1564
|
+
export function startMcpGateway(): McpGatewayController {
|
|
1565
|
+
gatewayController ??= createMcpGatewayListener();
|
|
1566
|
+
return gatewayController.start();
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/** Task admission gate: no runtime starts until the shared listener is bound. */
|
|
1570
|
+
export function waitForMcpGatewayReady(timeoutMs = 5_000): Promise<void> {
|
|
1571
|
+
return startMcpGateway().waitUntilReady(timeoutMs);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/** Stop and reset the process-wide listener (also the deterministic test seam). */
|
|
1575
|
+
export async function stopMcpGateway(): Promise<void> {
|
|
1576
|
+
const active = gatewayController;
|
|
1577
|
+
if (active === null) return;
|
|
1578
|
+
await active.stop();
|
|
1579
|
+
if (gatewayController === active) gatewayController = null;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// --- task container wiring (ADR-057 task-up writers) -------------------------
|
|
1583
|
+
|
|
1584
|
+
/** Reconcile JSON MCP definitions atomically and with compare-before-rename.
|
|
1585
|
+
* Only exact Uai gateway shapes whose URL names this task are owned: stale
|
|
1586
|
+
* ones are removed, while browser and foreign/user entries are untouched. */
|
|
1587
|
+
export const MERGE_MCP_JSON = `
|
|
1588
|
+
const fs = require("fs");
|
|
1589
|
+
const path = require("path");
|
|
1590
|
+
const crypto = require("crypto");
|
|
1591
|
+
const p = process.argv[1];
|
|
1592
|
+
const key = process.argv[3] || "mcpServers";
|
|
1593
|
+
const flavor = process.argv[4] || "claude";
|
|
1594
|
+
const taskId = process.argv[5];
|
|
1595
|
+
const plainObject = (value) =>
|
|
1596
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1597
|
+
if (!["claude", "cursor", "opencode"].includes(flavor)) {
|
|
1598
|
+
throw new Error("invalid MCP config flavor");
|
|
1599
|
+
}
|
|
1600
|
+
if (typeof taskId !== "string" || taskId.length === 0) {
|
|
1601
|
+
throw new Error("invalid MCP task id");
|
|
1602
|
+
}
|
|
1603
|
+
const desired = JSON.parse(process.argv[2]);
|
|
1604
|
+
if (!plainObject(desired)) {
|
|
1605
|
+
throw new Error("desired MCP definitions must be an object");
|
|
1606
|
+
}
|
|
1607
|
+
const exactKeys = (value, keys) => plainObject(value) &&
|
|
1608
|
+
Object.keys(value).sort().join("\\0") === keys.slice().sort().join("\\0");
|
|
1609
|
+
const gatewayPort = ${JSON.stringify(String(MCP_GATEWAY_PORT))};
|
|
1610
|
+
const managedUrl = (value) => {
|
|
1611
|
+
if (flavor === "claude" && exactKeys(value, ["type", "url"]) &&
|
|
1612
|
+
value.type === "http" && typeof value.url === "string") return value.url;
|
|
1613
|
+
if (flavor === "cursor" && exactKeys(value, ["url"]) &&
|
|
1614
|
+
typeof value.url === "string") return value.url;
|
|
1615
|
+
if (flavor === "opencode" &&
|
|
1616
|
+
exactKeys(value, ["enabled", "oauth", "type", "url"]) &&
|
|
1617
|
+
value.type === "remote" && value.enabled === true && value.oauth === false &&
|
|
1618
|
+
typeof value.url === "string") return value.url;
|
|
1619
|
+
return null;
|
|
1620
|
+
};
|
|
1621
|
+
const isTaskGatewayUrl = (value, slug) => {
|
|
1622
|
+
let parsed;
|
|
1623
|
+
try { parsed = new URL(value); } catch { return false; }
|
|
1624
|
+
if (parsed.protocol !== "http:" || (parsed.port || "80") !== gatewayPort ||
|
|
1625
|
+
parsed.username || parsed.password || parsed.search || parsed.hash) return false;
|
|
1626
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
1627
|
+
if (segments.length !== 3 || segments[0] !== "t" || segments[2] !== slug) return false;
|
|
1628
|
+
const prefix = taskId + ".";
|
|
1629
|
+
return value === "http://" + parsed.hostname + ":" + gatewayPort +
|
|
1630
|
+
"/t/" + segments[1] + "/" + slug &&
|
|
1631
|
+
segments[1].startsWith(prefix) &&
|
|
1632
|
+
/^[A-Za-z0-9_-]{32}$/.test(segments[1].slice(prefix.length));
|
|
1633
|
+
};
|
|
1634
|
+
const isManaged = (value, slug) => {
|
|
1635
|
+
const url = managedUrl(value);
|
|
1636
|
+
return url !== null && isTaskGatewayUrl(url, slug);
|
|
1637
|
+
};
|
|
1638
|
+
for (const [slug, value] of Object.entries(desired)) {
|
|
1639
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug) || !isManaged(value, slug)) {
|
|
1640
|
+
throw new Error("invalid desired MCP definition for " + slug);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
let original = null;
|
|
1644
|
+
let j = {};
|
|
1645
|
+
try {
|
|
1646
|
+
original = fs.readFileSync(p, "utf8");
|
|
1647
|
+
j = JSON.parse(original);
|
|
1648
|
+
} catch (error) {
|
|
1649
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
1650
|
+
}
|
|
1651
|
+
if (!plainObject(j)) throw new Error("MCP config root must be an object");
|
|
1652
|
+
if (j[key] === undefined) {
|
|
1653
|
+
if (Object.keys(desired).length === 0 && flavor !== "claude") process.exit(0);
|
|
1654
|
+
j[key] = {};
|
|
1655
|
+
}
|
|
1656
|
+
if (!plainObject(j[key])) {
|
|
1657
|
+
throw new Error("MCP config " + key + " must be an object");
|
|
1658
|
+
}
|
|
1659
|
+
let changed = false;
|
|
1660
|
+
// A foreign same-name entry is a local shadow, not a failed reconciliation:
|
|
1661
|
+
// preserve it, apply every other slug, and memoize success. If the user later
|
|
1662
|
+
// removes it, the next dirty pass or task restart installs the Uai definition.
|
|
1663
|
+
const skipped = new Set();
|
|
1664
|
+
const warnings = [];
|
|
1665
|
+
for (const [slug, current] of Object.entries(j[key])) {
|
|
1666
|
+
if (Object.hasOwn(desired, slug)) {
|
|
1667
|
+
if (JSON.stringify(current) === JSON.stringify(desired[slug])) continue;
|
|
1668
|
+
if (!isManaged(current, slug)) {
|
|
1669
|
+
skipped.add(slug);
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
j[key][slug] = desired[slug];
|
|
1673
|
+
changed = true;
|
|
1674
|
+
} else if (isManaged(current, slug)) {
|
|
1675
|
+
delete j[key][slug];
|
|
1676
|
+
changed = true;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
for (const [slug, value] of Object.entries(desired)) {
|
|
1680
|
+
if (!Object.hasOwn(j[key], slug)) {
|
|
1681
|
+
j[key][slug] = value;
|
|
1682
|
+
changed = true;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
for (const slug of skipped) {
|
|
1686
|
+
warnings.push(
|
|
1687
|
+
"[uai-mcp] kept foreign server " + slug +
|
|
1688
|
+
"; skipped its Uai definition in " + p + "\\n"
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
if (changed || original === null) {
|
|
1692
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
1693
|
+
const mode = original === null ? 0o644 : fs.statSync(p).mode & 0o777;
|
|
1694
|
+
const tmp = path.join(
|
|
1695
|
+
path.dirname(p),
|
|
1696
|
+
"." + path.basename(p) + "." + process.pid + "." +
|
|
1697
|
+
crypto.randomBytes(8).toString("hex") + ".tmp"
|
|
1698
|
+
);
|
|
1699
|
+
try {
|
|
1700
|
+
fs.writeFileSync(tmp, JSON.stringify(j, null, 2) + "\\n", {
|
|
1701
|
+
encoding: "utf8",
|
|
1702
|
+
flag: "wx",
|
|
1703
|
+
mode,
|
|
1704
|
+
});
|
|
1705
|
+
let current = null;
|
|
1706
|
+
try {
|
|
1707
|
+
current = fs.readFileSync(p, "utf8");
|
|
1708
|
+
} catch (error) {
|
|
1709
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
1710
|
+
}
|
|
1711
|
+
if (current !== original) {
|
|
1712
|
+
throw new Error("MCP config changed concurrently");
|
|
1713
|
+
}
|
|
1714
|
+
fs.renameSync(tmp, p);
|
|
1715
|
+
} finally {
|
|
1716
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
for (const warning of warnings) process.stderr.write(warning);
|
|
1720
|
+
`.trim();
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* Ensure the host-gateway MCP definitions in one Codex home.
|
|
1724
|
+
*
|
|
1725
|
+
* Codex itself is the TOML parser. This matters because a text search cannot
|
|
1726
|
+
* distinguish `[mcp_servers.foo]` from a comment/string, and quoted or spaced
|
|
1727
|
+
* spellings name the same TOML table. A same-name definition is accepted only
|
|
1728
|
+
* when Codex reports the exact shape Uai writes. Foreign same-name definitions
|
|
1729
|
+
* shadow only that slug; malformed configs still fail without modification.
|
|
1730
|
+
*/
|
|
1731
|
+
export const ENSURE_CODEX_MCP_TOML = String.raw`
|
|
1732
|
+
const fs = require("fs");
|
|
1733
|
+
const path = require("path");
|
|
1734
|
+
const crypto = require("crypto");
|
|
1735
|
+
const child = require("child_process");
|
|
1736
|
+
|
|
1737
|
+
const home = process.argv[1];
|
|
1738
|
+
const desired = JSON.parse(process.argv[2]);
|
|
1739
|
+
const codexBin = process.argv[3] || "codex";
|
|
1740
|
+
const taskId = process.argv[4];
|
|
1741
|
+
const plainObject = (value) =>
|
|
1742
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1743
|
+
if (typeof home !== "string" || !path.isAbsolute(home)) {
|
|
1744
|
+
throw new Error("Codex home must be an absolute path");
|
|
1745
|
+
}
|
|
1746
|
+
if (typeof taskId !== "string" || taskId.length === 0) {
|
|
1747
|
+
throw new Error("invalid MCP task id");
|
|
1748
|
+
}
|
|
1749
|
+
if (!plainObject(desired)) {
|
|
1750
|
+
throw new Error("Codex MCP definitions must be an object");
|
|
1751
|
+
}
|
|
1752
|
+
for (const [slug, url] of Object.entries(desired)) {
|
|
1753
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug)) {
|
|
1754
|
+
throw new Error("invalid Codex MCP slug");
|
|
1755
|
+
}
|
|
1756
|
+
if (typeof url !== "string") {
|
|
1757
|
+
throw new Error("invalid Codex MCP URL");
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
fs.mkdirSync(home, { recursive: true });
|
|
1762
|
+
// Codex canonicalizes CODEX_HOME before resolving relative config paths. Keep
|
|
1763
|
+
// the same stable target so symlinked homes validate beside the directory
|
|
1764
|
+
// Codex actually uses, not beside the link's lexical parent.
|
|
1765
|
+
const sourceHome = fs.realpathSync(home);
|
|
1766
|
+
const file = path.join(sourceHome, "config.toml");
|
|
1767
|
+
let original = null;
|
|
1768
|
+
try {
|
|
1769
|
+
original = fs.readFileSync(file, "utf8");
|
|
1770
|
+
} catch (error) {
|
|
1771
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
function runCodex(args, codexHome = sourceHome) {
|
|
1775
|
+
return child.spawnSync(codexBin, args, {
|
|
1776
|
+
encoding: "utf8",
|
|
1777
|
+
env: Object.assign({}, process.env, { CODEX_HOME: codexHome }),
|
|
1778
|
+
maxBuffer: 1024 * 1024,
|
|
1779
|
+
timeout: 5000,
|
|
1780
|
+
killSignal: "SIGKILL",
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function detail(result) {
|
|
1785
|
+
const lines = String(result.stderr || "")
|
|
1786
|
+
.split(/\r?\n/)
|
|
1787
|
+
.map((line) => line.trim())
|
|
1788
|
+
.filter(Boolean);
|
|
1789
|
+
return String(lines.pop() || result.error || result.status);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
function exactKeys(value, keys) {
|
|
1793
|
+
return plainObject(value) &&
|
|
1794
|
+
Object.keys(value).sort().join("\0") === keys.slice().sort().join("\0");
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function managedDefinitionUrl(value, slug) {
|
|
1798
|
+
const topKeys = [
|
|
1799
|
+
"auth_status", "disabled_reason", "enabled", "name",
|
|
1800
|
+
"startup_timeout_sec", "tool_timeout_sec", "transport"
|
|
1801
|
+
];
|
|
1802
|
+
const transportKeys = ["args", "command", "cwd", "env", "env_vars", "type"];
|
|
1803
|
+
if (!(exactKeys(value, topKeys) &&
|
|
1804
|
+
value.name === slug &&
|
|
1805
|
+
value.enabled === true &&
|
|
1806
|
+
value.disabled_reason === null &&
|
|
1807
|
+
value.auth_status === "unsupported" &&
|
|
1808
|
+
value.startup_timeout_sec === null &&
|
|
1809
|
+
value.tool_timeout_sec === null &&
|
|
1810
|
+
exactKeys(value.transport, transportKeys) &&
|
|
1811
|
+
value.transport.type === "stdio" &&
|
|
1812
|
+
value.transport.command === "npx" &&
|
|
1813
|
+
Array.isArray(value.transport.args) &&
|
|
1814
|
+
value.transport.args.length === 4 &&
|
|
1815
|
+
value.transport.args[0] === "-y" &&
|
|
1816
|
+
value.transport.args[1] === "mcp-remote" &&
|
|
1817
|
+
typeof value.transport.args[2] === "string" &&
|
|
1818
|
+
value.transport.args[3] === "--allow-http" &&
|
|
1819
|
+
value.transport.cwd === null &&
|
|
1820
|
+
value.transport.env === null &&
|
|
1821
|
+
Array.isArray(value.transport.env_vars) &&
|
|
1822
|
+
value.transport.env_vars.length === 0)) {
|
|
1823
|
+
return null;
|
|
1824
|
+
}
|
|
1825
|
+
return value.transport.args[2];
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
function exactDefinition(value, slug, url) {
|
|
1829
|
+
return managedDefinitionUrl(value, slug) === url;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function gatewayIdentity(value, slug) {
|
|
1833
|
+
let parsed;
|
|
1834
|
+
try {
|
|
1835
|
+
parsed = new URL(value);
|
|
1836
|
+
} catch {
|
|
1837
|
+
return null;
|
|
1838
|
+
}
|
|
1839
|
+
if (parsed.protocol !== "http:" || (parsed.port || "80") !== "${MCP_GATEWAY_PORT}" ||
|
|
1840
|
+
parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
1841
|
+
return null;
|
|
1842
|
+
}
|
|
1843
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
1844
|
+
if (segments.length !== 3 || segments[0] !== "t" || segments[2] !== slug ||
|
|
1845
|
+
parsed.pathname !== "/t/" + segments[1] + "/" + slug) {
|
|
1846
|
+
return null;
|
|
1847
|
+
}
|
|
1848
|
+
const token = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{32})$/.exec(segments[1]);
|
|
1849
|
+
if (!token || value !== "http://" + parsed.hostname + ":${MCP_GATEWAY_PORT}" +
|
|
1850
|
+
"/t/" + segments[1] + "/" + slug) return null;
|
|
1851
|
+
return { origin: parsed.origin, taskId: token[1] };
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function sameTaskGateway(oldUrl, desiredUrl, slug) {
|
|
1855
|
+
// Ownership is proven by the /t/<taskId>.<token>/<slug> path, not the
|
|
1856
|
+
// origin: the gateway host is topology-dependent (container gateway IP,
|
|
1857
|
+
// host.docker.internal, machine loopback behind a reverse tunnel) and
|
|
1858
|
+
// migrating a task across topologies is exactly when the rewrite runs.
|
|
1859
|
+
const oldIdentity = gatewayIdentity(oldUrl, slug);
|
|
1860
|
+
const desiredIdentity = gatewayIdentity(desiredUrl, slug);
|
|
1861
|
+
return oldIdentity !== null &&
|
|
1862
|
+
desiredIdentity !== null &&
|
|
1863
|
+
oldIdentity.taskId === desiredIdentity.taskId;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function escapeRegExp(value) {
|
|
1867
|
+
return value.replace(/[.*+?^$(){}|[\]\\]/g, "\\$&");
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/**
|
|
1871
|
+
* Replace or remove a raw block Uai can prove it authored.
|
|
1872
|
+
* The exact legacy three-line shape predates the marker. New blocks carry the
|
|
1873
|
+
* ADR-057 marker. A quoted/spaced header, extra field, or neighboring body
|
|
1874
|
+
* text is deliberately foreign even if Codex normalizes it to the same JSON.
|
|
1875
|
+
*/
|
|
1876
|
+
function editManagedRawBlock(text, slug, oldUrl, desiredUrl) {
|
|
1877
|
+
const prefix =
|
|
1878
|
+
"(^|\\r?\\n)\\[mcp_servers\\." + escapeRegExp(slug) + "\\]\\r?\\n" +
|
|
1879
|
+
"command = \\\"npx\\\"\\r?\\n";
|
|
1880
|
+
const legacyPattern = new RegExp(
|
|
1881
|
+
prefix +
|
|
1882
|
+
"args = \\[\\\"-y\\\", \\\"mcp-remote\\\", " +
|
|
1883
|
+
escapeRegExp(JSON.stringify(oldUrl)) +
|
|
1884
|
+
", \\\"--allow-http\\\"\\](?=\\r?\\n|$)",
|
|
1885
|
+
"g"
|
|
1886
|
+
);
|
|
1887
|
+
const markedPattern = new RegExp(
|
|
1888
|
+
"(^|\\r?\\n)# uai ADR-057: host MCP gateway\\r?\\n" +
|
|
1889
|
+
"\\[mcp_servers\\." + escapeRegExp(slug) + "\\]\\r?\\n" +
|
|
1890
|
+
"command = \\\"npx\\\"\\r?\\n" +
|
|
1891
|
+
"args = " +
|
|
1892
|
+
escapeRegExp(JSON.stringify([
|
|
1893
|
+
"-y", "mcp-remote", oldUrl, "--allow-http"
|
|
1894
|
+
])) +
|
|
1895
|
+
"(?=\\r?\\n|$)",
|
|
1896
|
+
"g"
|
|
1897
|
+
);
|
|
1898
|
+
const matches = [
|
|
1899
|
+
...Array.from(text.matchAll(legacyPattern)),
|
|
1900
|
+
...Array.from(text.matchAll(markedPattern)),
|
|
1901
|
+
].filter((match) => {
|
|
1902
|
+
const after = (match.index || 0) + match[0].length;
|
|
1903
|
+
const remainder = text.slice(after);
|
|
1904
|
+
const nextHeader = remainder.search(
|
|
1905
|
+
/^[ \t]*\[\[?.+\]\]?[ \t]*(?:#.*)?\r?$/m
|
|
1906
|
+
);
|
|
1907
|
+
const gap = nextHeader === -1 ? remainder : remainder.slice(0, nextHeader);
|
|
1908
|
+
return gap.split(/\r?\n/).every((line) => {
|
|
1909
|
+
const trimmed = line.trim();
|
|
1910
|
+
return trimmed === "" || trimmed.startsWith("#");
|
|
1911
|
+
});
|
|
1912
|
+
});
|
|
1913
|
+
if (matches.length !== 1) return null;
|
|
1914
|
+
const match = matches[0];
|
|
1915
|
+
const start = match.index || 0;
|
|
1916
|
+
const replacement = desiredUrl === null
|
|
1917
|
+
? match[1]
|
|
1918
|
+
: match[0].replace(JSON.stringify(oldUrl), JSON.stringify(desiredUrl));
|
|
1919
|
+
return text.slice(0, start) + replacement + text.slice(start + match[0].length);
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
/** The Codex parser has already proved the table's semantics. This raw check
|
|
1923
|
+
* rejects user customizations the list view intentionally omits, while still
|
|
1924
|
+
* accepting equivalent quoted/spaced table headers without claiming them as
|
|
1925
|
+
* removable Uai-owned text. */
|
|
1926
|
+
function plainRawDefinition(text, slug) {
|
|
1927
|
+
const atom = escapeRegExp(slug);
|
|
1928
|
+
const header = new RegExp(
|
|
1929
|
+
"^[ \\t]*\\[[ \\t]*(?:\\\"mcp_servers\\\"|'mcp_servers'|mcp_servers)" +
|
|
1930
|
+
"[ \\t]*\\.[ \\t]*(?:\\\"" + atom + "\\\"|'" + atom + "'|" + atom + ")" +
|
|
1931
|
+
"[ \\t]*\\][ \\t]*(?:#.*)?$",
|
|
1932
|
+
"gm"
|
|
1933
|
+
);
|
|
1934
|
+
const matches = Array.from(text.matchAll(header));
|
|
1935
|
+
if (matches.length !== 1) return false;
|
|
1936
|
+
const match = matches[0];
|
|
1937
|
+
const remainder = text.slice((match.index || 0) + match[0].length);
|
|
1938
|
+
const nextHeader = remainder.search(/^[ \t]*\[\[?.+\]\]?[ \t]*(?:#.*)?\r?$/m);
|
|
1939
|
+
const body = nextHeader === -1 ? remainder : remainder.slice(0, nextHeader);
|
|
1940
|
+
const fields = body
|
|
1941
|
+
.split(/\r?\n/)
|
|
1942
|
+
.map((line) => line.trim())
|
|
1943
|
+
.filter((line) => line !== "" && !line.startsWith("#"));
|
|
1944
|
+
return fields.length === 2 &&
|
|
1945
|
+
fields.some((line) => /^command[ \t]*=/.test(line)) &&
|
|
1946
|
+
fields.some((line) => /^args[ \t]*=/.test(line));
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
/** Any descendant table is user/Codex-authored state that the parent marker
|
|
1950
|
+
* does not prove Uai owns. Scan the whole document: TOML permits unrelated
|
|
1951
|
+
* tables between a parent and its descendants. */
|
|
1952
|
+
function hasRawDescendantTable(text, slug) {
|
|
1953
|
+
const atom = escapeRegExp(slug);
|
|
1954
|
+
return new RegExp(
|
|
1955
|
+
"^[ \\t]*\\[\\[?[ \\t]*(?:\\\"mcp_servers\\\"|'mcp_servers'|mcp_servers)" +
|
|
1956
|
+
"[ \\t]*\\.[ \\t]*(?:\\\"" + atom + "\\\"|'" + atom + "'|" + atom + ")" +
|
|
1957
|
+
"[ \\t]*\\.",
|
|
1958
|
+
"m"
|
|
1959
|
+
).test(text);
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
function listDefinitions(codexHome = sourceHome) {
|
|
1963
|
+
// One list call validates the whole TOML document and identifies truly
|
|
1964
|
+
// absent servers. A malformed document fails before any edit.
|
|
1965
|
+
const result = runCodex(["mcp", "list", "--json"], codexHome);
|
|
1966
|
+
if (result.status !== 0) {
|
|
1967
|
+
throw new Error("Codex config is invalid: " + detail(result));
|
|
1968
|
+
}
|
|
1969
|
+
let value;
|
|
1970
|
+
try {
|
|
1971
|
+
value = JSON.parse(result.stdout);
|
|
1972
|
+
} catch {
|
|
1973
|
+
throw new Error("codex mcp list returned invalid JSON");
|
|
1974
|
+
}
|
|
1975
|
+
if (!Array.isArray(value) ||
|
|
1976
|
+
!value.every((entry) => plainObject(entry) && typeof entry.name === "string")) {
|
|
1977
|
+
throw new Error("codex mcp list returned an invalid server list");
|
|
1978
|
+
}
|
|
1979
|
+
return value;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
/** Validate a candidate before publication through the same Codex parser used
|
|
1983
|
+
* for the real home. The sibling directory preserves ../ relative paths; its
|
|
1984
|
+
* top-level symlinks preserve paths within CODEX_HOME without copying secrets
|
|
1985
|
+
* or other account state. */
|
|
1986
|
+
function validateCandidate(text, mode) {
|
|
1987
|
+
const validationHome = fs.mkdtempSync(path.join(
|
|
1988
|
+
path.dirname(sourceHome),
|
|
1989
|
+
"." + path.basename(sourceHome) + ".uai-validate-"
|
|
1990
|
+
));
|
|
1991
|
+
try {
|
|
1992
|
+
for (const name of fs.readdirSync(sourceHome)) {
|
|
1993
|
+
if (name === path.basename(file)) continue;
|
|
1994
|
+
fs.symlinkSync(
|
|
1995
|
+
path.join(sourceHome, name),
|
|
1996
|
+
path.join(validationHome, name)
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1999
|
+
const validationFile = path.join(validationHome, path.basename(file));
|
|
2000
|
+
fs.writeFileSync(validationFile, text, {
|
|
2001
|
+
encoding: "utf8",
|
|
2002
|
+
flag: "wx",
|
|
2003
|
+
mode,
|
|
2004
|
+
});
|
|
2005
|
+
const definitions = listDefinitions(validationHome);
|
|
2006
|
+
if (fs.readFileSync(validationFile, "utf8") !== text) {
|
|
2007
|
+
throw new Error("Codex changed its config during candidate validation");
|
|
2008
|
+
}
|
|
2009
|
+
return definitions;
|
|
2010
|
+
} finally {
|
|
2011
|
+
fs.rmSync(validationHome, { recursive: true, force: true });
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
for (const [slug, url] of Object.entries(desired)) {
|
|
2016
|
+
if (gatewayIdentity(url, slug)?.taskId !== taskId) {
|
|
2017
|
+
throw new Error("invalid desired Codex MCP gateway URL");
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
const listed = listDefinitions();
|
|
2022
|
+
const bySlug = new Map();
|
|
2023
|
+
for (const entry of listed) {
|
|
2024
|
+
if (bySlug.has(entry.name)) {
|
|
2025
|
+
throw new Error("Codex MCP server " + entry.name + " is duplicated - left unchanged");
|
|
2026
|
+
}
|
|
2027
|
+
bySlug.set(entry.name, entry);
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
const managed = new Map();
|
|
2031
|
+
const customized = new Set();
|
|
2032
|
+
for (const [slug, definition] of bySlug) {
|
|
2033
|
+
const url = managedDefinitionUrl(definition, slug);
|
|
2034
|
+
if (url !== null && gatewayIdentity(url, slug)?.taskId === taskId) {
|
|
2035
|
+
if (!hasRawDescendantTable(original || "", slug) &&
|
|
2036
|
+
editManagedRawBlock(original || "", slug, url, url) !== null) {
|
|
2037
|
+
managed.set(slug, url);
|
|
2038
|
+
} else {
|
|
2039
|
+
customized.add(slug);
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
const missing = [];
|
|
2045
|
+
const rewrites = [];
|
|
2046
|
+
// As with JSON configs, a foreign definition shadows only this slug in this
|
|
2047
|
+
// home. Other homes and engine writers must still run.
|
|
2048
|
+
const skipped = new Set();
|
|
2049
|
+
const warnings = [];
|
|
2050
|
+
for (const [slug, url] of Object.entries(desired)) {
|
|
2051
|
+
const definition = bySlug.get(slug);
|
|
2052
|
+
if (definition === undefined) {
|
|
2053
|
+
missing.push([slug, url]);
|
|
2054
|
+
continue;
|
|
2055
|
+
}
|
|
2056
|
+
if (exactDefinition(definition, slug, url) &&
|
|
2057
|
+
plainRawDefinition(original || "", slug)) continue;
|
|
2058
|
+
const oldUrl = managed.get(slug);
|
|
2059
|
+
if (oldUrl === undefined || !sameTaskGateway(oldUrl, url, slug)) {
|
|
2060
|
+
skipped.add(slug);
|
|
2061
|
+
continue;
|
|
2062
|
+
}
|
|
2063
|
+
rewrites.push([slug, oldUrl, url]);
|
|
2064
|
+
}
|
|
2065
|
+
const removals = [...managed.entries()].filter(([slug]) =>
|
|
2066
|
+
!Object.hasOwn(desired, slug)
|
|
2067
|
+
);
|
|
2068
|
+
for (const slug of skipped) {
|
|
2069
|
+
warnings.push(
|
|
2070
|
+
"[uai-mcp] kept foreign Codex server " + slug +
|
|
2071
|
+
"; skipped its Uai definition in " + home + "\n"
|
|
2072
|
+
);
|
|
2073
|
+
}
|
|
2074
|
+
for (const slug of customized) {
|
|
2075
|
+
if (!Object.hasOwn(desired, slug)) {
|
|
2076
|
+
warnings.push(
|
|
2077
|
+
"[uai-mcp] kept customized Codex server " + slug +
|
|
2078
|
+
"; skipped its Uai removal in " + home + "\n"
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
if (missing.length === 0 && rewrites.length === 0 && removals.length === 0) {
|
|
2083
|
+
for (const warning of warnings) process.stderr.write(warning);
|
|
2084
|
+
process.exit(0);
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
const newline = original && original.includes("\r\n") ? "\r\n" : "\n";
|
|
2088
|
+
const blocks = missing.map(([slug, url]) => [
|
|
2089
|
+
"# uai ADR-057: host MCP gateway",
|
|
2090
|
+
"[mcp_servers." + slug + "]",
|
|
2091
|
+
'command = "npx"',
|
|
2092
|
+
"args = " + JSON.stringify(["-y", "mcp-remote", url, "--allow-http"]),
|
|
2093
|
+
"",
|
|
2094
|
+
].join(newline)).join(newline);
|
|
2095
|
+
let next = original || "";
|
|
2096
|
+
for (const [slug, oldUrl] of removals) {
|
|
2097
|
+
const removed = editManagedRawBlock(next, slug, oldUrl, null);
|
|
2098
|
+
if (removed === null) {
|
|
2099
|
+
throw new Error("Codex MCP server " + slug + " has foreign TOML text - left unchanged");
|
|
2100
|
+
}
|
|
2101
|
+
next = removed;
|
|
2102
|
+
}
|
|
2103
|
+
for (const [slug, oldUrl, desiredUrl] of rewrites) {
|
|
2104
|
+
const rewritten = editManagedRawBlock(next, slug, oldUrl, desiredUrl);
|
|
2105
|
+
if (rewritten === null) {
|
|
2106
|
+
throw new Error("Codex MCP server " + slug + " has foreign TOML text - left unchanged");
|
|
2107
|
+
}
|
|
2108
|
+
next = rewritten;
|
|
2109
|
+
}
|
|
2110
|
+
if (next.length > 0 && !next.endsWith("\n")) next += newline;
|
|
2111
|
+
if (blocks.length > 0) {
|
|
2112
|
+
if (next.length > 0 && !next.endsWith(newline + newline)) next += newline;
|
|
2113
|
+
next += blocks;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
const mode = original === null ? 0o644 : fs.statSync(file).mode & 0o777;
|
|
2117
|
+
// Validate and verify the complete candidate before it can replace the real
|
|
2118
|
+
// file. This is the final safety boundary for TOML spellings the conservative
|
|
2119
|
+
// raw ownership checks do not recognize (for example escaped quoted keys).
|
|
2120
|
+
const verified = validateCandidate(next, mode);
|
|
2121
|
+
const verifiedBySlug = new Map(verified.map((entry) => [entry.name, entry]));
|
|
2122
|
+
for (const [slug, url] of Object.entries(desired)) {
|
|
2123
|
+
if (skipped.has(slug)) continue;
|
|
2124
|
+
if (!exactDefinition(verifiedBySlug.get(slug), slug, url) ||
|
|
2125
|
+
!plainRawDefinition(next, slug)) {
|
|
2126
|
+
throw new Error("Codex MCP server " + slug + " failed verification");
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
for (const [slug] of removals) {
|
|
2130
|
+
if (verifiedBySlug.has(slug)) {
|
|
2131
|
+
throw new Error("Codex MCP server " + slug + " was not removed");
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
const tmp = path.join(
|
|
2136
|
+
sourceHome,
|
|
2137
|
+
"." + path.basename(file) + "." + process.pid + "." +
|
|
2138
|
+
crypto.randomBytes(8).toString("hex") + ".tmp"
|
|
2139
|
+
);
|
|
2140
|
+
try {
|
|
2141
|
+
fs.writeFileSync(tmp, next, {
|
|
2142
|
+
encoding: "utf8",
|
|
2143
|
+
flag: "wx",
|
|
2144
|
+
mode,
|
|
2145
|
+
});
|
|
2146
|
+
let current = null;
|
|
2147
|
+
try {
|
|
2148
|
+
current = fs.readFileSync(file, "utf8");
|
|
2149
|
+
} catch (error) {
|
|
2150
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
2151
|
+
}
|
|
2152
|
+
if (current !== original) {
|
|
2153
|
+
throw new Error("Codex config changed concurrently");
|
|
2154
|
+
}
|
|
2155
|
+
if (fs.realpathSync(home) !== sourceHome) {
|
|
2156
|
+
throw new Error("Codex home changed concurrently");
|
|
2157
|
+
}
|
|
2158
|
+
fs.renameSync(tmp, file);
|
|
2159
|
+
} finally {
|
|
2160
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
2161
|
+
}
|
|
2162
|
+
for (const warning of warnings) process.stderr.write(warning);
|
|
2163
|
+
`.trim();
|
|
2164
|
+
|
|
2165
|
+
/** Reconcile one GROK_HOME through Grok's own parser/writer. Compatibility
|
|
2166
|
+
* sources and project scope are disabled for the ownership probe: only an
|
|
2167
|
+
* exact user-scope HTTP entry routed through this task's gateway is removed
|
|
2168
|
+
* or replaced. */
|
|
2169
|
+
export const RECONCILE_GROK_MCP = String.raw`
|
|
2170
|
+
const child = require("child_process");
|
|
2171
|
+
const path = require("path");
|
|
2172
|
+
|
|
2173
|
+
const home = process.argv[1];
|
|
2174
|
+
const desired = JSON.parse(process.argv[2]);
|
|
2175
|
+
const grokBin = process.argv[3];
|
|
2176
|
+
const taskId = process.argv[4];
|
|
2177
|
+
const plainObject = (value) =>
|
|
2178
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2179
|
+
if (typeof home !== "string" || !path.isAbsolute(home)) {
|
|
2180
|
+
throw new Error("Grok home must be an absolute path");
|
|
2181
|
+
}
|
|
2182
|
+
if (!plainObject(desired)) {
|
|
2183
|
+
throw new Error("Grok MCP definitions must be an object");
|
|
2184
|
+
}
|
|
2185
|
+
if (typeof grokBin !== "string" || !path.isAbsolute(grokBin)) {
|
|
2186
|
+
throw new Error("Grok binary must be an absolute path");
|
|
2187
|
+
}
|
|
2188
|
+
if (typeof taskId !== "string" || taskId.length === 0) {
|
|
2189
|
+
throw new Error("invalid MCP task id");
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
function gatewayIdentity(value, slug) {
|
|
2193
|
+
let parsed;
|
|
2194
|
+
try { parsed = new URL(value); } catch { return null; }
|
|
2195
|
+
if (parsed.protocol !== "http:" || (parsed.port || "80") !== "${MCP_GATEWAY_PORT}" ||
|
|
2196
|
+
parsed.username || parsed.password || parsed.search || parsed.hash) return null;
|
|
2197
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
2198
|
+
if (segments.length !== 3 || segments[0] !== "t" || segments[2] !== slug ||
|
|
2199
|
+
parsed.pathname !== "/t/" + segments[1] + "/" + slug) return null;
|
|
2200
|
+
const prefix = taskId + ".";
|
|
2201
|
+
if (!segments[1].startsWith(prefix) ||
|
|
2202
|
+
!/^[A-Za-z0-9_-]{32}$/.test(segments[1].slice(prefix.length))) return null;
|
|
2203
|
+
if (value !== "http://" + parsed.hostname + ":${MCP_GATEWAY_PORT}" +
|
|
2204
|
+
"/t/" + segments[1] + "/" + slug) return null;
|
|
2205
|
+
return { taskId: taskId };
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
for (const [slug, url] of Object.entries(desired)) {
|
|
2209
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug) ||
|
|
2210
|
+
typeof url !== "string" || gatewayIdentity(url, slug) === null) {
|
|
2211
|
+
throw new Error("invalid desired Grok MCP definition for " + slug);
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
function run(args) {
|
|
2216
|
+
return child.spawnSync(grokBin, args, {
|
|
2217
|
+
cwd: "/",
|
|
2218
|
+
encoding: "utf8",
|
|
2219
|
+
env: Object.assign({}, process.env, {
|
|
2220
|
+
GROK_HOME: home,
|
|
2221
|
+
GROK_CURSOR_MCPS_ENABLED: "false",
|
|
2222
|
+
GROK_CLAUDE_MCPS_ENABLED: "false",
|
|
2223
|
+
}),
|
|
2224
|
+
maxBuffer: 1024 * 1024,
|
|
2225
|
+
timeout: 5000,
|
|
2226
|
+
killSignal: "SIGKILL",
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
function detail(result) {
|
|
2231
|
+
const lines = String(result.stderr || result.stdout || "")
|
|
2232
|
+
.split(/\r?\n/)
|
|
2233
|
+
.map((line) => line.trim())
|
|
2234
|
+
.filter(Boolean);
|
|
2235
|
+
return String(lines.pop() || result.error || result.status);
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
function list() {
|
|
2239
|
+
const result = run(["mcp", "list", "--json"]);
|
|
2240
|
+
if (result.status !== 0) {
|
|
2241
|
+
throw new Error("Grok config is invalid: " + detail(result));
|
|
2242
|
+
}
|
|
2243
|
+
let value;
|
|
2244
|
+
try { value = JSON.parse(result.stdout); }
|
|
2245
|
+
catch { throw new Error("grok mcp list returned invalid JSON"); }
|
|
2246
|
+
if (!Array.isArray(value) ||
|
|
2247
|
+
!value.every((entry) => plainObject(entry) && typeof entry.name === "string")) {
|
|
2248
|
+
throw new Error("grok mcp list returned an invalid server list");
|
|
2249
|
+
}
|
|
2250
|
+
return value;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
function managedUrl(entry, slug) {
|
|
2254
|
+
if (!plainObject(entry)) return null;
|
|
2255
|
+
const keys = Object.keys(entry).sort().join("\0");
|
|
2256
|
+
if (keys !== ["enabled", "name", "scope", "url"].sort().join("\0") ||
|
|
2257
|
+
entry.name !== slug || entry.scope !== "user" || entry.enabled !== true ||
|
|
2258
|
+
typeof entry.url !== "string") return null;
|
|
2259
|
+
return gatewayIdentity(entry.url, slug) === null ? null : entry.url;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
const current = new Map();
|
|
2263
|
+
const managed = new Map();
|
|
2264
|
+
for (const entry of list()) {
|
|
2265
|
+
if (current.has(entry.name)) {
|
|
2266
|
+
throw new Error("Grok MCP server " + entry.name + " is duplicated - left unchanged");
|
|
2267
|
+
}
|
|
2268
|
+
current.set(entry.name, entry);
|
|
2269
|
+
const url = managedUrl(entry, entry.name);
|
|
2270
|
+
if (url !== null) managed.set(entry.name, url);
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
const additions = [];
|
|
2274
|
+
// A user-scope foreign entry shadows only this slug in this GROK_HOME.
|
|
2275
|
+
const skipped = new Set();
|
|
2276
|
+
const warnings = [];
|
|
2277
|
+
for (const [slug, url] of Object.entries(desired)) {
|
|
2278
|
+
const entry = current.get(slug);
|
|
2279
|
+
if (entry === undefined) {
|
|
2280
|
+
additions.push([slug, url]);
|
|
2281
|
+
} else if (managed.get(slug) === url) {
|
|
2282
|
+
continue;
|
|
2283
|
+
} else if (managed.has(slug)) {
|
|
2284
|
+
additions.push([slug, url]);
|
|
2285
|
+
} else {
|
|
2286
|
+
skipped.add(slug);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
const removals = [...managed.keys()].filter((slug) => !Object.hasOwn(desired, slug));
|
|
2290
|
+
for (const slug of skipped) {
|
|
2291
|
+
warnings.push(
|
|
2292
|
+
"[uai-mcp] kept foreign Grok server " + slug +
|
|
2293
|
+
"; skipped its Uai definition in " + home + "\n"
|
|
2294
|
+
);
|
|
2295
|
+
}
|
|
2296
|
+
if (additions.length === 0 && removals.length === 0) {
|
|
2297
|
+
for (const warning of warnings) process.stderr.write(warning);
|
|
2298
|
+
process.exit(0);
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
for (const [slug, url] of additions) {
|
|
2302
|
+
const result = run(["mcp", "add", slug, url, "-t", "http", "-s", "user"]);
|
|
2303
|
+
if (result.status !== 0) {
|
|
2304
|
+
throw new Error("grok mcp add " + slug + " failed: " + detail(result));
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
for (const slug of removals) {
|
|
2308
|
+
const result = run(["mcp", "remove", slug, "-s", "user"]);
|
|
2309
|
+
if (result.status !== 0) {
|
|
2310
|
+
throw new Error("grok mcp remove " + slug + " failed: " + detail(result));
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
const verified = new Map(list().map((entry) => [entry.name, entry]));
|
|
2315
|
+
for (const [slug, url] of Object.entries(desired)) {
|
|
2316
|
+
if (skipped.has(slug)) continue;
|
|
2317
|
+
if (managedUrl(verified.get(slug), slug) !== url) {
|
|
2318
|
+
throw new Error("Grok MCP server " + slug + " failed verification");
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
for (const slug of removals) {
|
|
2322
|
+
if (verified.has(slug)) {
|
|
2323
|
+
throw new Error("Grok MCP server " + slug + " was not removed");
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
for (const warning of warnings) process.stderr.write(warning);
|
|
2327
|
+
`.trim();
|
|
2328
|
+
|
|
2329
|
+
function shellQuote(value: string): string {
|
|
2330
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
/** Config homes are independent, but every Uai/browser writer must remain
|
|
2334
|
+
* behind one flock. Run a bounded batch at a time so the supported account ×
|
|
2335
|
+
* agent count cannot turn the outer timeout into a deterministic failure. */
|
|
2336
|
+
const MCP_CONFIG_HOME_WRITE_CONCURRENCY = 8;
|
|
2337
|
+
|
|
2338
|
+
function boundedParallelShell(
|
|
2339
|
+
commands: readonly string[],
|
|
2340
|
+
width = MCP_CONFIG_HOME_WRITE_CONCURRENCY,
|
|
2341
|
+
): string | null {
|
|
2342
|
+
if (commands.length === 0) return null;
|
|
2343
|
+
const batches: string[] = [];
|
|
2344
|
+
for (let offset = 0; offset < commands.length; offset += width) {
|
|
2345
|
+
const batch = commands.slice(offset, offset + width);
|
|
2346
|
+
const launches = batch.map(
|
|
2347
|
+
(command, index) => `(${command}) & uai_mcp_pid_${index}=$!`,
|
|
2348
|
+
);
|
|
2349
|
+
const waits = batch.map(
|
|
2350
|
+
(_command, index) =>
|
|
2351
|
+
`wait "$uai_mcp_pid_${index}" || uai_mcp_status=1`,
|
|
2352
|
+
);
|
|
2353
|
+
batches.push(
|
|
2354
|
+
`{ ${launches.join("; ")}; uai_mcp_status=0; ${waits.join("; ")}; ` +
|
|
2355
|
+
`[ "$uai_mcp_status" -eq 0 ]; }`,
|
|
2356
|
+
);
|
|
2357
|
+
}
|
|
2358
|
+
return batches.join(" && ");
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// The host must never win this timeout race: killing the local `docker exec`
|
|
2362
|
+
// client does not kill its in-container child. The in-runtime deadline scales
|
|
2363
|
+
// with bounded home batches below; the host backstop always follows TERM/KILL.
|
|
2364
|
+
/** The grok CLI inside task containers/machines (x.ai installer path). */
|
|
2365
|
+
const GROK_BIN = "/home/node/.grok/bin/grok";
|
|
2366
|
+
const MCP_CONFIG_MIN_WRITE_TIMEOUT_S = 25;
|
|
2367
|
+
const MCP_CONFIG_WRITE_KILL_AFTER_S = 5;
|
|
2368
|
+
const MCP_CONFIG_HOST_GRACE_S = 10;
|
|
2369
|
+
const MCP_CONFIG_CODEX_BATCH_BUDGET_S = 3;
|
|
2370
|
+
// One home can replace/remove two disjoint maximum-size memberships. Grok's
|
|
2371
|
+
// CLI has no bulk mutation, so give a bounded batch a budget proportional to
|
|
2372
|
+
// the accepted manifest ceiling rather than pretending it is constant work.
|
|
2373
|
+
const MCP_CONFIG_GROK_BATCH_BUDGET_S = 2 + Math.ceil(MAX_MANIFEST_MCP / 4);
|
|
2374
|
+
|
|
2375
|
+
/**
|
|
2376
|
+
* Write the task's MCP configs inside the container, one shape per engine on
|
|
2377
|
+
* the roster — all pointing at the same host gateway URLs:
|
|
2378
|
+
* - Claude — /workspace/.mcp.json (merged; coexists with the ADR-053 browser
|
|
2379
|
+
* server). Written unconditionally: the adapter passes `--mcp-config`.
|
|
2380
|
+
* - Codex — reconciled mcp-remote shims in config.toml.
|
|
2381
|
+
* - Cursor — ~/.cursor/mcp.json ({ url } form; the adapter passes
|
|
2382
|
+
* `--approve-mcps`).
|
|
2383
|
+
* - Grok — reconciled through `grok mcp` in every GROK_HOME the task may use
|
|
2384
|
+
* (project scope is refused in an untrusted folder).
|
|
2385
|
+
* - OpenCode — ~/.config/opencode/opencode.json `mcp` block (type:"remote",
|
|
2386
|
+
* oauth:false so it treats the gateway URL as a plain server; the path
|
|
2387
|
+
* token is the auth).
|
|
2388
|
+
* Safe to re-run every ensure. `engineKinds` is the roster's agent kinds.
|
|
2389
|
+
*/
|
|
2390
|
+
export async function setupMcpTaskConfig(
|
|
2391
|
+
taskId: string,
|
|
2392
|
+
containerName: string,
|
|
2393
|
+
connections: TaskMcpConnection[],
|
|
2394
|
+
engineKinds: string[],
|
|
2395
|
+
codexHomes: readonly string[] = ["/home/node/.codex"],
|
|
2396
|
+
environment?: Pick<TaskEnvironmentHandle, "exec"> &
|
|
2397
|
+
Partial<Pick<TaskEnvironmentHandle, "descriptor">>,
|
|
2398
|
+
/** Every GROK_HOME the task may run under (default + account homes). */
|
|
2399
|
+
grokHomes: readonly string[] = ["/home/node/.grok"],
|
|
2400
|
+
): Promise<boolean> {
|
|
2401
|
+
// No early return on empty: the claude adapter passes
|
|
2402
|
+
// `--mcp-config /workspace/.mcp.json` unconditionally (ADR-057), so the
|
|
2403
|
+
// file must exist — an empty mcpServers map — even with no connections.
|
|
2404
|
+
try {
|
|
2405
|
+
// Boot-time standard-image maintenance can briefly replace the shared CLI
|
|
2406
|
+
// shims used by these in-container writers. The ACL is itself observable
|
|
2407
|
+
// state, so hold the same barrier before it or any Docker side effect.
|
|
2408
|
+
await agentClisReady;
|
|
2409
|
+
const has = (kind: string): boolean => engineKinds.includes(kind);
|
|
2410
|
+
if (
|
|
2411
|
+
has("codex") &&
|
|
2412
|
+
connections.some(
|
|
2413
|
+
(connection) =>
|
|
2414
|
+
!/^[a-z0-9][a-z0-9-]{0,63}$/.test(connection.slug),
|
|
2415
|
+
)
|
|
2416
|
+
) {
|
|
2417
|
+
throw new Error("invalid Codex MCP connection slug");
|
|
2418
|
+
}
|
|
2419
|
+
const acl = ensureTaskGatewayAcl(taskId, connections);
|
|
2420
|
+
// ADR-121: a machine dials the gateway through its ssh REVERSE tunnel —
|
|
2421
|
+
// the host's loopback appears at the machine's own 127.0.0.1:PORT
|
|
2422
|
+
// (machine-gateway-tunnel.ts). host.docker.internal / vmnet addresses
|
|
2423
|
+
// are container topology a machine can never route.
|
|
2424
|
+
const gatewayHost =
|
|
2425
|
+
isEnvironmentTransportProvider(environment?.descriptor?.locator.provider)
|
|
2426
|
+
? "127.0.0.1"
|
|
2427
|
+
: await taskGatewayHost();
|
|
2428
|
+
const urlFor = (slug: string): string =>
|
|
2429
|
+
`http://${gatewayHost}:${MCP_GATEWAY_PORT}/t/${acl.token}/${slug}`;
|
|
2430
|
+
|
|
2431
|
+
const claudeEntries: Record<string, unknown> = {};
|
|
2432
|
+
const codexEntries: Record<string, string> = {};
|
|
2433
|
+
const cursorEntries: Record<string, unknown> = {};
|
|
2434
|
+
const opencodeEntries: Record<string, unknown> = {};
|
|
2435
|
+
for (const c of connections) {
|
|
2436
|
+
claudeEntries[c.slug] = { type: "http", url: urlFor(c.slug) };
|
|
2437
|
+
codexEntries[c.slug] = urlFor(c.slug);
|
|
2438
|
+
// Cursor's mcp.json wants a bare { url } for remote (http/sse) servers.
|
|
2439
|
+
cursorEntries[c.slug] = { url: urlFor(c.slug) };
|
|
2440
|
+
// OpenCode: type:"remote" + oauth:false — the gateway needs no client
|
|
2441
|
+
// auth (the path token is the secret), so suppress OpenCode's own OAuth
|
|
2442
|
+
// flow that would otherwise fire on a gateway 401 and hang headless.
|
|
2443
|
+
opencodeEntries[c.slug] = {
|
|
2444
|
+
type: "remote",
|
|
2445
|
+
url: urlFor(c.slug),
|
|
2446
|
+
enabled: true,
|
|
2447
|
+
oauth: false,
|
|
2448
|
+
};
|
|
2449
|
+
}
|
|
2450
|
+
const codexWrites = has("codex")
|
|
2451
|
+
? codexHomes.map(
|
|
2452
|
+
(home) =>
|
|
2453
|
+
`node -e "$uai_codex_mcp_writer" ${shellQuote(home)} ` +
|
|
2454
|
+
`"$uai_mcp_desired" codex "$uai_mcp_task"`,
|
|
2455
|
+
)
|
|
2456
|
+
: [];
|
|
2457
|
+
const grokWrites = has("grok")
|
|
2458
|
+
? grokHomes
|
|
2459
|
+
.filter((home) => /^\/home\/node\/\.grok(-acct-[A-Za-z0-9_-]+)?$/.test(home))
|
|
2460
|
+
.map(
|
|
2461
|
+
(home) =>
|
|
2462
|
+
`node -e "$uai_grok_mcp_writer" ${shellQuote(home)} ` +
|
|
2463
|
+
`"$uai_mcp_desired" ${GROK_BIN} "$uai_mcp_task"`,
|
|
2464
|
+
)
|
|
2465
|
+
: [];
|
|
2466
|
+
const homeWrites = boundedParallelShell([...codexWrites, ...grokWrites]);
|
|
2467
|
+
const codexBatches = Math.ceil(
|
|
2468
|
+
codexWrites.length / MCP_CONFIG_HOME_WRITE_CONCURRENCY,
|
|
2469
|
+
);
|
|
2470
|
+
const grokBatches = Math.ceil(
|
|
2471
|
+
grokWrites.length / MCP_CONFIG_HOME_WRITE_CONCURRENCY,
|
|
2472
|
+
);
|
|
2473
|
+
const writeTimeoutS = Math.max(
|
|
2474
|
+
MCP_CONFIG_MIN_WRITE_TIMEOUT_S,
|
|
2475
|
+
10 +
|
|
2476
|
+
codexBatches * MCP_CONFIG_CODEX_BATCH_BUDGET_S +
|
|
2477
|
+
grokBatches * MCP_CONFIG_GROK_BATCH_BUDGET_S,
|
|
2478
|
+
);
|
|
2479
|
+
const hostTimeoutMs =
|
|
2480
|
+
(writeTimeoutS +
|
|
2481
|
+
MCP_CONFIG_WRITE_KILL_AFTER_S +
|
|
2482
|
+
MCP_CONFIG_HOST_GRACE_S) *
|
|
2483
|
+
1_000;
|
|
2484
|
+
// Browser migration touches these same Claude/Codex/Cursor files and
|
|
2485
|
+
// invokes Grok's own read-modify-write CLI. Keep every shared writer in
|
|
2486
|
+
// one critical section so neither side can erase the other's update.
|
|
2487
|
+
const lockedWrites = [
|
|
2488
|
+
`[ -f /workspace/.claude/settings.json ] || printf '%s\\n' ${shellQuote(
|
|
2489
|
+
JSON.stringify({ enableAllProjectMcpServers: true }, null, 2),
|
|
2490
|
+
)} > /workspace/.claude/settings.json`,
|
|
2491
|
+
`node -e ${shellQuote(
|
|
2492
|
+
MERGE_MCP_JSON,
|
|
2493
|
+
)} /workspace/.mcp.json ${shellQuote(JSON.stringify(claudeEntries))} mcpServers claude ${shellQuote(
|
|
2494
|
+
taskId,
|
|
2495
|
+
)}`,
|
|
2496
|
+
...(homeWrites === null
|
|
2497
|
+
? []
|
|
2498
|
+
: [
|
|
2499
|
+
`uai_mcp_desired=${shellQuote(JSON.stringify(codexEntries))}`,
|
|
2500
|
+
`uai_mcp_task=${shellQuote(taskId)}`,
|
|
2501
|
+
...(codexWrites.length === 0
|
|
2502
|
+
? []
|
|
2503
|
+
: [`uai_codex_mcp_writer=${shellQuote(ENSURE_CODEX_MCP_TOML)}`]),
|
|
2504
|
+
...(grokWrites.length === 0
|
|
2505
|
+
? []
|
|
2506
|
+
: [`uai_grok_mcp_writer=${shellQuote(RECONCILE_GROK_MCP)}`]),
|
|
2507
|
+
]),
|
|
2508
|
+
// Codex/Grok homes do not share files. Bound their parallelism while
|
|
2509
|
+
// holding the one cross-writer lock; each home still reconciles itself
|
|
2510
|
+
// serially through its engine's parser/writer.
|
|
2511
|
+
...(homeWrites === null ? [] : [homeWrites]),
|
|
2512
|
+
...(has("cursor")
|
|
2513
|
+
? [
|
|
2514
|
+
`node -e ${shellQuote(MERGE_MCP_JSON)} /home/node/.cursor/mcp.json ${shellQuote(
|
|
2515
|
+
JSON.stringify(cursorEntries),
|
|
2516
|
+
)} mcpServers cursor ${shellQuote(taskId)}`,
|
|
2517
|
+
]
|
|
2518
|
+
: []),
|
|
2519
|
+
].join(" && ");
|
|
2520
|
+
const writes = [
|
|
2521
|
+
"mkdir -p /workspace/.claude",
|
|
2522
|
+
`flock -w 10 ${MCP_CONFIG_LOCK_PATH} sh -lc ${shellQuote(lockedWrites)}`,
|
|
2523
|
+
// OpenCode: global config `mcp` block (auto-discovered regardless of the
|
|
2524
|
+
// exec cwd, and independent of any per-account OPENCODE_DATA_DIR).
|
|
2525
|
+
...(has("opencode")
|
|
2526
|
+
? [
|
|
2527
|
+
`node -e ${shellQuote(MERGE_MCP_JSON)} /home/node/.config/opencode/opencode.json ${shellQuote(
|
|
2528
|
+
JSON.stringify(opencodeEntries),
|
|
2529
|
+
)} mcp opencode ${shellQuote(taskId)}`,
|
|
2530
|
+
]
|
|
2531
|
+
: []),
|
|
2532
|
+
].join(" && ");
|
|
2533
|
+
const steps =
|
|
2534
|
+
`timeout --kill-after=${MCP_CONFIG_WRITE_KILL_AFTER_S} ` +
|
|
2535
|
+
`${writeTimeoutS} sh -lc ${shellQuote(writes)}`;
|
|
2536
|
+
const result = environment
|
|
2537
|
+
? await environment.exec({
|
|
2538
|
+
argv: ["sh", "-lc", steps],
|
|
2539
|
+
env: RUNTIME_AUTHORITY_ENV,
|
|
2540
|
+
projectEnvProfile: "maintenance",
|
|
2541
|
+
timeoutMs: hostTimeoutMs,
|
|
2542
|
+
maxOutputBytes: 256 * 1024,
|
|
2543
|
+
})
|
|
2544
|
+
: await taskContainerCli(
|
|
2545
|
+
[
|
|
2546
|
+
"exec",
|
|
2547
|
+
...runtimeAuthorityDockerExecArgs(),
|
|
2548
|
+
// Backend-aware fallback: the passed name is the Docker replica
|
|
2549
|
+
// shape and `docker` does not exist on an apple host (live
|
|
2550
|
+
// 2026-08-18: spawn docker ENOENT killed every no-handle config
|
|
2551
|
+
// write, so sessions never saw their MCP tools).
|
|
2552
|
+
taskContainerBackend().apple
|
|
2553
|
+
? taskAppContainerName(taskId)
|
|
2554
|
+
: containerName,
|
|
2555
|
+
"sh",
|
|
2556
|
+
"-lc",
|
|
2557
|
+
steps,
|
|
2558
|
+
],
|
|
2559
|
+
{ timeoutMs: hostTimeoutMs },
|
|
2560
|
+
);
|
|
2561
|
+
const status = "exitCode" in result ? result.exitCode : result.status;
|
|
2562
|
+
const stderrValue = "stderr" in result ? result.stderr : "";
|
|
2563
|
+
const stderr = stderrValue instanceof Uint8Array
|
|
2564
|
+
? Buffer.from(stderrValue).toString("utf8")
|
|
2565
|
+
: String(stderrValue ?? "");
|
|
2566
|
+
if (status !== 0) {
|
|
2567
|
+
// Earlier writers in the && chain may have succeeded with local-shadow
|
|
2568
|
+
// warnings before a later writer failed. Remove only those known soft
|
|
2569
|
+
// lines so the bounded log cannot hide the hard parse/I/O diagnostic.
|
|
2570
|
+
const hardStderr = stderr
|
|
2571
|
+
.split(/\r?\n/)
|
|
2572
|
+
.filter((line) =>
|
|
2573
|
+
!/^\[uai-mcp\] kept (?:foreign|customized) .+; skipped its Uai (?:definition|removal) in /.test(line),
|
|
2574
|
+
)
|
|
2575
|
+
.join("\n")
|
|
2576
|
+
.trim();
|
|
2577
|
+
console.warn(
|
|
2578
|
+
`[mcp-gateway] task ${taskId}: config write failed: ${(hardStderr || stderr).slice(0, 300)}`,
|
|
2579
|
+
);
|
|
2580
|
+
return false;
|
|
2581
|
+
}
|
|
2582
|
+
if (stderr.trim().length > 0) {
|
|
2583
|
+
console.warn(
|
|
2584
|
+
`[mcp-gateway] task ${taskId}: config write warning: ${stderr.slice(0, 300)}`,
|
|
2585
|
+
);
|
|
2586
|
+
}
|
|
2587
|
+
return true;
|
|
2588
|
+
} catch (err) {
|
|
2589
|
+
console.warn(
|
|
2590
|
+
`[mcp-gateway] task ${taskId}: setup failed: ${err instanceof Error ? err.message : err}`,
|
|
2591
|
+
);
|
|
2592
|
+
return false;
|
|
2593
|
+
}
|
|
2594
|
+
}
|