Skip to content

Commit 311ba5c

Browse files
fix(local-mcp): make the relay always-ask gate fire for codex too
The relay always-ask permission gate keyed off Claude's `_meta.claudeCode.toolName`, which codex never writes — codex populates the neutral `_meta.posthog` channel. For codex the gate silently no-op'd, so in non-asking permission modes a relayed tool could auto-run on the user's machine without a prompt. Read the server through the adapter-neutral `readMcpToolDescriptor` (with the Claude `rawInput.toolName` fallback) so the gate fires for both adapters. Also re-append the loopback relay entries on refresh_session (Django's refresh rebuilds the list without them, whose URL and per-run bearer live only in the agent-server), and cover both fixes plus the codex `_meta.posthog` gate shape with tests. Generated-By: PostHog Code Task-Id: 7c87c3a4-0be6-475e-a8ec-269140ded301
1 parent 6416ba1 commit 311ba5c

5 files changed

Lines changed: 243 additions & 31 deletions

File tree

docs/cloud-mcp-import.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ private-network URLs) need the desktop **relay** — see
5050
just stays desktop-only, while the reverse would ship an unreachable server
5151
(or leak headers) to the sandbox.
5252

53-
3. **Show** — the cloud task composer renders `LocalMcpServersButton`
54-
(`packages/ui/src/features/task-detail/components/LocalMcpServersButton.tsx`),
55-
a list of the user's servers annotated "Available in cloud" / "Requires
56-
your machine" / "Not available in cloud".
53+
3. **Show** — the MCP servers view rail renders `LocalMcpRailSection`
54+
(`packages/ui/src/features/local-mcp/LocalMcpRailSection.tsx`), listing the
55+
user's local servers annotated "Available in cloud" / "Relayed via your
56+
machine" / "Built into cloud runs" / "Not available in cloud".
5757

5858
4. **Send** — importable servers are included in the run-creation payload
5959
(`imported_mcp_servers` in `buildCloudRunRequestBody`,

docs/cloud-mcp-relay.md

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -95,21 +95,30 @@ Per incoming HTTP request:
9595
JSON-RPC *notifications* (no `id`) are relayed fire-and-forget: emit the
9696
event, answer 202 immediately.
9797

98-
Liveness: relay endpoints 503 when no client can service the request,
99-
reusing the permission relay's `hasReachableClient` (a direct SSE viewer OR
100-
an active durable event stream). An earlier design used a stricter
101-
`desktopSeenAt` timestamp, but in the durable-ingest topology the desktop
102-
reads the run's stream through the agent-proxy and never connects to the
103-
sandbox, so that stricter signal 503s every request — `hasReachableClient`
104-
is the correct gate. Relay endpoints only exist when a desktop designated
98+
Liveness: the relay endpoints use the permission relay's `hasReachableClient`
99+
signal (a direct SSE viewer OR an active durable event stream) but must not
100+
503 during the ~2 s startup window before the first client attaches — an MCP
101+
client connects to each server once at session start, so a 503 there drops
102+
the server for the whole run. So the endpoint tracks `everReachable` and only
103+
503s once a client has been reachable and then went away (a genuine mid-run
104+
desktop disconnect). Before the first client ever attaches, the request is
105+
buffered — `broadcastEvent` already buffers-and-replays events until a
106+
controller attaches — and resolves when the client arrives or the request
107+
times out. (An earlier design gated purely on a `desktopSeenAt` timestamp,
108+
which 503'd the startup handshake in the durable-ingest topology, where the
109+
desktop reads the run's stream through the agent-proxy and never connects to
110+
the sandbox directly; that's why the gate is `everReachable`-then-lost, not
111+
"seen recently".) Relay endpoints only exist when a desktop designated
105112
servers at creation, so a non-headless run is the precondition anyway. So:
106113

107-
- Claude gets an MCP connection error it reports cleanly, not a 60 s hang.
108-
- Codex-style reachability probes (which treat any HTTP response as
109-
reachable but connection failures as not — see `isMcpServerReachable` in
110-
`packages/workspace-server/src/services/agent/agent.ts`) must be extended
111-
in the sandbox to treat 503 from loopback relay endpoints as unreachable
112-
when pruning for Codex sessions.
114+
- Claude gets a clean MCP error on a genuine mid-run disconnect, not a 60 s
115+
hang, and its session-start handshake is never dropped by a startup 503.
116+
- Codex reachability probes treat any HTTP response (including the buffered
117+
200 or a 503) as reachable and connection failures as not — see
118+
`isMcpServerReachable` in
119+
`packages/workspace-server/src/services/agent/agent.ts` — so a loopback
120+
relay endpoint always probes as reachable and is never pruned from a Codex
121+
session.
113122

