diff --git a/src/agents/subagents/announce/subagent-announce-delivery.test.ts b/src/agents/subagents/announce/subagent-announce-delivery.test.ts index a58c6f4a602b..d3d08bcbb895 100644 --- a/src/agents/subagents/announce/subagent-announce-delivery.test.ts +++ b/src/agents/subagents/announce/subagent-announce-delivery.test.ts @@ -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, }); }); diff --git a/src/agents/subagents/announce/subagent-announce-delivery.ts b/src/agents/subagents/announce/subagent-announce-delivery.ts index 46fa16f07b29..efcf3b74d7fe 100644 --- a/src/agents/subagents/announce/subagent-announce-delivery.ts +++ b/src/agents/subagents/announce/subagent-announce-delivery.ts @@ -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 { 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, }); }, }); diff --git a/src/agents/subagents/announce/subagent-announce-direct-delivery.ts b/src/agents/subagents/announce/subagent-announce-direct-delivery.ts index 09004aff5e18..618ec9a44c43 100644 --- a/src/agents/subagents/announce/subagent-announce-direct-delivery.ts +++ b/src/agents/subagents/announce/subagent-announce-direct-delivery.ts @@ -73,6 +73,7 @@ async function runAnnounceAgentCall(params: { delegatedToolPolicyHandoff?: SubagentCompletionToolHandoffRegistration; expectFinal?: boolean; timeoutMs?: number; + resolveGatewayContext?: import("../../../gateway/server-methods/types.js").GatewayContextResolver; }): Promise { 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 { if (params.signal?.aborted) { return { @@ -369,6 +372,7 @@ export async function sendSubagentAnnounceDirectly(params: { : undefined, expectFinal: true, timeoutMs: announceTimeoutMs, + resolveGatewayContext: params.resolveGatewayContext, }); }, }); diff --git a/src/agents/subagents/announce/subagent-announce.requester-settle-wake.ts b/src/agents/subagents/announce/subagent-announce.requester-settle-wake.ts index c0961b5e677e..832d5f007beb 100644 --- a/src/agents/subagents/announce/subagent-announce.requester-settle-wake.ts +++ b/src/agents/subagents/announce/subagent-announce.requester-settle-wake.ts @@ -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 diff --git a/src/agents/subagents/announce/subagent-announce.ts b/src/agents/subagents/announce/subagent-announce.ts index 9036078d1e1b..0472571d9479 100644 --- a/src/agents/subagents/announce/subagent-announce.ts +++ b/src/agents/subagents/announce/subagent-announce.ts @@ -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 { 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"); diff --git a/src/agents/subagents/registry/subagent-gateway-context-binding.test.ts b/src/agents/subagents/registry/subagent-gateway-context-binding.test.ts new file mode 100644 index 000000000000..cab49f4f1d85 --- /dev/null +++ b/src/agents/subagents/registry/subagent-gateway-context-binding.test.ts @@ -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(); + }); +}); diff --git a/src/agents/subagents/registry/subagent-registry-lifecycle-announce-cleanup.ts b/src/agents/subagents/registry/subagent-registry-lifecycle-announce-cleanup.ts index c4dd4e124b04..dff2b6363d4d 100644 --- a/src/agents/subagents/registry/subagent-registry-lifecycle-announce-cleanup.ts +++ b/src/agents/subagents/registry/subagent-registry-lifecycle-announce-cleanup.ts @@ -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, diff --git a/src/agents/subagents/registry/subagent-registry-lifecycle-wake.ts b/src/agents/subagents/registry/subagent-registry-lifecycle-wake.ts index 70b4bdefd8b6..57b9502f89c1 100644 --- a/src/agents/subagents/registry/subagent-registry-lifecycle-wake.ts +++ b/src/agents/subagents/registry/subagent-registry-lifecycle-wake.ts @@ -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); diff --git a/src/agents/subagents/registry/subagent-registry-run-launch.ts b/src/agents/subagents/registry/subagent-registry-run-launch.ts index 3aa4a67314cc..aecd239a54e3 100644 --- a/src/agents/subagents/registry/subagent-registry-run-launch.ts +++ b/src/agents/subagents/registry/subagent-registry-run-launch.ts @@ -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, diff --git a/src/agents/subagents/registry/subagent-registry-run-manager.ts b/src/agents/subagents/registry/subagent-registry-run-manager.ts index ca0c07527b4f..07f28c525d50 100644 --- a/src/agents/subagents/registry/subagent-registry-run-manager.ts +++ b/src/agents/subagents/registry/subagent-registry-run-manager.ts @@ -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); } diff --git a/src/agents/subagents/registry/subagent-registry-run-recovery.ts b/src/agents/subagents/registry/subagent-registry-run-recovery.ts index 1c44d4fc8a9f..d18b24d5fa9a 100644 --- a/src/agents/subagents/registry/subagent-registry-run-recovery.ts +++ b/src/agents/subagents/registry/subagent-registry-run-recovery.ts @@ -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) { diff --git a/src/agents/subagents/registry/subagent-registry.ts b/src/agents/subagents/registry/subagent-registry.ts index 43ab12d1253c..5fe64ad4eabd 100644 --- a/src/agents/subagents/registry/subagent-registry.ts +++ b/src/agents/subagents/registry/subagent-registry.ts @@ -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 } + : {}), }); } diff --git a/src/agents/subagents/spawn/subagent-spawn-gateway.ts b/src/agents/subagents/spawn/subagent-spawn-gateway.ts index 2c9d558a8bc4..0e89e9bbf4bd 100644 --- a/src/agents/subagents/spawn/subagent-spawn-gateway.ts +++ b/src/agents/subagents/spawn/subagent-spawn-gateway.ts @@ -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 } : {}), diff --git a/src/agents/subagents/spawn/subagent-spawn.in-process-gateway.test.ts b/src/agents/subagents/spawn/subagent-spawn.in-process-gateway.test.ts index 3863c5c779c8..b6211f268531 100644 --- a/src/agents/subagents/spawn/subagent-spawn.in-process-gateway.test.ts +++ b/src/agents/subagents/spawn/subagent-spawn.in-process-gateway.test.ts @@ -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; options?: NonNullable[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(); diff --git a/src/agents/subagents/spawn/subagent-spawn.ts b/src/agents/subagents/spawn/subagent-spawn.ts index 15dda35017a6..8bb4cbbc45fd 100644 --- a/src/agents/subagents/spawn/subagent-spawn.ts +++ b/src/agents/subagents/spawn/subagent-spawn.ts @@ -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", ); diff --git a/src/agents/tools/gateway-caller-context.ts b/src/agents/tools/gateway-caller-context.ts index d3f3bd8a65ae..f56e77c32155 100644 --- a/src/agents/tools/gateway-caller-context.ts +++ b/src/agents/tools/gateway-caller-context.ts @@ -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( 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( ...(executionIdentityToken ? { executionIdentityToken } : {}), ...(workerTurnClaim ? { workerTurnClaim } : {}), ...(workerTurnExecutionIdentityCapability ? { workerTurnExecutionIdentityCapability } : {}), + ...(gatewayContextResolver ? { gatewayContextResolver } : {}), ...(turnSourceChannel ? { turnSourceChannel } : {}), ...(turnSourceLocal === true ? { turnSourceLocal: true } : {}), ...(turnSourceTo ? { turnSourceTo } : {}), diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index d8e5e4f7e75f..69f2d2e4c67b 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -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; }, }); diff --git a/src/auto-reply/reply/get-reply-run-context.ts b/src/auto-reply/reply/get-reply-run-context.ts index 51bf8b8b1819..ae1826f36de9 100644 --- a/src/auto-reply/reply/get-reply-run-context.ts +++ b/src/auto-reply/reply/get-reply-run-context.ts @@ -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, diff --git a/src/channels/message-access/admission-evidence.test.ts b/src/channels/message-access/admission-evidence.test.ts index b25e6bad6275..76074283576b 100644 --- a/src/channels/message-access/admission-evidence.test.ts +++ b/src/channels/message-access/admission-evidence.test.ts @@ -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 { diff --git a/src/channels/message-access/admission-evidence.ts b/src/channels/message-access/admission-evidence.ts index d48647be1d38..f6182d4f6159 100644 --- a/src/channels/message-access/admission-evidence.ts +++ b/src/channels/message-access/admission-evidence.ts @@ -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(), ownerByChannelId: new Map(), evidenceByPreparation: new WeakMap(), + gatewayResolverByPreparation: new WeakMap(), evidenceByContext: new WeakMap(), + gatewayResolverByContext: new WeakMap(), + gatewayResolverConflictsByContext: new WeakSet(), scopeByContext: new WeakMap(), consumedEvidence: new WeakSet(), 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) { diff --git a/src/gateway/agent-turn/agent-run-admission-phase.ts b/src/gateway/agent-turn/agent-run-admission-phase.ts index 1691ff805913..bdaf751dfdff 100644 --- a/src/gateway/agent-turn/agent-run-admission-phase.ts +++ b/src/gateway/agent-turn/agent-run-admission-phase.ts @@ -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( diff --git a/src/gateway/agent-turn/agent-run-execution-phase.ts b/src/gateway/agent-turn/agent-run-execution-phase.ts index 2a589c632f44..258a51962844 100644 --- a/src/gateway/agent-turn/agent-run-execution-phase.ts +++ b/src/gateway/agent-turn/agent-run-execution-phase.ts @@ -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"); diff --git a/src/gateway/agent-turn/types.ts b/src/gateway/agent-turn/types.ts index 464c1bb6dab8..07c30531f25b 100644 --- a/src/gateway/agent-turn/types.ts +++ b/src/gateway/agent-turn/types.ts @@ -41,5 +41,6 @@ export type AgentTurnContext = Pick< | "loadGatewayModelCatalog" | "loadGatewayModelCatalogSnapshot" | "logGateway" + | "resolveGatewayContext" | "validateAgentRuntimeApprovalAuthority" >; diff --git a/src/gateway/server-core-runtime.ts b/src/gateway/server-core-runtime.ts index c89f353235c7..5b9c1f5688d5 100644 --- a/src/gateway/server-core-runtime.ts +++ b/src/gateway/server-core-runtime.ts @@ -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) { diff --git a/src/gateway/server-instance-runtime.ts b/src/gateway/server-instance-runtime.ts index bdbdec478701..a41c751b57fb 100644 --- a/src/gateway/server-instance-runtime.ts +++ b/src/gateway/server-instance-runtime.ts @@ -262,6 +262,7 @@ export function createGatewayInstanceRuntime( }, }, recovery, + isAvailable: () => !closed && options.isDispatchAvailable(), close: () => { closed = true; releaseRecoveryRuntime(); diff --git a/src/gateway/server-instance-runtime.types.ts b/src/gateway/server-instance-runtime.types.ts index 9cb448abad42..7e34d7dc3624 100644 --- a/src/gateway/server-instance-runtime.types.ts +++ b/src/gateway/server-instance-runtime.types.ts @@ -54,5 +54,6 @@ export type GatewayInstanceRuntime = { approvalEvents: GatewayApprovalEventPublisher; nativeApprovals: GatewayNativeApprovalRuntime; recovery: GatewayRecoveryRuntime; + isAvailable: () => boolean; close: () => void; }; diff --git a/src/gateway/server-kernel-request-runtime.ts b/src/gateway/server-kernel-request-runtime.ts index 730ec03a5340..da8bfb049054 100644 --- a/src/gateway/server-kernel-request-runtime.ts +++ b/src/gateway/server-kernel-request-runtime.ts @@ -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 }; diff --git a/src/gateway/server-methods/agent-task-tracking.ts b/src/gateway/server-methods/agent-task-tracking.ts index 2c5f4f44b67d..d99300b57185 100644 --- a/src/gateway/server-methods/agent-task-tracking.ts +++ b/src/gateway/server-methods/agent-task-tracking.ts @@ -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 { 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 } + : {}), }); } diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index ee3b5325ea4e..14e01059ffb3 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -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 = { diff --git a/src/gateway/server-plugin-bootstrap.ts b/src/gateway/server-plugin-bootstrap.ts index d1c3c51209ec..64a80b0a7a75 100644 --- a/src/gateway/server-plugin-bootstrap.ts +++ b/src/gateway/server-plugin-bootstrap.ts @@ -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, diff --git a/src/gateway/server-plugin-in-process-dispatch.ts b/src/gateway/server-plugin-in-process-dispatch.ts index 356308a03e73..0a960ae97036 100644 --- a/src/gateway/server-plugin-in-process-dispatch.ts +++ b/src/gateway/server-plugin-in-process-dispatch.ts @@ -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; context: GatewayRequestContext; diff --git a/src/gateway/server-plugins-node-runtime.ts b/src/gateway/server-plugins-node-runtime.ts index 8412d765e612..26234dd03a8f 100644 --- a/src/gateway/server-plugins-node-runtime.ts +++ b/src/gateway/server-plugins-node-runtime.ts @@ -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, diff --git a/src/gateway/server-plugins.lifecycle.test.ts b/src/gateway/server-plugins.lifecycle.test.ts index 35d2ff7a8b65..e0f4eee22486 100644 --- a/src/gateway/server-plugins.lifecycle.test.ts +++ b/src/gateway/server-plugins.lifecycle.test.ts @@ -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", diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index cfca1f80c511..d1d140895320 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -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>["nodes"]; @@ -433,26 +443,37 @@ export function createGatewayNodesRuntime( function createGatewayPluginRuntimeBindings( resolveGatewayContext: GatewayContextResolver, overridePolicies: PluginSubagentOverridePolicies, -): Pick & - Pick { +): { + runtime: Pick & + Pick; + 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, + }; } diff --git a/src/gateway/server-startup-plugins.ts b/src/gateway/server-startup-plugins.ts index 224dbb3da21a..e18f25b34920 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -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; diff --git a/src/plugins/registry-runtime.ts b/src/plugins/registry-runtime.ts index 4e8c74416a14..b484a4f75e6d 100644 --- a/src/plugins/registry-runtime.ts +++ b/src/plugins/registry-runtime.ts @@ -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), }); diff --git a/src/plugins/runtime/gateway-request-scope.ts b/src/plugins/runtime/gateway-request-scope.ts index 53eceaa1b48b..a9d63de1f297 100644 --- a/src/plugins/runtime/gateway-request-scope.ts +++ b/src/plugins/runtime/gateway-request-scope.ts @@ -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(), ); +const gatewayContextResolvers = new WeakMap(); + +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.