fix(gateway): bind subagent completion dispatch to instance (#126062)

* fix(gateway): bind subagent completion dispatch to instance

* fix(gateway): keep completion binding cycle-free

* fix(gateway): consolidate resolver type imports
This commit is contained in:
Peter Steinberger
2026-08-18 16:37:31 -07:00
committed by GitHub
parent 8ccfd075fe
commit 2cd87d3d27
37 changed files with 322 additions and 48 deletions
@@ -1894,6 +1894,8 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
getRuntimeConfig: () => ({}) as never,
});
const ownerContext = { owner: "gateway-a" } as never;
const resolveGatewayContext = () => ownerContext;
const result = await deliverSubagentAnnouncement({
requesterSessionKey: "agent:main:slack:channel:C123:thread:171.222",
targetRequesterSessionKey: "agent:main:slack:channel:C123:thread:171.222",
@@ -1912,6 +1914,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
expectsCompletionMessage: true,
bestEffortDeliver: true,
directIdempotencyKey: "announce-local-dispatch",
resolveGatewayContext,
});
expectDeliveryPath(result, "direct");
@@ -1936,6 +1939,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
idempotencyKey: "announce-local-dispatch",
},
timeoutMs: 120_000,
resolveGatewayContext,
});
});
@@ -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,
});
},
});
@@ -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,36 @@
import { describe, expect, it } from "vitest";
import {
bindGatewayContextResolver,
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 excludes restored rows", () => {
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();
});
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);
expect(getSharedGatewayContextResolver([first, second])).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);
@@ -1,9 +1,11 @@
import type { GatewayContextResolver } from "../../../gateway/server-methods/types.js";
/** Owns subagent registration and queued collector launch transitions. */
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 {
@@ -181,6 +184,7 @@ export class SubagentLaunchManager extends SubagentRecoveryManager {
retainAttachmentsOnKeep: registerParams.retainAttachmentsOnKeep,
});
this.options.runs.set(runId, entry);
bindGatewayContextResolver(entry, registerParams.gatewayContextResolver);
const killReconciliationSnapshots = this.markOlderKillReconciliationsSuperseded(entry);
const registeredKillReconciliationSnapshots = new Map(
[...killReconciliationSnapshots.keys()].map((candidate) => [
@@ -274,6 +278,7 @@ export class SubagentLaunchManager extends SubagentRecoveryManager {
runId: string,
gatewayRunId?: string,
lifecycleGeneration?: string,
gatewayContextResolver?: GatewayContextResolver,
): boolean => {
const key = runId.trim();
const entry = this.findRunByIdentity(key);
@@ -367,6 +372,7 @@ export class SubagentLaunchManager extends SubagentRecoveryManager {
try {
this.options.persistOrThrow(previousRunId, nextRunId);
if (terminalBeforeAcceptance) {
bindGatewayContextResolver(entry, gatewayContextResolver);
return true;
}
persistedRunning = true;
@@ -392,6 +398,7 @@ export class SubagentLaunchManager extends SubagentRecoveryManager {
}
throw error;
}
bindGatewayContextResolver(entry, gatewayContextResolver);
const cfg = this.options.getRuntimeConfig();
void this.waitForSubagentCompletion(
nextRunId,
@@ -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 @@
import type { GatewayContextResolver } from "../../../gateway/server-methods/types.js";
/** Owns steer replacement and restart-recovery receipt transitions. */
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) {
@@ -2,6 +2,7 @@
import type { AgentWaitParams } from "../../../../packages/gateway-protocol/src/index.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { callGateway } from "../../../gateway/call.js";
import type { GatewayContextResolver } from "../../../gateway/server-methods/types.js";
import { getGatewayRecoveryRuntime } from "../../../gateway/server-recovery-runtime-context.js";
import { createSubsystemLogger } from "../../../logging/subsystem.js";
import {
@@ -488,6 +489,7 @@ export function adoptPausedSubagentRunForFollowUp(params: {
childSessionKey: string;
runId: string;
task: string;
gatewayContextResolver?: GatewayContextResolver;
}): boolean {
const childSessionKey = params.childSessionKey.trim();
const runId = params.runId.trim();
@@ -522,6 +524,9 @@ export function adoptPausedSubagentRunForFollowUp(params: {
// Persist the follow-up text so restart recovery cannot reissue the task that
// the child already yielded on.
task: params.task,
...(params.gatewayContextResolver
? { gatewayContextResolver: params.gatewayContextResolver }
: {}),
});
}
@@ -50,7 +50,9 @@ async function callSubagentGatewayWithDispatchMode(
);
const allowModelOverride = authorization !== undefined;
const deps = getSubagentSpawnDeps();
const hasInProcessGateway = deps.hasInProcessGatewayContext();
const gatewayCaller = getGatewayToolCallerIdentity();
const hasInProcessGateway =
deps.hasInProcessGatewayContext() || Boolean(gatewayCaller?.gatewayContextResolver?.());
const needsOutOfProcessModelOverrideAuth = allowModelOverride && !hasInProcessGateway;
const scopes =
params.scopes ??
@@ -62,7 +64,6 @@ async function callSubagentGatewayWithDispatchMode(
params: authorizedParams,
...(scopes != null ? { scopes } : {}),
};
const gatewayCaller = getGatewayToolCallerIdentity();
if (
hasInProcessGateway &&
request.params != null &&
@@ -107,6 +108,9 @@ async function callSubagentGatewayWithDispatchMode(
expectFinal: request.expectFinal,
...(allowModelOverride ? { allowSyntheticModelOverride: true } : {}),
...(options?.agentRunTracking ? { agentRunTracking: options.agentRunTracking } : {}),
...(gatewayCaller?.gatewayContextResolver
? { resolveGatewayContext: gatewayCaller.gatewayContextResolver }
: {}),
...(forceSyntheticClient ? { forceSyntheticClient: true } : {}),
...(typeof request.timeoutMs === "number" ? { timeoutMs: request.timeoutMs } : {}),
...(scopes != null ? { syntheticScopes: scopes } : {}),
@@ -29,7 +29,10 @@ import {
claimAgentRunDelegatedAuthority,
releaseAgentRunDelegatedAuthority,
} from "../../../infra/agent-run-registry.js";
import { withPluginRuntimeGatewayRequestScope } from "../../../plugins/runtime/gateway-request-scope.js";
import {
getGatewayContextResolver,
withPluginRuntimeGatewayRequestScope,
} from "../../../plugins/runtime/gateway-request-scope.js";
import {
isGatewaySubordinateWorkAdmissionClosed,
resetGatewayWorkAdmission,
@@ -895,6 +898,7 @@ describe("spawnSubagentDirect in-process Gateway collector launch", () => {
it("launches child runs as a Gateway client that does not own a second task row", async () => {
const gatewayContext = makeGatewayContext();
const gatewayContextResolver = () => gatewayContext;
const agentDispatches: Array<{
params: Record<string, unknown>;
options?: NonNullable<Parameters<typeof dispatchGatewayMethodInProcess>[2]>;
@@ -919,16 +923,24 @@ describe("spawnSubagentDirect in-process Gateway collector launch", () => {
isWebchatConnect: () => false,
},
() =>
spawnSubagentDirect(
withGatewayToolCallerIdentity(
{
task: "summarize the repository",
context: "isolated",
lightContext: true,
},
{
agentSessionKey: "agent:main:main",
requesterRunId: "parent-run",
agentId: "main",
sessionKey: "agent:main:main",
gatewayContextResolver,
},
() =>
spawnSubagentDirect(
{
task: "summarize the repository",
context: "isolated",
lightContext: true,
},
{
agentSessionKey: "agent:main:main",
requesterRunId: "parent-run",
},
),
),
);
@@ -941,6 +953,7 @@ describe("spawnSubagentDirect in-process Gateway collector launch", () => {
childSessionKey: result.childSessionKey,
});
});
expect(getGatewayContextResolver(subagentRuns.get(runId)!)?.()).toBe(gatewayContext);
const dispatch = agentDispatches[0];
expect(dispatch).toBeDefined();
+12 -1
View File
@@ -24,6 +24,7 @@ import {
type SpawnBackendAdapter,
summarizeSpawnError,
} from "../../spawn-pipeline.js";
import { getGatewayToolCallerIdentity } from "../../tools/gateway-caller-context.js";
import {
completeCollectorLaunchCleanup,
settleFailedQueuedSubagentLaunch,
@@ -111,6 +112,7 @@ export async function spawnSubagentDirect(
const requestThreadBinding = params.thread === true;
const sandboxMode = params.sandbox === "require" ? "require" : "inherit";
const requesterSessionKey = ctx.agentSessionKey;
const gatewayContextResolver = getGatewayToolCallerIdentity()?.gatewayContextResolver;
let requestedAgentId = params.agentId?.trim();
const requestResolution = resolveSubagentSpawnRequest(params, ctx, {
initial: requestedAgentId,
@@ -570,6 +572,7 @@ export async function spawnSubagentDirect(
queuedLaunch,
queued: params.collect === true,
taskRowOwnership,
...(gatewayContextResolver ? { gatewayContextResolver } : {}),
attachmentsDir: attachmentAbsDir,
attachmentsRootDir: attachmentRootDir,
retainAttachmentsOnKeep: retainOnSessionKeep,
@@ -615,7 +618,15 @@ export async function spawnSubagentDirect(
}),
});
try {
if (!startQueuedSubagentRun(childRunId, gatewayRunId)) {
const started = gatewayContextResolver
? startQueuedSubagentRun(
childRunId,
gatewayRunId,
undefined,
gatewayContextResolver,
)
: startQueuedSubagentRun(childRunId, gatewayRunId);
if (!started) {
throw new Error(
"collector registry row could not transition from queued to running",
);
@@ -2,8 +2,10 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { ExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js";
import type { CronCreatorAuthorityGrant } from "../../gateway/cron-creator-authority-grant.js";
import type { GatewayContextResolver } from "../../gateway/server-methods/types.js";
import type { WorkerSessionTurnClaim } from "../../gateway/worker-environments/placement-record.js";
import type { WorkerTurnExecutionIdentityCapability } from "../../gateway/worker-environments/placement-turn-claim-events.js";
import { getGatewayContextResolver } from "../../plugins/runtime/gateway-request-scope.js";
import type { AdmittedRunContext, OperationalRunInstanceRef } from "../admitted-run-context.js";
import { copyAgentToolMetadata } from "../agent-tool-metadata.js";
import {
@@ -25,6 +27,8 @@ type GatewayToolCallerIdentity = {
workerTurnClaim?: WorkerSessionTurnClaim;
/** Closure-bound Gateway capability; revalidates both owners at child admission. */
workerTurnExecutionIdentityCapability?: WorkerTurnExecutionIdentityCapability;
/** Instance-bound routing only; delegated authority is revalidated separately. */
gatewayContextResolver?: GatewayContextResolver;
/** Host-signed capability for the scheduled run's existing self-management surface. */
cronSelfManagementJobId?: string;
cronToolsAllowCapture?: "final-executable-surface";
@@ -76,6 +80,7 @@ export function createAdmittedGatewayToolCallerIdentity(
sessionKey,
operationalRunInstance: params.admittedRunContext.operationalRunInstance,
executionIdentityToken: params.admittedRunContext.executionIdentityToken,
gatewayContextResolver: getGatewayContextResolver(params.admittedRunContext),
turnSourceChannel: params.turnSourceChannel,
turnSourceLocal: params.turnSourceLocal,
turnSourceTo: params.turnSourceTo,
@@ -117,6 +122,8 @@ export async function withGatewayToolCallerIdentity<T>(
const workerTurnExecutionIdentityCapability =
inheritedOwner?.workerTurnExecutionIdentityCapability ??
identity.workerTurnExecutionIdentityCapability;
const gatewayContextResolver =
inheritedOwner?.gatewayContextResolver ?? identity.gatewayContextResolver;
const cronSelfManagementJobId =
identity.cronSelfManagementJobId?.trim() ?? inheritedOwner?.cronSelfManagementJobId;
const cronToolsAllowCapture =
@@ -146,6 +153,7 @@ export async function withGatewayToolCallerIdentity<T>(
...(executionIdentityToken ? { executionIdentityToken } : {}),
...(workerTurnClaim ? { workerTurnClaim } : {}),
...(workerTurnExecutionIdentityCapability ? { workerTurnExecutionIdentityCapability } : {}),
...(gatewayContextResolver ? { gatewayContextResolver } : {}),
...(turnSourceChannel ? { turnSourceChannel } : {}),
...(turnSourceLocal === true ? { turnSourceLocal: true } : {}),
...(turnSourceTo ? { turnSourceTo } : {}),
@@ -25,6 +25,7 @@ import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-erro
import { leaseMcpAppModelContextForTurn } from "../../agents/mcp-app-model-context.js";
import { isAgentRunRestartAbortReason } from "../../agents/run-termination.js";
import { createAgentPatchedSessionModelRunGuard } from "../../agents/session-model-auto-revert.js";
import { readChannelContextGatewayContextResolver } from "../../channels/message-access/admission-evidence.js";
import type { SessionEntry } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import {
@@ -38,6 +39,7 @@ import { formatErrorMessage } from "../../infra/errors.js";
import { recordMessageToolRunOutcome } from "../../infra/message-tool-run-outcome-store.js";
import { logSessionTurnCreated } from "../../logging/diagnostic.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { bindGatewayContextResolver } from "../../plugins/runtime/gateway-request-scope.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import type { ReplyPayload } from "../types.js";
import {
@@ -522,6 +524,7 @@ async function executeAgentTurnInternal(
};
const runId = params.opts?.runId ?? crypto.randomUUID();
const admittedRunContext: { current?: AdmittedRunContext } = {};
const gatewayContextResolver = readChannelContextGatewayContextResolver(params.sessionCtx);
const preparedRunAdmission = prepareChannelRunAdmission({
cfg: resolveQueuedReplyRuntimeConfig(params.followupRun.run.config),
runId,
@@ -530,6 +533,7 @@ async function executeAgentTurnInternal(
boundary: "auto-reply.agent-runner",
evidence: params.followupRun.channelAdmissionEvidence,
onAdmitted: (context) => {
bindGatewayContextResolver(context, gatewayContextResolver);
admittedRunContext.current = context;
},
});
@@ -4,6 +4,7 @@ import { resolveEmbeddedFullAccessState } from "../../agents/embedded-agent-runn
import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js";
import type { SilentReplyPromptMode } from "../../agents/system-prompt.types.js";
import { resolveEffectiveAgentRuntime } from "../../agents/thinking-runtime.js";
import { copyChannelParticipantAdmissionEvidence } from "../../channels/message-access/admission-evidence.js";
import { loadSessionEntry } from "../../config/sessions/session-accessor.js";
import { resolveSilentReplySettings } from "../../config/silent-reply.js";
import { logVerbose } from "../../globals.js";
@@ -111,6 +112,10 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) {
ctx,
isHeartbeat,
});
copyChannelParticipantAdmissionEvidence(ctx, promptSessionCtx);
if (sessionCtx !== ctx) {
copyChannelParticipantAdmissionEvidence(sessionCtx, promptSessionCtx);
}
const inboundEventKind = promptSessionCtx.InboundEventKind;
const { sourceReplyDeliveryMode, injectedSessionStableMode } = resolvePromptSourceReplyMode({
promptSessionCtx,
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayContextResolver } from "../../gateway/server-methods/types.js";
import {
buildChannelInboundEventContext,
buildHostChannelInboundEventContext,
@@ -10,15 +11,26 @@ import {
consumeChannelAdmissionEvidence,
copyChannelParticipantAdmissionEvidence,
readChannelContextAdmissionEvidence,
readChannelContextGatewayContextResolver,
registerChannelAdmissionEvidenceOwner,
type ChannelAdmissionEvidence,
} from "./admission-evidence.js";
import { resolveStableChannelMessageIngress } from "./runtime.js";
async function buildAdmittedContext(participantId: string, allowFrom = [participantId]) {
async function buildAdmittedContext(
participantId: string,
allowFrom = [participantId],
resolveGatewayContext?: GatewayContextResolver,
) {
const record = {};
const epoch = {};
const owner = { channelId: "test", record, epoch, isLive: () => true };
const owner = {
channelId: "test",
record,
epoch,
isLive: () => true,
resolveGatewayContext,
};
const dispose = registerChannelAdmissionEvidenceOwner(owner);
const channelIngress = await resolveStableChannelMessageIngress({
channelId: "test",
@@ -62,6 +74,22 @@ function inspectChannelContext(context: object) {
}
describe("channel admission evidence", () => {
it("keeps Gateway routing instance-bound when audit collection is disabled", async () => {
const gatewayContext = { owner: "gateway-a" } as never;
let live = true;
const source = await buildAdmittedContext("person:42", ["person:42"], () =>
live ? gatewayContext : undefined,
);
const copied = { ...source };
copyChannelParticipantAdmissionEvidence(source, copied);
expect(readChannelContextGatewayContextResolver(source)?.()).toBe(gatewayContext);
expect(readChannelContextGatewayContextResolver(copied)?.()).toBe(gatewayContext);
live = false;
expect(readChannelContextGatewayContextResolver(source)?.()).toBeUndefined();
});
it("carries the resolver participant to one run admission without route inference", async () => {
const cleanup = configureChannelAdmissionEvidenceCollection(true);
try {
@@ -1,4 +1,5 @@
import type { DecisionReceiptV1 } from "../../../packages/gateway-protocol/src/index.js";
import type { GatewayContextResolver } from "../../gateway/server-methods/types.js";
import { resolveGlobalSingleton } from "../../shared/global-singleton.js";
import {
finalizedContextScopeKey,
@@ -65,6 +66,7 @@ type ChannelAdmissionEvidenceOwner = Readonly<{
record: object;
epoch: object;
isLive: () => boolean;
resolveGatewayContext?: GatewayContextResolver;
}>;
type PreparedChannelAdmissionEvidence = Readonly<{
@@ -81,7 +83,10 @@ const state = resolveGlobalSingleton(CHANNEL_ADMISSION_EVIDENCE_STATE_KEY, () =>
resolutionByIngress: new WeakMap<object, ChannelIngressResolutionBinding>(),
ownerByChannelId: new Map<string, ChannelAdmissionEvidenceOwner>(),
evidenceByPreparation: new WeakMap<object, ChannelAdmissionEvidence | undefined>(),
gatewayResolverByPreparation: new WeakMap<object, GatewayContextResolver>(),
evidenceByContext: new WeakMap<object, ChannelAdmissionEvidence>(),
gatewayResolverByContext: new WeakMap<object, GatewayContextResolver>(),
gatewayResolverConflictsByContext: new WeakSet<object>(),
scopeByContext: new WeakMap<object, string>(),
consumedEvidence: new WeakSet<object>(),
decisionSink: undefined as ((receipt: DecisionReceiptV1) => boolean) | undefined,
@@ -439,6 +444,9 @@ export function prepareHostChannelContextAdmissionEvidence(params: {
preparation,
valid ? combineChannelAdmissionEvidence(sources) : unknownChannelAdmissionEvidence(),
);
if (valid && params.owner?.resolveGatewayContext) {
state.gatewayResolverByPreparation.set(preparation, params.owner.resolveGatewayContext);
}
return preparation;
}
@@ -448,11 +456,17 @@ export function bindHostChannelContextAdmissionEvidence(params: {
preparation: PreparedChannelAdmissionEvidence;
}): void {
const preparedEvidence = state.evidenceByPreparation.get(params.preparation);
const gatewayContextResolver = state.gatewayResolverByPreparation.get(params.preparation);
state.evidenceByPreparation.delete(params.preparation);
state.gatewayResolverByPreparation.delete(params.preparation);
const scopeKey = finalizedContextScopeKey(params.context);
if (gatewayContextResolver && scopeKey !== undefined) {
state.gatewayResolverByContext.set(params.context, gatewayContextResolver);
state.scopeByContext.set(params.context, scopeKey);
}
if (!state.collectionEnabled) {
return;
}
const scopeKey = finalizedContextScopeKey(params.context);
const evidence =
preparedEvidence && scopeKey !== undefined
? preparedEvidence
@@ -471,10 +485,17 @@ export function readChannelContextAdmissionEvidence(
return state.evidenceByContext.get(context);
}
export function readChannelContextGatewayContextResolver(
context: object,
): GatewayContextResolver | undefined {
return state.gatewayResolverByContext.get(context);
}
/** Preserve private evidence when an owner intentionally replaces a finalized context object. */
export function copyChannelParticipantAdmissionEvidence(source: object, target: object): void {
const evidence = state.evidenceByContext.get(source);
if (!evidence) {
const gatewayContextResolver = state.gatewayResolverByContext.get(source);
if (!evidence && !gatewayContextResolver) {
return;
}
const sourceScope = state.scopeByContext.get(source);
@@ -485,6 +506,16 @@ export function copyChannelParticipantAdmissionEvidence(source: object, target:
activePayload(evidence, Date.now()) !== undefined
? evidence
: unknownChannelAdmissionEvidence();
if (gatewayContextResolver && sourceScope !== undefined && targetScope === sourceScope) {
const currentResolver = state.gatewayResolverByContext.get(target);
if (currentResolver && currentResolver !== gatewayContextResolver) {
state.gatewayResolverByContext.delete(target);
state.gatewayResolverConflictsByContext.add(target);
} else if (!state.gatewayResolverConflictsByContext.has(target)) {
state.gatewayResolverByContext.set(target, gatewayContextResolver);
state.scopeByContext.set(target, sourceScope);
}
}
if (safeEvidence) {
state.evidenceByContext.set(target, safeEvidence);
if (targetScope !== undefined) {
@@ -360,6 +360,7 @@ export async function prepareAgentRunDispatch(params: {
task: params.request.message.trim(),
requester: params.client?.internal?.pluginSubagentRequester,
pluginId: normalizeOptionalString(params.client?.internal?.pluginRuntimeOwnerId),
gatewayContextResolver: params.context.resolveGatewayContext,
});
} catch (err) {
params.context.logGateway.warn(
@@ -23,6 +23,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessageWithCode } from "../../infra/errors.js";
import type { MediaFact } from "../../media/media-facts.js";
import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js";
import { bindGatewayContextResolver } from "../../plugins/runtime/gateway-request-scope.js";
import { retainGatewayRootWorkAdmissionContinuation } from "../../process/gateway-work-admission.js";
import {
annotateInterSessionPromptText,
@@ -353,6 +354,10 @@ export function startAgentRunExecution(params: {
...(executionIdentityAdmission ? { executionIdentityAdmission } : {}),
operationalRunInstance: prepared.operationalRunInstance,
onAdmittedRunContext: (admittedRunContext) => {
bindGatewayContextResolver(
admittedRunContext,
params.context.resolveGatewayContext,
);
const authority = getAdmittedRunDelegatedAuthority(admittedRunContext);
if (!authority) {
throw new Error("agent run delegated authority was not admitted");
+1
View File
@@ -41,5 +41,6 @@ export type AgentTurnContext = Pick<
| "loadGatewayModelCatalog"
| "loadGatewayModelCatalogSnapshot"
| "logGateway"
| "resolveGatewayContext"
| "validateAgentRuntimeApprovalAuthority"
>;
+8
View File
@@ -434,6 +434,10 @@ export async function startGatewayCoreRuntime(input: {
);
};
let attachedGatewayMethodRegistry = buildAttachedGatewayMethodRegistry(pluginRuntime.registry);
let retireAttachedPluginRuntimeBindings = () => {};
kernel.addGatewayLifetimeSidecar({
stop: async () => retireAttachedPluginRuntimeBindings(),
});
const listAttachedGatewayMethods = () => {
const methods = attachedGatewayMethodRegistry.listAdvertisedMethods();
methods.push(...listStartupChannelGatewayMethods());
@@ -443,7 +447,11 @@ export async function startGatewayCoreRuntime(input: {
const replaceAttachedPluginRuntime = (loaded: {
pluginRegistry: typeof pluginRuntime.registry;
gatewayMethods: string[];
retireGatewayRuntimeBindings?: () => void;
}) => {
const retirePreviousBindings = retireAttachedPluginRuntimeBindings;
retireAttachedPluginRuntimeBindings = loaded.retireGatewayRuntimeBindings ?? (() => {});
retirePreviousBindings();
pluginRuntime.registry = loaded.pluginRegistry;
pluginRuntime.baseGatewayMethods = loaded.gatewayMethods;
for (const key of attachedPluginGatewayHandlerKeys) {
+1
View File
@@ -262,6 +262,7 @@ export function createGatewayInstanceRuntime(
},
},
recovery,
isAvailable: () => !closed && options.isDispatchAvailable(),
close: () => {
closed = true;
releaseRecoveryRuntime();
@@ -54,5 +54,6 @@ export type GatewayInstanceRuntime = {
approvalEvents: GatewayApprovalEventPublisher;
nativeApprovals: GatewayNativeApprovalRuntime;
recovery: GatewayRecoveryRuntime;
isAvailable: () => boolean;
close: () => void;
};
@@ -228,6 +228,8 @@ export async function prepareGatewayKernelRequestRuntime(params: {
logError: (message) => log.error(message),
});
gatewayInstanceRuntimeRef.current = gatewayInstanceRuntime;
gatewayRequestContext.resolveGatewayContext = () =>
gatewayInstanceRuntime.isAvailable() ? gatewayRequestContext : undefined;
gatewayRequestContext.approvalEvents = gatewayInstanceRuntime.approvalEvents;
gatewayRequestContext.recoveryRuntime = gatewayInstanceRuntime.recovery;
return { ...runtime, chatMetadataLifecycle, gatewayRequestContext, gatewayInstanceRuntime };
@@ -17,7 +17,11 @@ import { finalizeTaskRunByRunId } from "../../tasks/detached-task-runtime.js";
import { findTaskByRunId } from "../../tasks/runtime-internal.js";
import type { TaskStatus } from "../../tasks/task-registry.types.js";
import { formatForLog } from "../ws-log.js";
import type { GatewayRequestContext, GatewayRequestHandlerOptions } from "./types.js";
import type {
GatewayContextResolver,
GatewayRequestContext,
GatewayRequestHandlerOptions,
} from "./types.js";
export type TrustedGroupMetadata = {
groupId?: string;
@@ -176,6 +180,7 @@ export async function registerPluginSubagentRunFromGateway(params: {
task: string;
requester?: PluginSubagentRequesterContext;
pluginId?: string;
gatewayContextResolver?: GatewayContextResolver;
}): Promise<void> {
const childSessionKey = params.childSessionKey.trim();
if (!childSessionKey) {
@@ -200,6 +205,9 @@ export async function registerPluginSubagentRunFromGateway(params: {
childSessionKey,
runId: params.runId,
task: params.task,
...(params.gatewayContextResolver
? { gatewayContextResolver: params.gatewayContextResolver }
: {}),
})
) {
return;
@@ -216,6 +224,9 @@ export async function registerPluginSubagentRunFromGateway(params: {
...(params.pluginId ? { label: `plugin:${params.pluginId}` } : {}),
expectsCompletionMessage: params.requester !== undefined,
spawnMode: "run",
...(params.gatewayContextResolver
? { gatewayContextResolver: params.gatewayContextResolver }
: {}),
});
}
+5 -1
View File
@@ -396,9 +396,13 @@ type GatewayResidentBridgeContext = {
};
/** Complete runtime context available to gateway request handlers. */
export type GatewayContextResolver = () => GatewayRequestContext | undefined;
export type GatewayRequestContext = GatewayKernelContext &
GatewayTransportContext &
GatewayResidentBridgeContext;
GatewayResidentBridgeContext & {
/** Live instance routing only; never authorization or wire state. */
resolveGatewayContext?: GatewayContextResolver;
};
/** Full dispatch context for raw request frames before params are normalized. */
export type GatewayRequestOptions = {
+1 -2
View File
@@ -14,8 +14,7 @@ import {
} from "../plugins/runtime-degraded-state.js";
import { resolveDurableWorkerProviderAutoEnabledReasons } from "../plugins/worker-provider-manifest.js";
import { mergeActivationSectionsIntoRuntimeConfig } from "./plugin-activation-runtime-config.js";
import type { GatewayRequestHandler } from "./server-methods/types.js";
import type { GatewayContextResolver } from "./server-plugin-in-process-dispatch.js";
import type { GatewayContextResolver, GatewayRequestHandler } from "./server-methods/types.js";
import { loadGatewayPlugins } from "./server-plugins.js";
// Gateway plugin bootstrap applies activation/auto-enable config, loads plugins,
@@ -14,6 +14,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,
@@ -55,8 +56,6 @@ type DispatchGatewayMethodInProcessOptions = {
resolveGatewayContext?: GatewayContextResolver;
};
export type GatewayContextResolver = () => GatewayRequestContext | undefined;
type ResolvedInProcessGatewayDispatch = {
client: NonNullable<GatewayRequestOptions["client"]>;
context: GatewayRequestContext;
+1 -2
View File
@@ -1,7 +1,6 @@
import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "./node-command-policy.js";
import type { GatewayRequestContext } from "./server-methods/types.js";
import type { GatewayContextResolver } from "./server-plugin-in-process-dispatch.js";
import type { GatewayContextResolver, GatewayRequestContext } from "./server-methods/types.js";
export function hasInProcessGatewayContext(
resolveGatewayContext?: GatewayContextResolver,
@@ -208,6 +208,9 @@ describe("gateway plugin instance bindings", () => {
await second.close({ reason: "close last-started Gateway first" });
started.pop();
await expect(requestInstanceBindingProbe(secondRuntime)).rejects.toThrow(
"In-process gateway dispatch requires a gateway request scope or instance binding",
);
await expect(requestInstanceBindingProbe(firstRuntime)).resolves.toEqual(firstProbe);
await expect(
firstRuntime.subagent.getSessionMessages({ sessionKey: "agent:main:main", limit: 1 }),
@@ -270,6 +273,9 @@ describe("gateway plugin instance bindings", () => {
expect(reloadedProbe.sessionsId).toBe(initialProbe.sessionsId);
expect(reloadedProbe.placementId).toBe(initialProbe.placementId);
expect(hotReloadRecovery).not.toHaveBeenCalled();
await expect(requestInstanceBindingProbe(initialRuntime)).rejects.toThrow(
"In-process gateway dispatch requires a gateway request scope or instance binding",
);
await expect(
reloadedRuntime.subagent.getSessionMessages({
sessionKey: "agent:main:main",
+49 -23
View File
@@ -17,7 +17,10 @@ import { getPluginModuleLoaderStats } from "../plugins/plugin-module-loader-cach
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import type { PluginRegistryParams } from "../plugins/registry-types.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
import {
bindGatewayContextResolver,
getPluginRuntimeGatewayRequestScope,
} from "../plugins/runtime/gateway-request-scope.js";
import { createPluginRuntimeLoaderLogger } from "../plugins/runtime/load-context.js";
import { resolvePluginSubagentCompletionRequester } from "../plugins/runtime/subagent-requester-context.js";
import type {
@@ -28,12 +31,15 @@ import type {
import type { PluginLogger, PluginOrigin } from "../plugins/types.js";
import { ADMIN_SCOPE } from "./method-scopes.js";
import { normalizeOperatorScopeList, type OperatorScope } from "./operator-scopes.js";
import type { GatewayRequestHandler, GatewayRequestOptions } from "./server-methods/types.js";
import type {
GatewayContextResolver,
GatewayRequestHandler,
GatewayRequestOptions,
} from "./server-methods/types.js";
import {
dispatchGatewayMethodInProcess,
dispatchGatewayMethodInProcessRaw,
getInProcessGatewayRequestContext,
type GatewayContextResolver,
} from "./server-plugin-in-process-dispatch.js";
import { resolvePluginSubagentToolsAlsoAllow } from "./server-plugin-runtime-client.js";
import {
@@ -241,7 +247,7 @@ export function createGatewaySubagentRuntime(
return { messages: Array.isArray(payload?.messages) ? payload.messages : [] };
};
return {
const subagentRuntime: PluginRuntime["subagent"] = {
async run(params) {
const pluginSubagentRequester = resolvePluginSubagentCompletionRequester(
params.completionDelivery,
@@ -367,6 +373,10 @@ export function createGatewaySubagentRuntime(
);
},
};
if (resolveGatewayContext) {
bindGatewayContextResolver(subagentRuntime, resolveGatewayContext);
}
return subagentRuntime;
}
type GatewayRuntimeNodes = Awaited<ReturnType<PluginRuntime["nodes"]["list"]>>["nodes"];
@@ -433,26 +443,37 @@ export function createGatewayNodesRuntime(
function createGatewayPluginRuntimeBindings(
resolveGatewayContext: GatewayContextResolver,
overridePolicies: PluginSubagentOverridePolicies,
): Pick<PluginRuntime, "gateway" | "nodes" | "subagent"> &
Pick<CreatePluginRuntimeOptions, "dispatchReplyFromConfig"> {
): {
runtime: Pick<PluginRuntime, "gateway" | "nodes" | "subagent"> &
Pick<CreatePluginRuntimeOptions, "dispatchReplyFromConfig">;
retire: () => void;
} {
let active = true;
const resolveBoundGatewayContext = () => (active ? resolveGatewayContext() : undefined);
return {
dispatchReplyFromConfig: async (params) => {
const { dispatchReplyFromConfig } =
await import("../auto-reply/reply/dispatch-from-config.js");
const sessionWorkerPlacementContext =
getInProcessGatewayRequestContext(resolveGatewayContext);
return await dispatchReplyFromConfig({
...params,
...(sessionWorkerPlacementContext ? { sessionWorkerPlacementContext } : {}),
});
retire: () => {
active = false;
},
gateway: {
isAvailable: async () => hasInProcessGatewayContext(resolveGatewayContext),
request: (method, params, options) =>
dispatchTrustedPluginGatewayMethod(method, params, options, resolveGatewayContext),
runtime: {
dispatchReplyFromConfig: async (params) => {
const { dispatchReplyFromConfig } =
await import("../auto-reply/reply/dispatch-from-config.js");
const sessionWorkerPlacementContext = getInProcessGatewayRequestContext(
resolveBoundGatewayContext,
);
return await dispatchReplyFromConfig({
...params,
...(sessionWorkerPlacementContext ? { sessionWorkerPlacementContext } : {}),
});
},
gateway: {
isAvailable: async () => hasInProcessGatewayContext(resolveBoundGatewayContext),
request: (method, params, options) =>
dispatchTrustedPluginGatewayMethod(method, params, options, resolveBoundGatewayContext),
},
nodes: createGatewayNodesRuntime(resolveBoundGatewayContext),
subagent: createGatewaySubagentRuntime(resolveBoundGatewayContext, overridePolicies),
},
nodes: createGatewayNodesRuntime(resolveGatewayContext),
subagent: createGatewaySubagentRuntime(resolveGatewayContext, overridePolicies),
};
}
@@ -564,6 +585,7 @@ export function loadGatewayPlugins(params: {
return {
pluginRegistry,
gatewayMethods: [...params.baseMethods],
retireGatewayRuntimeBindings: () => {},
};
}
const beforeLoad = performance.now();
@@ -593,7 +615,7 @@ export function loadGatewayPlugins(params: {
}),
runtimeOptions: {
allowGatewaySubagentBinding: true,
...gatewayRuntimeBindings,
...gatewayRuntimeBindings.runtime,
},
channelPluginLoadIntent: params.channelPluginLoadIntent,
preferBuiltPluginArtifacts: true,
@@ -641,5 +663,9 @@ export function loadGatewayPlugins(params: {
.join(","),
],
]);
return { pluginRegistry, gatewayMethods };
return {
pluginRegistry,
gatewayMethods,
retireGatewayRuntimeBindings: gatewayRuntimeBindings.retire,
};
}
+1 -1
View File
@@ -16,7 +16,7 @@ import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { resolveGatewayStartupPluginActivationConfig } from "./plugin-activation-runtime-config.js";
import { listGatewayMethods } from "./server-methods-list.js";
import type { GatewayContextResolver } from "./server-plugin-in-process-dispatch.js";
import type { GatewayContextResolver } from "./server-methods/types.js";
type GatewayPluginBootstrapLog = {
info: (message: string) => void;
+2
View File
@@ -38,6 +38,7 @@ import {
import type { PluginRegistryState } from "./registry-state.js";
import type { PluginRecord } from "./registry-types.js";
import {
getGatewayContextResolver,
withPluginRuntimePluginIdScope,
withPluginRuntimePluginScope,
} from "./runtime/gateway-request-scope.js";
@@ -169,6 +170,7 @@ export function createPluginRuntimeResolver(state: PluginRegistryState) {
channelId: record.id,
record,
epoch,
resolveGatewayContext: getGatewayContextResolver(registryParams.runtime.subagent),
isLive: () =>
ownsLiveRegistrySlot() && isPluginRecordLifecycleEpochActive(registry, record, epoch),
});
@@ -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,29 @@ const pluginRuntimeGatewayRequestScope = resolveGlobalSingleton<
PLUGIN_RUNTIME_GATEWAY_REQUEST_SCOPE_KEY,
() => new AsyncLocalStorage<PluginRuntimeGatewayRequestScope>(),
);
const gatewayContextResolvers = new WeakMap<object, GatewayContextResolver>();
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 first = owners[0] ? gatewayContextResolvers.get(owners[0]) : undefined;
return first && owners.every((owner) => gatewayContextResolvers.get(owner) === first)
? first
: undefined;
}
/**
* Runs plugin gateway handlers with request-scoped context that runtime helpers can read.