114123
Headless-started runs (web/mobile/Slack) have no desktop: those creation
115124
paths never declare relayed servers, so the endpoints simply don't exist.
@@ -232,7 +241,8 @@ persisted, with caps and the no-secrets rule above.**
232241

233242
| Failure | Behavior |
234243
| --- | --- |
235-
| Desktop offline at call time | Loopback endpoint 503s; agent sees "requires the desktop app" MCP error; Codex pruning treats the server as unreachable. |
244+
| Desktop never attaches (offline from session start) | Request buffers until it times out (60 s) → agent gets JSON-RPC `-32001`; the endpoint does **not** 503 during startup (that would drop the server for the whole run). |
245+
| Desktop disconnects mid-run (was reachable, now gone) | Endpoint 503s (`everReachable && !reachable`); agent sees a clean "requires the desktop app" MCP error rather than a hang. |
236246
| Desktop disconnects mid-call | Sandbox timeout fires (60 s), agent gets JSON-RPC `-32001`. |
237247
| Desktop reconnects after backlog | Replayed `mcp_request` events past `expiresAt` are dropped; unexpired ones are deduped by `requestId`. |
238248
| stdio process crashes | Desktop replies with `error`; next request lazily respawns. |
@@ -241,8 +251,9 @@ persisted, with caps and the no-secrets rule above.**
241251
## Testing plan
242252

243253
- Sandbox: unit tests for the correlation map — resolve, timeout, late
244-
response, oversized payload, notification fire-and-forget, 503 when no
245-
desktop seen (Vitest, faked clock).
254+
response, oversized payload, notification fire-and-forget, startup
255+
buffering before any client attaches, and 503 only after a client was
256+
reachable and then went away (Vitest).
246257
- Desktop: unit tests for `McpRelayService` — name designation enforcement,
247258
requestId dedupe, expiry drop, stdio spawn failure → error reply (faked
248259
MCP client).

packages/agent/src/server/agent-server.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,6 +1324,25 @@ describe("AgentServer HTTP Mode", () => {
13241324
};
13251325
}
13261326

