fix(agents): retain beta subagent gateway context

This commit is contained in:
Vincent Koc
2026-08-19 02:03:37 -07:00
parent 50c8beb3f4
commit be78094ddd
23 changed files with 203 additions and 7 deletions
@@ -99,6 +99,7 @@ export async function deliverSubagentAnnouncement(params: {
directIdempotencyKey: string;
onDeliveryResult?: (delivery: SubagentAnnounceDeliveryResult) => void;
signal?: AbortSignal;
resolveGatewayContext?: import("../../../gateway/server-methods/types.js").GatewayContextResolver;
}): Promise<SubagentAnnounceDeliveryResult> {
const sourceOwnerChanged = () => params.isSourceSessionEffectsAllowed?.() === false;
if (sourceOwnerChanged()) {
@@ -258,6 +259,7 @@ export async function deliverSubagentAnnouncement(params: {
onDeliveryResult: params.onDeliveryResult,
signal: params.signal,
bestEffortDeliver: params.bestEffortDeliver,
resolveGatewayContext: params.resolveGatewayContext,
});
},
});
@@ -73,6 +73,7 @@ async function runAnnounceAgentCall(params: {
delegatedToolPolicyHandoff?: SubagentCompletionToolHandoffRegistration;
expectFinal?: boolean;
timeoutMs?: number;
resolveGatewayContext?: import("../../../gateway/server-methods/types.js").GatewayContextResolver;
}): Promise<unknown> {
return await dispatchSubagentAnnounceAgent(params.agentParams, {
expectFinal: params.expectFinal,
@@ -81,6 +82,7 @@ async function runAnnounceAgentCall(params: {
),
delegatedToolPolicyHandoff: params.delegatedToolPolicyHandoff,
timeoutMs: params.timeoutMs,
resolveGatewayContext: params.resolveGatewayContext,
});
}
@@ -105,6 +107,7 @@ export async function sendSubagentAnnounceDirectly(params: {
requesterIsSubagent: boolean;
onDeliveryResult?: (delivery: SubagentAnnounceDeliveryResult) => void;
signal?: AbortSignal;
resolveGatewayContext?: import("../../../gateway/server-methods/types.js").GatewayContextResolver;
}): Promise<SubagentAnnounceDeliveryResult> {
if (params.signal?.aborted) {
return {
@@ -369,6 +372,7 @@ export async function sendSubagentAnnounceDirectly(params: {
: undefined,
expectFinal: true,
timeoutMs: announceTimeoutMs,
resolveGatewayContext: params.resolveGatewayContext,
});
},
});
@@ -2,6 +2,7 @@
// drain gating, batch idempotency, and the guards that keep the wake out of
// nested/cron/single-delivered paths.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { bindGatewayContextResolver } from "../../../plugins/runtime/gateway-request-scope.js";
import type { SubagentRunRecord } from "../registry/subagent-registry.types.js";
import type { SubagentAnnounceDeliveryResult } from "./subagent-announce-dispatch.js";
@@ -181,7 +182,9 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => {
});
it("wakes the requester once with a batch-stable idempotency key when the fan-out drains", async () => {
registryRuntimeMock.listSubagentRunsForRequester.mockReturnValue([
const context = { owner: "gateway-a" } as never;
const resolveGatewayContext = () => context;
const children = [
makeSettledChild({
runId: "run-b",
completion: { required: true, resultText: "network findings" },
@@ -190,7 +193,11 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => {
runId: "run-a",
completion: { required: true, resultText: "social findings" },
}),
]);
];
for (const child of children) {
bindGatewayContextResolver(child, resolveGatewayContext);
}
registryRuntimeMock.listSubagentRunsForRequester.mockReturnValue(children);
const woke = await maybeWakeRequesterAfterAllChildrenSettled(wakeParams());
@@ -203,6 +210,7 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => {
expect(call.requireDirectDelivery).toBe(true);
expect(call.requireVisibleReply).toBeUndefined();
expect(call.directIdempotencyKey).toBe(requesterSettleKey("run-a,run-b"));
expect((call.resolveGatewayContext as (() => typeof context) | undefined)?.()).toBe(context);
const message = String(call.triggerMessage);
expect(message).toContain("settled");
expect(message).toContain("social findings");
@@ -7,6 +7,7 @@
import { SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js";
import { getRuntimeConfig } from "../../../config/config.js";
import { logWarn } from "../../../logger.js";
import { getSharedGatewayContextResolver } from "../../../plugins/runtime/gateway-request-scope.js";
import { isCronSessionKey } from "../../../sessions/session-key-utils.js";
import {
type DeliveryContext,
@@ -451,6 +452,7 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(params: {
attemptIndex === 0 ? wakeKeyBase : `${wakeKeyBase}:retry-${attemptIndex}`,
),
signal: params.signal,
resolveGatewayContext: getSharedGatewayContextResolver(settledBatch),
});
} catch (error) {
// A transport exception can arrive after gateway admission. Replay the
@@ -193,6 +193,7 @@ export async function runSubagentAnnounceFlow(params: {
bestEffortDeliver?: boolean;
onDeliveryResult?: (delivery: SubagentAnnounceDeliveryResult) => void;
onBeforeDeleteChildSession?: () => boolean;
resolveGatewayContext?: import("../../../gateway/server-methods/types.js").GatewayContextResolver;
}): Promise<SubagentAnnounceFlowOutcome> {
let announceOutcome: SubagentAnnounceFlowOutcome = "retryable";
const expectsCompletionMessage = params.expectsCompletionMessage === true;
@@ -589,6 +590,7 @@ export async function runSubagentAnnounceFlow(params: {
directIdempotencyKey,
onDeliveryResult: reportDeliveryResult,
signal: params.signal,
resolveGatewayContext: params.resolveGatewayContext,
});
reportDeliveryResult(delivery);
announceOutcome = delivery.disposition ?? (delivery.delivered ? "delivered" : "retryable");
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
bindGatewayContextResolver,
clearGatewayContextResolver,
getGatewayContextResolver,
getSharedGatewayContextResolver,
} from "../../../plugins/runtime/gateway-request-scope.js";
import { createSubagentRunRecord } from "../../subagent-test-fixtures.test-helpers.js";
describe("subagent Gateway context binding", () => {
it("keeps successor routing private and clears retired owners", () => {
const context = { owner: "gateway-a" } as never;
const resolver = () => context;
const source = createSubagentRunRecord({ runId: "run-source" });
const successor = createSubagentRunRecord({ runId: "run-successor" });
const restored = structuredClone(source);
bindGatewayContextResolver(source, resolver);
bindGatewayContextResolver(successor, getGatewayContextResolver(source));
expect(getGatewayContextResolver(successor)?.()).toBe(context);
expect(getGatewayContextResolver(restored)).toBeUndefined();
clearGatewayContextResolver(source);
expect(getGatewayContextResolver(source)).toBeUndefined();
});
it("refuses to select one Gateway for a mixed-owner settle batch", () => {
const first = createSubagentRunRecord({ runId: "run-first" });
const second = createSubagentRunRecord({ runId: "run-second" });
const firstContext = { owner: "gateway-a" } as never;
const secondContext = { owner: "gateway-b" } as never;
bindGatewayContextResolver(first, () => firstContext);
bindGatewayContextResolver(second, () => secondContext);
expect(getGatewayContextResolver(first)?.()).toBe(firstContext);
expect(getGatewayContextResolver(second)?.()).toBe(secondContext);
const shared = getSharedGatewayContextResolver([first, second]);
expect(shared).toBeTypeOf("function");
expect(shared?.()).toBeUndefined();
});
it("shares a Gateway context captured by separate sibling resolvers", () => {
const first = createSubagentRunRecord({ runId: "run-first" });
const second = createSubagentRunRecord({ runId: "run-second" });
const context = { owner: "gateway-a" } as never;
bindGatewayContextResolver(first, () => context);
bindGatewayContextResolver(second, () => context);
expect(getSharedGatewayContextResolver([first, second])?.()).toBe(context);
});
it("refuses a mixed bound and unbound settle batch", () => {
const bound = createSubagentRunRecord({ runId: "run-bound" });
const unbound = createSubagentRunRecord({ runId: "run-unbound" });
bindGatewayContextResolver(bound, () => ({ owner: "gateway-a" }) as never);
const shared = getSharedGatewayContextResolver([unbound, bound]);
expect(shared).toBeTypeOf("function");
expect(shared?.()).toBeUndefined();
});
});
@@ -1,3 +1,4 @@
import { getGatewayContextResolver } from "../../../plugins/runtime/gateway-request-scope.js";
import { defaultRuntime } from "../../../runtime.js";
import { normalizeDeliveryContext } from "../../../utils/delivery-context.shared.js";
import {
@@ -606,6 +607,7 @@ export const startSubagentAnnounceCleanupFlow = (
params.persist(runId);
}
},
resolveGatewayContext: getGatewayContextResolver(entry),
};
runDetachedCleanupAttempt(context, {
runId,
@@ -1,4 +1,5 @@
import { runWithoutOwnedSessionTranscriptWrites } from "../../../config/sessions/transcript-write-context.js";
import { clearGatewayContextResolver } from "../../../plugins/runtime/gateway-request-scope.js";
import {
runWithGatewayIndependentRootWorkContinuation,
runWithGatewayIndependentRootWorkAdmission,
@@ -155,6 +156,7 @@ const completeRequesterSettleWakeBatch = (
context.deleteRequesterSettleWakeTimer(runId);
}
if (entry.requesterSettleWake === undefined || !params.runs.has(runId)) {
clearGatewayContextResolver(entry);
params.resumedRuns.delete(runId);
params.clearPendingLifecycleError(runId);
}
@@ -485,6 +487,7 @@ export function completeCleanupBookkeeping(
cleanupParams.entry.terminalOwner = previousTerminalOwner;
throw error;
}
clearGatewayContextResolver(cleanupParams.entry);
scheduleCleanupTails({ allowRetiredRow: false, isDeleteCleanup });
retryDeferredCompletedAnnounces(cleanupParams.runId);
return;
@@ -506,6 +509,7 @@ export function completeCleanupBookkeeping(
params.runs.set(cleanupParams.runId, cleanupParams.entry);
throw error;
}
clearGatewayContextResolver(cleanupParams.entry);
scheduleCleanupTails({ allowRetiredRow: true, isDeleteCleanup });
retryDeferredCompletedAnnounces(cleanupParams.runId);
return;
@@ -549,6 +553,7 @@ export function completeCleanupBookkeeping(
cleanupParams.entry.terminalOwner = previousTerminalOwner;
throw error;
}
clearGatewayContextResolver(cleanupParams.entry);
}
scheduleCleanupTails({ allowRetiredRow: false, isDeleteCleanup });
retryDeferredCompletedAnnounces(cleanupParams.runId);
@@ -8,6 +8,7 @@ import {
} from "../../../config/sessions/transcript-write-context.js";
import type { CallGatewayOptions } from "../../../gateway/call.js";
import { getAgentEventLifecycleGeneration } from "../../../infra/agent-events.js";
import { bindGatewayContextResolver } from "../../../plugins/runtime/gateway-request-scope.js";
import {
getActiveGatewayRootWorkCount,
markGatewayRestartDraining,
@@ -2629,6 +2630,9 @@ describe("subagent registry lifecycle hardening", () => {
expectsCompletionMessage: true,
});
const runSubagentAnnounceFlow = vi.fn(async () => "delivered" as const);
const gatewayContext = { owner: "gateway-a" } as never;
const resolveGatewayContext = () => gatewayContext;
bindGatewayContextResolver(entry, resolveGatewayContext);
const controller = createLifecycleController({ entry, persist, runSubagentAnnounceFlow });
@@ -2641,6 +2645,7 @@ describe("subagent registry lifecycle hardening", () => {
expect(browserCleanupArg.onWarn).toBeTypeOf("function");
expectFields(firstCallArg(runSubagentAnnounceFlow), {
childSessionKey: entry.childSessionKey,
resolveGatewayContext,
});
});
@@ -1,9 +1,11 @@
/** Owns subagent registration and queued collector launch transitions. */
import type { GatewayContextResolver } from "../../../gateway/server-methods/types.js";
import {
getAgentEventLifecycleGeneration,
isAgentEventLifecycleGenerationCurrent,
} from "../../../infra/agent-events.js";
import { createSubsystemLogger } from "../../../logging/subsystem.js";
import { bindGatewayContextResolver } from "../../../plugins/runtime/gateway-request-scope.js";
import {
createQueuedTaskRun,
createRunningTaskRun,
@@ -87,6 +89,7 @@ export type RegisterSubagentRunParams = {
/** Required when direct dispatch suppresses Gateway tracking. Out-of-process launches keep
Gateway's existing best-effort CLI policy; other callers create a best-effort row here. */
taskRowOwnership?: "required" | "gateway_best_effort";
gatewayContextResolver?: GatewayContextResolver;
};
export class SubagentLaunchManager extends SubagentRecoveryManager {
@@ -266,6 +269,7 @@ export class SubagentLaunchManager extends SubagentRecoveryManager {
}
}
}
bindGatewayContextResolver(entry, registerParams.gatewayContextResolver);
// Wait through Gateway RPC; the in-process lifecycle listener is the embedded fallback.
activateRegistrationLifecycle();
};
@@ -8,6 +8,7 @@ import {
isAgentEventLifecycleGenerationCurrent,
} from "../../../infra/agent-events.js";
import { createSubsystemLogger } from "../../../logging/subsystem.js";
import { clearGatewayContextResolver } from "../../../plugins/runtime/gateway-request-scope.js";
import { runWithGatewayIndependentRootWorkAdmission } from "../../../process/gateway-work-admission.js";
import { SUBAGENT_KILL_TASK_ERROR } from "../../../tasks/detached-task-runtime-contract.js";
import { finalizeTaskRunByRunId } from "../../../tasks/detached-task-runtime.js";
@@ -45,6 +46,7 @@ class SubagentRunManager extends SubagentLaunchManager {
throw error;
}
this.options.clearPendingLifecycleError(runId);
clearGatewayContextResolver(entry);
if (this.shouldDeleteAttachments(entry)) {
void safeRemoveAttachmentsDir(entry);
}
@@ -1,9 +1,14 @@
/** Owns steer replacement and restart-recovery receipt transitions. */
import type { GatewayContextResolver } from "../../../gateway/server-methods/types.js";
import {
getAgentEventLifecycleGeneration,
isAgentEventLifecycleGenerationCurrent,
} from "../../../infra/agent-events.js";
import { createSubsystemLogger } from "../../../logging/subsystem.js";
import {
bindGatewayContextResolver,
getGatewayContextResolver,
} from "../../../plugins/runtime/gateway-request-scope.js";
import { finalizeTaskRunByRunId } from "../../../tasks/detached-task-runtime.js";
import { removeInternalSessionEffectsSession } from "../../internal-session-effects.js";
import type { AgentRunSessionTarget } from "../../run-session-target.js";
@@ -140,6 +145,7 @@ export class SubagentRecoveryManager extends SubagentWaitManager {
restartRecovery?: SubagentRestartRecoveryReceipt;
lifecycleGeneration?: string;
persistenceFailure?: "return-false" | "throw";
gatewayContextResolver?: GatewayContextResolver;
}): boolean => {
const previousRunId = replaceParams.previousRunId.trim();
const nextRunId = replaceParams.nextRunId.trim();
@@ -270,6 +276,10 @@ export class SubagentRecoveryManager extends SubagentWaitManager {
archiveAtMs: undefined,
runTimeoutSeconds,
});
bindGatewayContextResolver(
next,
replaceParams.gatewayContextResolver ?? getGatewayContextResolver(source),
);
clearDeliveryState(next);
if (previousRunId !== nextRunId) {
@@ -19,7 +19,11 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import type { GatewayRecoveryRuntime } from "../../../gateway/server-instance-runtime.types.js";
import type { AgentEventPayload } from "../../../infra/agent-events.js";
import { createEmptyPluginRegistry } from "../../../plugins/registry-empty.js";
import { getPluginRuntimeGatewayRequestScope } from "../../../plugins/runtime/gateway-request-scope.js";
import {
bindGatewayContextResolver,
getGatewayContextResolver,
getPluginRuntimeGatewayRequestScope,
} from "../../../plugins/runtime/gateway-request-scope.js";
import {
getActiveGatewayRootWorkCount,
markGatewayRestartDraining,
@@ -6461,7 +6465,7 @@ describe("subagent registry seam flow", () => {
},
};
});
mod.addSubagentRunForTests({
const entry = createSubagentRunRecord({
runId: "run-release-context-engine",
childSessionKey: "agent:main:session:child",
controllerSessionKey: "agent:main:session:parent",
@@ -6479,8 +6483,15 @@ describe("subagent registry seam flow", () => {
accumulatedRuntimeMs: 0,
cleanupHandled: false,
});
mod.addSubagentRunForTests(entry);
const registeredEntry = mod
.listSubagentRunsForRequester("agent:main:session:parent")
.find((run) => run.runId === "run-release-context-engine");
expect(registeredEntry).toBeDefined();
bindGatewayContextResolver(registeredEntry!, () => ({ owner: "gateway-a" }) as never);
mod.releaseSubagentRun("run-release-context-engine");
expect(getGatewayContextResolver(registeredEntry!)).toBeUndefined();
await waitForFast(() => {
expect(mocks.onSubagentEnded).toHaveBeenCalledWith({
@@ -5,6 +5,7 @@ import {
ensureContextEnginesInitialized,
forkSessionEntryFromParent,
getGlobalHookRunner,
getInProcessGatewayRequestContext,
getRuntimeConfig,
hasInProcessGatewayContext,
loadPreparedModelCatalog,
@@ -16,6 +17,7 @@ type SubagentSpawnDeps = {
dispatchGatewayMethodInProcess: typeof dispatchGatewayMethodInProcess;
forkSessionEntryFromParent: typeof forkSessionEntryFromParent;
getGlobalHookRunner: () => SubagentLifecycleHookRunner | null;
getInProcessGatewayRequestContext: typeof getInProcessGatewayRequestContext;
getRuntimeConfig: typeof getRuntimeConfig;
hasInProcessGatewayContext: typeof hasInProcessGatewayContext;
ensureContextEnginesInitialized: typeof ensureContextEnginesInitialized;
@@ -28,6 +30,7 @@ const defaultSubagentSpawnDeps: SubagentSpawnDeps = {
dispatchGatewayMethodInProcess,
forkSessionEntryFromParent,
getGlobalHookRunner,
getInProcessGatewayRequestContext,
getRuntimeConfig,
hasInProcessGatewayContext,
ensureContextEnginesInitialized,
@@ -14,6 +14,7 @@ export { resolveContextEngine } from "../../../context-engine/registry.js";
export { callGateway } from "../../../gateway/call.js";
export {
dispatchGatewayMethodInProcess,
getInProcessGatewayRequestContext,
hasInProcessGatewayContext,
} from "../../../gateway/server-plugins.js";
export {
@@ -130,6 +130,7 @@ export function expectPersistedRuntimeModel(params: {
export async function loadSubagentSpawnModuleForTest(params: {
callGatewayMock: MockFn;
dispatchGatewayMethodInProcessMock?: MockFn;
getInProcessGatewayRequestContextMock?: MockFn;
hasInProcessGatewayContextMock?: MockFn;
getRuntimeConfig?: () => Record<string, unknown>;
loadSessionStoreMock?: MockFn;
@@ -219,6 +220,7 @@ export async function loadSubagentSpawnModuleForTest(params: {
callGateway: (opts: unknown) => params.callGatewayMock(opts),
dispatchGatewayMethodInProcess: (...args: unknown[]) =>
params.dispatchGatewayMethodInProcessMock?.(...args),
getInProcessGatewayRequestContext: () => params.getInProcessGatewayRequestContextMock?.(),
hasInProcessGatewayContext: () => Boolean(params.hasInProcessGatewayContextMock?.()),
buildSubagentSystemPrompt: () => "system-prompt",
forkSessionEntryFromParent:
@@ -9,6 +9,7 @@ type SpawnDeps = Omit<
| "ensureContextEnginesInitialized"
| "forkSessionEntryFromParent"
| "getGlobalHookRunner"
| "getInProcessGatewayRequestContext"
| "getRuntimeConfig"
| "hasInProcessGatewayContext"
| "loadPreparedModelCatalog"
@@ -28,6 +28,7 @@ const hoisted = vi.hoisted(() => ({
completeCollectorLaunchCleanupMock: vi.fn(),
emitSessionLifecycleEventMock: vi.fn(),
dispatchGatewayMethodInProcessMock: vi.fn(),
getInProcessGatewayRequestContextMock: vi.fn(),
hasInProcessGatewayContextMock: vi.fn(),
resolveAgentConfigMock: vi.fn(),
resolveContextEngineMock: vi.fn(),
@@ -160,6 +161,7 @@ describe("spawnSubagentDirect seam flow", () => {
({ resetSubagentRegistryForTests, spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({
callGatewayMock: hoisted.callGatewayMock,
dispatchGatewayMethodInProcessMock: hoisted.dispatchGatewayMethodInProcessMock,
getInProcessGatewayRequestContextMock: hoisted.getInProcessGatewayRequestContextMock,
hasInProcessGatewayContextMock: hoisted.hasInProcessGatewayContextMock,
getRuntimeConfig: () => hoisted.configOverride,
loadSessionStoreMock: hoisted.loadSessionStoreMock,
@@ -194,6 +196,7 @@ describe("spawnSubagentDirect seam flow", () => {
hoisted.completeCollectorLaunchCleanupMock.mockReset();
hoisted.emitSessionLifecycleEventMock.mockReset();
hoisted.dispatchGatewayMethodInProcessMock.mockReset();
hoisted.getInProcessGatewayRequestContextMock.mockReset();
hoisted.hasInProcessGatewayContextMock.mockReset().mockReturnValue(false);
hoisted.resolveAgentConfigMock.mockReset();
hoisted.resolveContextEngineMock.mockReset().mockResolvedValue({});
@@ -1233,6 +1236,8 @@ describe("spawnSubagentDirect seam flow", () => {
});
it("dispatches spawned agent runs in process when a gateway context is available", async () => {
const gatewayContext = { owner: "gateway-a" } as never;
hoisted.getInProcessGatewayRequestContextMock.mockReturnValue(gatewayContext);
hoisted.hasInProcessGatewayContextMock.mockReturnValue(true);
hoisted.callGatewayMock.mockRejectedValue(new Error("unexpected websocket gateway call"));
hoisted.dispatchGatewayMethodInProcessMock.mockImplementation(async (method: string) => {
@@ -1274,7 +1279,11 @@ describe("spawnSubagentDirect seam flow", () => {
expect(agentOptions.allowSyntheticModelOverride).toBeUndefined();
// In-process dispatch claims the task row directly, unlike ACP's best-effort
// registration (see acp-spawn.test.ts).
expect(firstRegisteredSubagentRun().taskRowOwnership).toBe("required");
const registration = firstRegisteredSubagentRun();
expect(registration.taskRowOwnership).toBe("required");
expect(
(registration.gatewayContextResolver as (() => typeof gatewayContext) | undefined)?.(),
).toBe(gatewayContext);
});
it("authorizes explicit model overrides for in-process child launches", async () => {
+4 -1
View File
@@ -51,7 +51,7 @@ import type {
SpawnSubagentParams,
SpawnSubagentResult,
} from "./subagent-spawn-contract.js";
import { setSubagentSpawnDepsForTest } from "./subagent-spawn-deps.js";
import { getSubagentSpawnDeps, setSubagentSpawnDepsForTest } from "./subagent-spawn-deps.js";
import { callNativeSubagentGateway, readGatewayRunId } from "./subagent-spawn-gateway.js";
import { buildSubagentLaunchRequest } from "./subagent-spawn-launch-request.js";
import { createSubagentSpawnLifecycleEmitter } from "./subagent-spawn-lifecycle.js";
@@ -105,6 +105,8 @@ export async function spawnSubagentDirect(
const requestThreadBinding = params.thread === true;
const sandboxMode = params.sandbox === "require" ? "require" : "inherit";
const requesterSessionKey = ctx.agentSessionKey;
const gatewayContext = getSubagentSpawnDeps().getInProcessGatewayRequestContext();
const gatewayContextResolver = gatewayContext ? () => gatewayContext : undefined;
let requestedAgentId = params.agentId?.trim();
const requestResolution = resolveSubagentSpawnRequest(params, ctx, {
initial: requestedAgentId,
@@ -546,6 +548,7 @@ export async function spawnSubagentDirect(
queuedLaunch,
queued: params.collect === true,
taskRowOwnership,
...(gatewayContextResolver ? { gatewayContextResolver } : {}),
attachmentsDir: attachmentAbsDir,
attachmentsRootDir: attachmentRootDir,
retainAttachmentsOnKeep: retainOnSessionKeep,
@@ -386,6 +386,8 @@ type GatewayResidentBridgeContext = {
) => void;
};
export type GatewayContextResolver = () => GatewayRequestContext | undefined;
/** Complete runtime context available to gateway request handlers. */
export type GatewayRequestContext = GatewayKernelContext &
GatewayTransportContext &
@@ -9,6 +9,10 @@ import { upsertSessionEntryCore } from "../config/sessions/session-accessor.js";
import { withPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js";
import {
clearFallbackGatewayContext,
setFallbackGatewayContext,
} from "./server-plugin-fallback-context.js";
import { dispatchGatewayMethodInProcess } from "./server-plugin-in-process-dispatch.js";
const startTurn = vi.hoisted(() => vi.fn());
@@ -100,10 +104,24 @@ async function dispatchScopedMethod(params: {
describe("typed in-process agent authorization", () => {
beforeEach(() => {
clearFallbackGatewayContext();
startTurn.mockReset();
waitForTurn.mockReset();
});
it("fails closed when an explicit Gateway resolver loses its owner", async () => {
setFallbackGatewayContext(createContext());
await expect(
dispatchGatewayMethodInProcess(
"agent",
{ message: "detached completion", idempotencyKey: "detached-completion" },
{ resolveGatewayContext: () => undefined },
),
).rejects.toThrow("No scope set and no fallback context available");
expect(startTurn).not.toHaveBeenCalled();
});
it("rejects a scoped agent turn without operator.write", async () => {
await expect(
dispatchScopedAgent({
@@ -13,6 +13,7 @@ import type { AgentRunRequest } from "./server-methods/agent-request-types.js";
import type { TrustedSessionCreation } from "./server-methods/session-creation-provenance.js";
import type {
GatewayAgentRunTaskOwner,
GatewayContextResolver,
GatewayRequestContext,
GatewayRequestOptions,
TrustedAgentToolCaller,
@@ -52,6 +53,7 @@ type DispatchGatewayMethodInProcessOptions = {
syntheticScopes?: string[];
timeoutMs?: number;
signal?: AbortSignal;
resolveGatewayContext?: GatewayContextResolver;
};
type ResolvedInProcessGatewayDispatch = {
@@ -66,7 +68,11 @@ function resolveInProcessGatewayDispatch(
options?: DispatchGatewayMethodInProcessOptions,
): ResolvedInProcessGatewayDispatch {
const scope = getPluginRuntimeGatewayRequestScope();
const context = scope?.context ?? getFallbackGatewayContext();
const context =
scope?.context ??
(options?.resolveGatewayContext
? options.resolveGatewayContext()
: getFallbackGatewayContext());
const isWebchatConnect = scope?.isWebchatConnect ?? (() => false);
if (!context) {
throw new Error(
@@ -1,6 +1,7 @@
// Gateway request scope tracks request-local plugin runtime context across async work.
import { AsyncLocalStorage } from "node:async_hooks";
import type {
GatewayContextResolver,
GatewayRequestContext,
GatewayRequestOptions,
} from "../../gateway/server-methods/types.js";
@@ -37,6 +38,38 @@ const pluginRuntimeGatewayRequestScope = resolveGlobalSingleton<
PLUGIN_RUNTIME_GATEWAY_REQUEST_SCOPE_KEY,
() => new AsyncLocalStorage<PluginRuntimeGatewayRequestScope>(),
);
const gatewayContextResolvers = new WeakMap<object, GatewayContextResolver>();
const resolveNoGatewayContext: GatewayContextResolver = () => undefined;
export function bindGatewayContextResolver(
owner: object,
resolver: GatewayContextResolver | undefined,
): void {
if (resolver) {
gatewayContextResolvers.set(owner, resolver);
}
}
export const getGatewayContextResolver = (owner: object) => gatewayContextResolvers.get(owner);
export const clearGatewayContextResolver = (owner: object) => gatewayContextResolvers.delete(owner);
export function getSharedGatewayContextResolver(
owners: readonly object[],
): GatewayContextResolver | undefined {
const resolvers = owners.map((owner) => gatewayContextResolvers.get(owner));
if (resolvers.every((resolver) => resolver === undefined)) {
return undefined;
}
if (resolvers.some((resolver) => resolver === undefined)) {
return resolveNoGatewayContext;
}
const contexts = resolvers.map((resolver) => resolver?.());
const first = contexts[0];
return first && contexts.every((context) => context === first)
? () => first
: resolveNoGatewayContext;
}
/**
* Runs plugin gateway handlers with request-scoped context that runtime helpers can read.