mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
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:
committed by
GitHub
parent
8ccfd075fe
commit
2cd87d3d27
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -41,5 +41,6 @@ export type AgentTurnContext = Pick<
|
||||
| "loadGatewayModelCatalog"
|
||||
| "loadGatewayModelCatalogSnapshot"
|
||||
| "logGateway"
|
||||
| "resolveGatewayContext"
|
||||
| "validateAgentRuntimeApprovalAuthority"
|
||||
>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user