1327+
// Codex never writes `_meta.claudeCode`; it populates the neutral
1328+
// `_meta.posthog` channel with a structured `mcp` descriptor and sets
1329+
// rawInput to the tool arguments (no toolName). The gate must still fire.
1330+
function codexPermissionRequestFor(server: string, tool: string) {
1331+
return {
1332+
options: [{ optionId: "allow_once", kind: "allow_once" }],
1333+
toolCall: {
1334+
kind: "other",
1335+
_meta: {
1336+
posthog: {
1337+
toolName: `mcp__${server}__${tool}`,
1338+
mcp: { server, tool },
1339+
},
1340+
},
1341+
rawInput: { some: "arg" },
1342+
},
1343+
};
1344+
}
1345+
13271346
const basePayload = {
13281347
run_id: "run-1",
13291348
task_id: "task-1",
@@ -1425,6 +1444,122 @@ describe("AgentServer HTTP Mode", () => {
14251444
optionId: "allow_once",
14261445
});
14271446
});
1447+
1448+
it("treats a codex relay tool call (posthog _meta channel) as always-ask", async () => {
1449+
const testServer = exposeCloudClient(createServer());
1450+
testServer.config.relayMcpServers = ["slack"];
1451+
testServer.session = { hasDesktopConnected: true };
1452+
const relaySpy = vi
1453+
.spyOn(testServer, "relayPermissionToClient")
1454+
.mockResolvedValue({
1455+
outcome: { outcome: "selected", optionId: "allow_once" },
1456+
});
1457+
1458+
const { requestPermission } = testServer.createCloudClient(basePayload);
1459+
await requestPermission(
1460+
codexPermissionRequestFor("slack", "send_message"),
1461+
);
1462+
1463+
expect(relaySpy).toHaveBeenCalledOnce();
1464+
});
1465+
1466+
it("denies a codex relay tool call when no client is reachable", async () => {
1467+
const testServer = exposeCloudClient(createServer());
1468+
testServer.config.relayMcpServers = ["slack"];
1469+
testServer.session = null;
1470+
testServer.eventStreamSender = null;
1471+
const relaySpy = vi.spyOn(testServer, "relayPermissionToClient");
1472+
1473+
const { requestPermission } = testServer.createCloudClient(basePayload);
1474+
const result = await requestPermission(
1475+
codexPermissionRequestFor("slack", "send_message"),
1476+
);
1477+
1478+
expect(relaySpy).not.toHaveBeenCalled();
1479+
expect(result.outcome).toEqual({ outcome: "cancelled" });
1480+
});
1481+
});
1482+
1483+
describe("refresh_session relay re-append", () => {
1484+
function exposeRefresh(testServer: AgentServer) {
1485+
return testServer as unknown as {
1486+
session: {
1487+
clientConnection: { extMethod: ReturnType<typeof vi.fn> };
1488+
} | null;
1489+
mcpRelayServer: { mcpServers: unknown[] } | null;
1490+
executeCommand(
1491+
method: string,
1492+
params: Record<string, unknown>,
1493+
): Promise<unknown>;
1494+
};
1495+
}
1496+
1497+
it("re-appends the loopback relay entries so a refresh doesn't drop them", async () => {
1498+
const testServer = exposeRefresh(createServer());
1499+
const extMethod = vi.fn(async () => ({ refreshed: true }));
1500+
testServer.session = { clientConnection: { extMethod } };
1501+
const relayEntry = {
1502+
type: "http",
1503+
name: "slack",
1504+
url: "http://127.0.0.1:5555/relay/slack",
1505+
headers: [{ name: "Authorization", value: "Bearer secret" }],
1506+
};
1507+
testServer.mcpRelayServer = { mcpServers: [relayEntry] };
1508+
1509+
// Django's refresh list carries posthog + imported, never the relay entries.
1510+
await testServer.executeCommand("refresh_session", {
1511+
mcpServers: [
1512+
{ type: "http", name: "posthog", url: "https://mcp", headers: [] },
1513+
],
1514+
});
1515+
1516+
expect(extMethod).toHaveBeenCalledOnce();
1517+
const forwarded = (extMethod.mock.calls[0] as unknown[])[1] as {
1518+
mcpServers: Array<{ name: string }>;
1519+
};
1520+
expect(forwarded.mcpServers.map((s) => s.name)).toEqual([
1521+
"posthog",
1522+
"slack",
1523+
]);
1524+
1525+
// Detach the fake session so afterEach's stop() short-circuits before
1526+
// touching the partial session/relay stubs.
1527+
testServer.session = null;
1528+
});
1529+
1530+
it("does not duplicate a relay entry already present in the refresh list", async () => {
1531+
const testServer = exposeRefresh(createServer());
1532+
const extMethod = vi.fn(async () => ({ refreshed: true }));
1533+
testServer.session = { clientConnection: { extMethod } };
1534+
testServer.mcpRelayServer = {
1535+
mcpServers: [
1536+
{
1537+
type: "http",
1538+
name: "slack",
1539+
url: "http://127.0.0.1:5555/relay/slack",
1540+
headers: [],
1541+
},
1542+
],
1543+
};
1544+
1545+
await testServer.executeCommand("refresh_session", {
1546+
mcpServers: [
1547+
{
1548+
type: "http",
1549+
name: "slack",
1550+
url: "http://127.0.0.1:5555/relay/slack",
1551+
headers: [],
1552+
},
1553+
],
1554+
});
1555+
1556+
const forwarded = (extMethod.mock.calls[0] as unknown[])[1] as {
1557+
mcpServers: Array<{ name: string }>;
1558+
};
1559+
expect(forwarded.mcpServers.map((s) => s.name)).toEqual(["slack"]);
1560+
1561+
testServer.session = null;
1562+
});
14281563
});
14291564

14301565
describe("GET /events", () => {

packages/agent/src/server/agent-server.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
buildPrOutput,
2222
getErrorMessage,
2323
mergePrUrls,
24+
readMcpToolDescriptor,
2425
readPrUrls,
2526
} from "@posthog/shared";
2627
import { unzipSync } from "fflate";
@@ -1180,13 +1181,31 @@ export class AgentServer {
11801181
return { refreshed: true };
11811182
}
11821183

1184+
// refresh_session replaces the session's MCP server list wholesale, and
1185+
// Django's refresh rebuilds it from posthog + user + imported configs —
1186+
// it can't include the relay loopback entries, whose URL and per-run
1187+
// bearer live only here. Re-append them so a mid-run refresh (token
1188+
// rotation, follow-up past the refresh window) doesn't silently drop
1189+
// every relayed server from the session.
1190+
const relayServers = this.mcpRelayServer?.mcpServers ?? [];
1191+
const refreshedMcpServers = [
1192+
...mcpServers,
1193+
...relayServers.filter(
1194+
(relay) =>
1195+
!mcpServers.some(
1196+
(s: { name?: unknown }) => s?.name === relay.name,
1197+
),
1198+
),
1199+
];
1200+
11831201
this.logger.debug("Refresh session requested", {
1184-
serverCount: mcpServers.length,
1202+
serverCount: refreshedMcpServers.length,
1203+
relayServerCount: relayServers.length,
11851204
});
11861205

11871206
return await this.session.clientConnection.extMethod(
11881207
POSTHOG_METHODS.REFRESH_SESSION,
1189-
{ mcpServers },
1208+
{ mcpServers: refreshedMcpServers },
11901209
);
11911210
}
11921211

