fix: distinguish local vs remote gatewayUrl before suppressing deliveryContext

isRemoteGateway was inferred from gatewayUrl being present, but gatewayUrl
overrides are valid for loopback/local targets too (ws://127.0.0.1:<port>,
localhost, [::1]). These local-override calls should still forward
deliveryContext — treating them as remote falls back to
extractDeliveryInfo(sessionKey) and reintroduces the stale heartbeat routing
this patch was meant to fix.

Fix: export resolveGatewayTarget() from gateway.ts (returns 'local' | 'remote'
| undefined) and use it instead of Boolean(gatewayUrl?.trim()). Only
gatewayUrl values that classify as 'remote' now suppress deliveryContext.

Adds test coverage for the local loopback case.
This commit is contained in:
Bryan Marty 2026-03-09 16:46:16 +00:00
parent 33c24858ff
commit e705d9b23d
No known key found for this signature in database
3 changed files with 59 additions and 3 deletions

View File

@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
formatDoctorNonInteractiveHint: vi.fn(() => ""),
callGatewayTool: vi.fn(async () => ({})),
readGatewayCallOptions: vi.fn(() => ({})),
resolveGatewayTarget: vi.fn(() => undefined),
}));
vi.mock("../../config/commands.js", () => ({ isRestartEnabled: mocks.isRestartEnabled }));
@ -31,6 +32,7 @@ vi.mock("../../infra/restart.js", () => ({
vi.mock("./gateway.js", () => ({
callGatewayTool: mocks.callGatewayTool,
readGatewayCallOptions: mocks.readGatewayCallOptions,
resolveGatewayTarget: mocks.resolveGatewayTarget,
}));
import { createGatewayTool } from "./gateway-tool.js";
@ -174,6 +176,7 @@ describe("createGatewayTool live delivery context guard", () => {
// destination on the remote host.
mocks.callGatewayTool.mockClear();
mocks.readGatewayCallOptions.mockReturnValueOnce({ gatewayUrl: "wss://remote-gw.example.com" });
mocks.resolveGatewayTarget.mockReturnValueOnce("remote");
const tool = createGatewayTool({
agentSessionKey: "agent:main:main",
agentChannel: "discord",
@ -193,6 +196,37 @@ describe("createGatewayTool live delivery context guard", () => {
expect(forwardedParams?.deliveryContext).toBeUndefined();
});
it("forwards live RPC delivery context when gatewayUrl is a local loopback override", async () => {
// A gatewayUrl pointing to 127.0.0.1/localhost/[::1] is still the local server;
// deliveryContext must be forwarded so restart sentinels use the correct chat destination.
mocks.callGatewayTool.mockClear();
mocks.readGatewayCallOptions.mockReturnValueOnce({
gatewayUrl: "ws://127.0.0.1:18789",
});
mocks.resolveGatewayTarget.mockReturnValueOnce("local");
const tool = createGatewayTool({
agentSessionKey: "agent:main:main",
agentChannel: "discord",
agentTo: "123456789",
});
await execTool(tool, {
action: "config.patch",
raw: '{"key":"value"}',
baseHash: "abc123",
sessionKey: "agent:main:main",
note: "local loopback patch",
gatewayUrl: "ws://127.0.0.1:18789",
});
const forwardedParams = getCallArg<Record<string, unknown>>(mocks.callGatewayTool, 0, 2);
expect(forwardedParams?.deliveryContext).toEqual({
channel: "discord",
to: "123456789",
accountId: undefined,
});
});
it("does not forward live RPC delivery context when a non-default agent passes sessionKey='main'", async () => {
// "main" resolves to "agent:main:main" (default agent), which differs from the
// current session "agent:shopping-claw:main". Live context must NOT be forwarded.

View File

@ -12,7 +12,7 @@ import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { stringEnum } from "../schema/typebox.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, readGatewayCallOptions } from "./gateway.js";
import { callGatewayTool, readGatewayCallOptions, resolveGatewayTarget } from "./gateway.js";
const log = createSubsystemLogger("gateway-tool");
@ -274,7 +274,12 @@ export function createGatewayTool(opts?: {
// would write a sentinel with the wrong chat destination on the remote host,
// causing post-restart wake messages to be sent to the caller's chat instead
// of the session on the remote gateway. See #18612.
const isRemoteGateway = Boolean(gatewayOpts.gatewayUrl?.trim());
// Only suppress deliveryContext for truly remote gateways. A gatewayUrl
// override pointing to a local loopback address (127.0.0.1, localhost,
// [::1]) is still the local server and should forward context normally;
// treating it as remote would fall back to extractDeliveryInfo(sessionKey)
// and reintroduce the stale heartbeat routing this patch was meant to fix.
const isRemoteGateway = resolveGatewayTarget(gatewayOpts) === "remote";
const deliveryContext =
isTargetingOtherSession || isRemoteGateway ? undefined : liveDeliveryContextForRpc;
return { sessionKey, note, restartDelayMs, deliveryContext };

View File

@ -13,7 +13,7 @@ export type GatewayCallOptions = {
timeoutMs?: number;
};
type GatewayOverrideTarget = "local" | "remote";
export type GatewayOverrideTarget = "local" | "remote";
export function readGatewayCallOptions(params: Record<string, unknown>): GatewayCallOptions {
return {
@ -113,6 +113,23 @@ function resolveGatewayOverrideToken(params: {
}).token;
}
/**
* Resolves whether a GatewayCallOptions points to a local or remote gateway.
* Returns undefined when no gatewayUrl override is present (default local gateway).
* Local loopback overrides (127.0.0.1, localhost, [::1]) return "local";
* all other URL overrides return "remote".
*/
export function resolveGatewayTarget(opts?: GatewayCallOptions): GatewayOverrideTarget | undefined {
if (trimToUndefined(opts?.gatewayUrl) === undefined) {
return undefined;
}
const cfg = loadConfig();
return validateGatewayUrlOverrideForAgentTools({
cfg,
urlOverride: String(opts?.gatewayUrl),
}).target;
}
export function resolveGatewayOptions(opts?: GatewayCallOptions) {
const cfg = loadConfig();
const validatedOverride =