@@ -3638,19 +3657,21 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
36383657
// (docs/cloud-mcp-relay.md). Without a reachable client, deny rather
36393658
// than auto-approve.
36403659
{
3641-
const meta = params.toolCall?._meta as
3642-
| { claudeCode?: { toolName?: string } }
3643-
| undefined;
3660+
// Read the MCP server through the adapter-neutral `_meta.posthog`
3661+
// channel (codex writes `_meta.posthog.mcp`, Claude writes the legacy
3662+
// `_meta.claudeCode.toolName`; readMcpToolDescriptor handles both),
3663+
// falling back to Claude's `rawInput.toolName`. Keying off only the
3664+
// Claude channel would silently skip this gate for codex and let a
3665+
// relayed tool auto-run in non-asking modes.
36443666
const rawInput = params.toolCall?.rawInput as
36453667
| { toolName?: string }
36463668
| undefined;
3647-
const permissionToolName =
3648-
meta?.claudeCode?.toolName ?? rawInput?.toolName;
36493669
const mcpServerName =
3650-
typeof permissionToolName === "string" &&
3651-
permissionToolName.startsWith("mcp__")
3652-
? permissionToolName.split("__")[1]
3653-
: undefined;
3670+
readMcpToolDescriptor(params.toolCall?._meta)?.server ??
3671+
(typeof rawInput?.toolName === "string" &&
3672+
rawInput.toolName.startsWith("mcp__")
3673+
? rawInput.toolName.split("__")[1]
3674+
: undefined);
36543675
if (
36553676
mcpServerName &&
36563677
(this.config.relayMcpServers ?? []).includes(mcpServerName)

packages/core/src/cloud-task/cloud-task.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3283,4 +3283,49 @@ describe("CloudTaskService MCP relay", () => {
32833283
),
32843284
).toBe(false);
32853285
});
3286+
3287+
it("posts a -32000 mcp_response when the executor throws", async () => {
3288+
mcpRelayExecutor.execute.mockRejectedValueOnce(new Error("spawn failed"));
3289+
mockStreamFetch.mockResolvedValueOnce(
3290+
createOpenSseResponse(
3291+
mcpRequestSseLine({ requestId: "req-1", server: "slack" }),
3292+
),
3293+
);
3294+
mockNetFetch.mockResolvedValueOnce(createJsonResponse({ result: {} }));
3295+
relayService.designateRelayedMcpServers("run-1", ["slack"]);
3296+
watchRun("run-1");
3297+
3298+
await waitFor(() =>
3299+
mockNetFetch.mock.calls.some(([url]) =>
3300+
(url as string).includes("/command/"),
3301+
),
3302+
);
3303+
const commandCall = mockNetFetch.mock.calls.find(([url]) =>
3304+
(url as string).includes("/command/"),
3305+
);
3306+
const body = JSON.parse((commandCall?.[1] as RequestInit).body as string);
3307+
// A thrown executor error must still answer the sandbox — otherwise it hangs to timeout.
3308+
expect(body).toEqual(
3309+
expect.objectContaining({
3310+
method: "mcp_response",
3311+
params: {
3312+
requestId: "req-1",
3313+
server: "slack",
3314+
error: { code: -32000, message: "spawn failed" },
3315+
},
3316+
}),
3317+
);
3318+
});
3319+
3320+
it("closes the run's relay connections when the run is unwatched", async () => {
3321+
mockStreamFetch.mockResolvedValue(createOpenSseResponse(""));
3322+
relayService.designateRelayedMcpServers("run-1", ["slack"]);
3323+
watchRun("run-1");
3324+
await waitFor(() => mockStreamFetch.mock.calls.length > 0);
3325+
3326+
relayService.unwatch("task-1", "run-1");
3327+
await waitFor(() => mcpRelayExecutor.closeRun.mock.calls.length > 0);
3328+
3329+
expect(mcpRelayExecutor.closeRun).toHaveBeenCalledWith("run-1");
3330+
});
32863331
});

0 commit comments

Comments
 (0)