diff --git a/config/knip.all-exports.config.ts b/config/knip.all-exports.config.ts index e2a694889f68..19488a611862 100644 --- a/config/knip.all-exports.config.ts +++ b/config/knip.all-exports.config.ts @@ -85,7 +85,13 @@ const workspaces = Object.fromEntries( ...settings.entry, ...(workspace === "." ? [".agents/skills/**/scripts/**/*.{js,mjs,cjs,ts,mts,cts}!", ...ROOT_TEST_ENTRY_GLOBS] - : [TEST_ENTRY_GLOB]), + : [ + TEST_ENTRY_GLOB, + // QA Lab loads this plugin fixture by path during the Gateway E2E. + ...(workspace === "extensions/qa-lab" + ? ["test-fixtures/current-requester-subagent-plugin/index.js!"] + : []), + ]), ], project: workspace === "." diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 44ef7ec69345..5e934653610f 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -334,6 +334,7 @@ two-party event loops that do not go through the shared inbound reply runner. provider: "openai", // optional override model: "gpt-5.6-sol", // optional override deliver: false, + completionDelivery: "current-requester", // optional, before_dispatch hooks only }); // Wait for completion @@ -357,6 +358,8 @@ two-party event loops that do not go through the shared inbound reply runner. `toolsAlsoAllow` adds exact, uniquely owned tools registered by the calling plugin to the worker's normal tool surface. The runtime rejects core tools and names shared with another plugin. Profiles and operator tool policies still apply, including explicit allowlists and denies. + `completionDelivery: "current-requester"` is default-off and is only available while a `before_dispatch` hook is handling an authenticated inbound request. OpenClaw captures the canonical requester session and delivery route before invoking the plugin, then delivers the subagent completion through the normal announce path. Plugins cannot provide or override requester lineage or destination fields. Calls outside that requester-bound hook context are rejected. + `deleteSession(...)` can delete sessions created by the same plugin through `api.runtime.subagent.run(...)`. Deleting arbitrary user or operator sessions still requires an admin-scoped Gateway request. diff --git a/extensions/qa-lab/src/plugin-subagent-current-requester.e2e.test.ts b/extensions/qa-lab/src/plugin-subagent-current-requester.e2e.test.ts new file mode 100644 index 000000000000..d00354b83607 --- /dev/null +++ b/extensions/qa-lab/src/plugin-subagent-current-requester.e2e.test.ts @@ -0,0 +1,125 @@ +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { afterEach, describe, expect, it } from "vitest"; +import { startQaBusServer } from "./bus-server.js"; +import { createQaBusState } from "./bus-state.js"; +import { startQaGatewayChild } from "./gateway-child.js"; +import { startQaMockOpenAiServer } from "./providers/mock-openai/server.js"; +import { createQaChannelTransport } from "./qa-channel-transport.js"; + +const PLUGIN_ID = "qa-current-requester-subagent"; +const TRIGGER = "qa current requester completion"; +const COMPLETION_MARKER = "QA-CURRENT-REQUESTER-COMPLETION-OK"; +const REQUESTER_CONVERSATION = { id: "requester-user", kind: "direct" as const }; +const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); +const PLUGIN_DIR = path.join( + REPO_ROOT, + "extensions/qa-lab/test-fixtures/current-requester-subagent-plugin", +); + +function withFixturePlugin(config: OpenClawConfig): OpenClawConfig { + return { + ...config, + plugins: { + ...config.plugins, + enabled: true, + allow: [...new Set([...(config.plugins?.allow ?? []), PLUGIN_ID])], + load: { + ...config.plugins?.load, + paths: [...new Set([...(config.plugins?.load?.paths ?? []), PLUGIN_DIR])], + }, + entries: { + ...config.plugins?.entries, + [PLUGIN_ID]: { enabled: true }, + }, + }, + }; +} + +describe("plugin subagent current-requester delivery", () => { + const cleanups: Array<() => Promise> = []; + + afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) { + await cleanup(); + } + }); + + it("delivers to the captured requester and rejects forged lineage outside hook scope", async () => { + const state = createQaBusState(); + const transport = createQaChannelTransport(state); + const bus = await startQaBusServer({ state }); + cleanups.push(() => bus.stop()); + + const mock = await startQaMockOpenAiServer(); + cleanups.push(() => mock.stop()); + + const gateway = await startQaGatewayChild({ + repoRoot: REPO_ROOT, + useRepoCli: true, + providerBaseUrl: `${mock.baseUrl}/v1`, + providerMode: "mock-openai", + transport, + transportBaseUrl: bus.baseUrl, + controlUiEnabled: false, + mutateConfig: withFixturePlugin, + }); + cleanups.push(() => gateway.stop()); + await transport.waitReady({ gateway }); + + const outsideHookResponse = await fetch( + `${gateway.baseUrl}/qa/current-requester/outside-hook`, + { + method: "POST", + headers: { Authorization: `Bearer ${gateway.token}` }, + }, + ); + expect(outsideHookResponse.status).toBe(409); + await expect(outsideHookResponse.json()).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining("requester-bound plugin hook invocation"), + }); + + const outboundStartIndex = state + .getSnapshot() + .messages.filter((message) => message.direction === "outbound").length; + await transport.sendInbound({ + accountId: "default", + conversation: REQUESTER_CONVERSATION, + senderId: REQUESTER_CONVERSATION.id, + text: TRIGGER, + }); + + let completion; + try { + const spawn = await transport.waitForOutbound({ + conversation: REQUESTER_CONVERSATION, + sinceIndex: outboundStartIndex, + timeoutMs: 30_000, + }); + expect(spawn.text).toContain("QA-CURRENT-REQUESTER-SPAWNED"); + completion = await transport.waitForOutbound({ + conversation: REQUESTER_CONVERSATION, + sinceIndex: outboundStartIndex, + textIncludes: COMPLETION_MARKER, + timeoutMs: 90_000, + }); + } catch (error) { + throw new Error( + [ + error instanceof Error ? error.message : String(error), + `bus=${JSON.stringify(state.getSnapshot())}`, + `gateway=${gateway.logs()}`, + ].join("\n"), + { cause: error }, + ); + } + expect(completion.accountId).toBe("default"); + + const outbound = state + .getSnapshot() + .messages.filter((message) => message.direction === "outbound"); + expect(outbound.some((message) => message.conversation.id === "attacker")).toBe(false); + expect(outbound.filter((message) => message.text.includes(COMPLETION_MARKER))).toHaveLength(1); + }, 180_000); +}); diff --git a/extensions/qa-lab/test-fixtures/current-requester-subagent-plugin/index.js b/extensions/qa-lab/test-fixtures/current-requester-subagent-plugin/index.js new file mode 100644 index 000000000000..e9522e117d92 --- /dev/null +++ b/extensions/qa-lab/test-fixtures/current-requester-subagent-plugin/index.js @@ -0,0 +1,68 @@ +import { randomUUID } from "node:crypto"; + +const TRIGGER = "qa current requester completion"; +const COMPLETION_MARKER = "QA-CURRENT-REQUESTER-COMPLETION-OK"; + +function writeJson(res, statusCode, body) { + res.statusCode = statusCode; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(`${JSON.stringify(body)}\n`); +} + +function createForgedRunParams() { + return { + sessionKey: `agent:qa:subagent:qa-current-requester-${randomUUID()}`, + message: `Reply with only this exact marker: ${COMPLETION_MARKER}`, + deliver: false, + completionDelivery: "current-requester", + requesterSessionKey: "agent:main:qa-channel:direct:attacker", + expectsCompletionMessage: false, + channel: "qa-channel", + to: "dm:attacker", + }; +} + +export default { + id: "qa-current-requester-subagent", + register(api) { + api.on("before_dispatch", async (event) => { + if (!event.content.toLowerCase().includes(TRIGGER)) { + return undefined; + } + let result; + try { + result = await api.runtime.subagent.run(createForgedRunParams()); + } catch (error) { + return { + handled: true, + text: `QA-CURRENT-REQUESTER-ERROR ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + return { + handled: true, + text: `QA-CURRENT-REQUESTER-SPAWNED ${result.runId}`, + }; + }); + + api.registerHttpRoute({ + path: "/qa/current-requester/outside-hook", + auth: "gateway", + match: "exact", + gatewayRuntimeScopeSurface: "trusted-operator", + async handler(_req, res) { + try { + const result = await api.runtime.subagent.run(createForgedRunParams()); + writeJson(res, 500, { ok: true, runId: result.runId }); + } catch (error) { + writeJson(res, 409, { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + return true; + }, + }); + }, +}; diff --git a/extensions/qa-lab/test-fixtures/current-requester-subagent-plugin/openclaw.plugin.json b/extensions/qa-lab/test-fixtures/current-requester-subagent-plugin/openclaw.plugin.json new file mode 100644 index 000000000000..e5f1b79077ba --- /dev/null +++ b/extensions/qa-lab/test-fixtures/current-requester-subagent-plugin/openclaw.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "qa-current-requester-subagent", + "activation": { + "onStartup": true + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/src/auto-reply/reply/dispatch-from-config.choose-route.ts b/src/auto-reply/reply/dispatch-from-config.choose-route.ts index 512222e2fdc5..9b42dd8b569b 100644 --- a/src/auto-reply/reply/dispatch-from-config.choose-route.ts +++ b/src/auto-reply/reply/dispatch-from-config.choose-route.ts @@ -4,6 +4,7 @@ import { resolveSendableOutboundReplyParts, } from "openclaw/plugin-sdk/reply-payload"; import { logVerbose } from "../../globals.js"; +import { createPluginSubagentRequesterContext } from "../../plugins/runtime/subagent-requester-context.js"; import { registerReplyDispatcherSettledTask } from "../dispatch-dispatcher.js"; import { copyReplyPayloadMetadata, @@ -458,6 +459,18 @@ export async function chooseDispatchRoute(state: PrepareDispatchOperationReadySt // Run before_dispatch hook — let plugins inspect or handle before model dispatch. if (hookRunner?.hasHooks("before_dispatch")) { + // This outer lookup key is resolved from the routed context; fields inside + // sessionStoreEntry.entry cannot redirect hook or requester lineage. + const beforeDispatchSessionKey = sessionStoreEntry.sessionKey ?? sessionKey; + const pluginSubagentRequester = createPluginSubagentRequesterContext({ + sessionKey: beforeDispatchSessionKey, + origin: { + channel: routeReplyChannel, + to: routeReplyTo, + accountId: replyContextAccountId, + threadId: routeReplyThreadId, + }, + }); const beforeDispatchResult = await traceReplyPhase("reply.before_dispatch_hooks", () => runWithDispatchLifecycleAdmission( async () => @@ -470,7 +483,7 @@ export async function chooseDispatchRoute(state: PrepareDispatchOperationReadySt content: state.hookContext.content, body: state.hookContext.bodyForAgent ?? state.hookContext.body, channel: state.hookContext.channelId, - sessionKey: sessionStoreEntry.sessionKey ?? sessionKey, + sessionKey: beforeDispatchSessionKey, senderId: state.hookContext.senderId, replyToId: state.hookContext.replyToId, replyToIdFull: state.hookContext.replyToIdFull, @@ -485,7 +498,7 @@ export async function chooseDispatchRoute(state: PrepareDispatchOperationReadySt channelId: state.hookContext.channelId, accountId: state.hookContext.accountId, conversationId: state.inboundClaimContext.conversationId, - sessionKey: sessionStoreEntry.sessionKey ?? sessionKey, + sessionKey: beforeDispatchSessionKey, senderId: state.hookContext.senderId, replyToId: state.hookContext.replyToId, replyToIdFull: state.hookContext.replyToIdFull, @@ -493,6 +506,7 @@ export async function chooseDispatchRoute(state: PrepareDispatchOperationReadySt replyToSender: state.hookContext.replyToSender, replyToIsQuote: state.hookContext.replyToIsQuote, }, + pluginSubagentRequester, ), trackDispatchLifecycleWork, ), diff --git a/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts index 3e7adaa5ae1b..c94a13692b89 100644 --- a/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts @@ -2,6 +2,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; +import type { PluginSubagentRequesterContext } from "../../plugins/runtime/subagent-requester-context.js"; import { setReplyPayloadMetadata } from "../reply-payload.js"; import type { MsgContext } from "../templating.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; @@ -144,6 +145,45 @@ describe("before_dispatch hook", () => { expect(result.queuedFinal).toBe(true); }); + it("passes canonical requester lineage to the before_dispatch runner", async () => { + sessionStoreMocks.currentEntry = { + sessionId: "canonical-session-id", + sessionKey: "agent:main:telegram:direct:canonical", + updatedAt: 0, + }; + hookMocks.runner.runBeforeDispatch.mockImplementation(async (event, context) => { + (event as { sessionKey?: string }).sessionKey = "agent:plugin:forged"; + (context as { sessionKey?: string }).sessionKey = "agent:plugin:forged"; + return { handled: true }; + }); + + await dispatchReplyFromConfig({ + ctx: createHookCtx({ + SessionKey: "agent:main:telegram:direct:fallback", + OriginatingChannel: " Telegram ", + OriginatingTo: " telegram:999 ", + AccountId: " Work ", + MessageThreadId: 42, + }), + cfg: emptyConfig, + dispatcher: createDispatcher(), + }); + + const requester = firstMockCall( + hookMocks.runner.runBeforeDispatch, + "before dispatch hook", + )[2] as PluginSubagentRequesterContext | undefined; + expect(requester).toEqual({ + sessionKey: "agent:main:telegram:direct:fallback", + origin: { + channel: "telegram", + to: "telegram:999", + accountId: "work", + threadId: 42, + }, + }); + }); + it("passes inbound reply metadata to before_dispatch event and context", async () => { hookMocks.runner.runBeforeDispatch.mockResolvedValue({ handled: true }); const dispatcher = createDispatcher(); diff --git a/src/gateway/server-methods/agent-run-admission-phase.ts b/src/gateway/server-methods/agent-run-admission-phase.ts index 560f137abe0d..c5ea9b7436d3 100644 --- a/src/gateway/server-methods/agent-run-admission-phase.ts +++ b/src/gateway/server-methods/agent-run-admission-phase.ts @@ -18,7 +18,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { claimAgentRunContext } from "../../infra/agent-events.js"; import type { InputProvenance } from "../../sessions/input-provenance.js"; import type { SessionWorkAdmissionLease } from "../../sessions/session-lifecycle-admission.js"; -import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js"; import { registerChatAbortController, resolveAgentRunExpiresAtMs } from "../chat-abort.js"; import { loadSessionEntry, resolveSessionModelRef } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; @@ -293,12 +292,7 @@ export async function prepareAgentRunDispatch(params: { runId: params.runId, childSessionKey: params.resolvedSessionKey, task: params.request.message.trim(), - requesterOrigin: normalizeDeliveryContext({ - channel: params.delivery.resolvedChannel, - to: params.delivery.resolvedTo, - accountId: params.delivery.resolvedAccountId, - threadId: resolvedThreadId, - }), + requester: params.client?.internal?.pluginSubagentRequester, pluginId: normalizeOptionalString(params.client?.internal?.pluginRuntimeOwnerId), }); } catch (err) { diff --git a/src/gateway/server-methods/agent-task-tracking.ts b/src/gateway/server-methods/agent-task-tracking.ts index c5d91f167d9a..e6d0cd455ccb 100644 --- a/src/gateway/server-methods/agent-task-tracking.ts +++ b/src/gateway/server-methods/agent-task-tracking.ts @@ -8,6 +8,7 @@ import { isTimeoutError } from "../../agents/failover-error.js"; import { resolveAgentIdFromSessionKey, resolveAgentMainSessionKey } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { isAbortError } from "../../infra/abort-signal.js"; +import type { PluginSubagentRequesterContext } from "../../plugins/runtime/subagent-requester-context.js"; import { isAcpSessionKey } from "../../routing/session-key.js"; import type { InputProvenance } from "../../sessions/input-provenance.js"; import { @@ -16,7 +17,6 @@ import { } from "../../sessions/session-key-utils.js"; import { finalizeTaskRunByRunId } from "../../tasks/detached-task-runtime.js"; import type { TaskStatus } from "../../tasks/task-registry.types.js"; -import type { DeliveryContext } from "../../utils/delivery-context.shared.js"; import { formatForLog } from "../ws-log.js"; import type { GatewayRequestContext, GatewayRequestHandlerOptions } from "./types.js"; @@ -158,7 +158,7 @@ export async function registerPluginSubagentRunFromGateway(params: { runId: string; childSessionKey: string; task: string; - requesterOrigin?: DeliveryContext; + requester?: PluginSubagentRequesterContext; pluginId?: string; }): Promise { const childSessionKey = params.childSessionKey.trim(); @@ -169,18 +169,19 @@ export async function registerPluginSubagentRunFromGateway(params: { cfg: params.cfg, agentId: resolveAgentIdFromSessionKey(childSessionKey), }); + const requesterSessionKey = params.requester?.sessionKey ?? ownerSessionKey; const { registerSubagentRun } = await import("../../agents/subagent-registry.js"); registerSubagentRun({ runId: params.runId, childSessionKey, controllerSessionKey: ownerSessionKey, - requesterSessionKey: ownerSessionKey, - requesterOrigin: params.requesterOrigin, - requesterDisplayKey: "main", + requesterSessionKey, + requesterOrigin: params.requester?.origin, + requesterDisplayKey: params.requester ? requesterSessionKey : "main", task: params.task, cleanup: "keep", ...(params.pluginId ? { label: `plugin:${params.pluginId}` } : {}), - expectsCompletionMessage: false, + expectsCompletionMessage: params.requester !== undefined, spawnMode: "run", }); } diff --git a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts index ad811a34ba41..0edcecb81e61 100644 --- a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts +++ b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts @@ -20,6 +20,7 @@ import { } from "../../tasks/task-runtime.test-helpers.js"; import { withTempDir } from "../../test-helpers/temp-dir.js"; import { dispatchAgentRunFromGateway } from "./agent-run-dispatch.js"; +import { registerPluginSubagentRunFromGateway } from "./agent-task-tracking.js"; import { applyGatewaySubagentRegistryTestDeps, getAgentTestMocks, @@ -325,6 +326,50 @@ describe("gateway agent handler", () => { }); }); + it("registers host-owned requester lineage for plugin subagent completion", async () => { + await withTempDir({ prefix: "openclaw-gateway-plugin-subagent-requester-" }, async (root) => { + useTestStateDir(root); + resetSubagentRegistryForTests({ persist: false }); + const childSessionKey = "agent:work:subagent:plugin-completion"; + const requester = { + sessionKey: "agent:main:telegram:direct:123", + origin: { + channel: "telegram", + to: "telegram:123", + accountId: "work", + threadId: 42, + }, + } as const; + + await registerPluginSubagentRunFromGateway({ + cfg: { + session: { mainKey: "main", scope: "per-sender" }, + agents: { + list: [{ id: "main", default: true }, { id: "work" }], + }, + }, + runId: "plugin-subagent-current-requester", + childSessionKey, + task: "background plugin subagent task", + requester, + pluginId: "memory-core", + }); + + const run = requireValue( + getSubagentRunByChildSessionKey(childSessionKey), + "expected requester-bound plugin subagent run", + ); + expectRecordFields(run, { + controllerSessionKey: "agent:work:main", + requesterSessionKey: requester.sessionKey, + requesterDisplayKey: requester.sessionKey, + requesterOrigin: requester.origin, + label: "plugin:memory-core", + }); + expectRecordFields(run.completion, { required: true }); + }); + }); + it("keeps plugin SDK subagent runs best-effort when registry persistence fails", async () => { await withTempDir( { prefix: "openclaw-gateway-plugin-subagent-registry-fail-" }, diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 030679f3b2b6..819c285248da 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -18,6 +18,7 @@ import type { } from "../../infra/plugin-approvals.js"; import type { SystemAgentApprovalRequestPayload } from "../../infra/system-agent-approvals.js"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { PluginSubagentRequesterContext } from "../../plugins/runtime/subagent-requester-context.js"; import type { RuntimePluginToolGrant } from "../../plugins/runtime/tool-grant.js"; import type { SystemAgentOperation } from "../../system-agent/operation-types.js"; import type { WizardSession } from "../../wizard/session.js"; @@ -93,6 +94,8 @@ export type GatewayClient = { agentRuntimeIdentity?: AgentRuntimeIdentity; pluginRuntimeOwnerId?: string; agentRunTracking?: "plugin_subagent"; + /** Host-captured requester lineage for opt-in plugin subagent completion delivery. */ + pluginSubagentRequester?: PluginSubagentRequesterContext; /** Host-owned exact media set for a scoped automatic recovery delivery. */ internalDeliveryMediaUrls?: string[]; internalDeliverySuppressText?: boolean; diff --git a/src/gateway/server-plugin-runtime-client.ts b/src/gateway/server-plugin-runtime-client.ts index 75242b19b6b5..29aab2ec91e4 100644 --- a/src/gateway/server-plugin-runtime-client.ts +++ b/src/gateway/server-plugin-runtime-client.ts @@ -8,6 +8,7 @@ import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/version.js import { isKnownCoreToolId } from "../agents/tool-catalog.js"; import { normalizeToolName } from "../agents/tool-policy.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; +import type { PluginSubagentRequesterContext } from "../plugins/runtime/subagent-requester-context.js"; import type { RuntimePluginToolGrant } from "../plugins/runtime/tool-grant.js"; import { APPROVALS_SCOPE, WRITE_SCOPE } from "./method-scopes.js"; import type { TrustedSessionCreation } from "./server-methods/session-creation-provenance.js"; @@ -20,6 +21,7 @@ export function createSyntheticPluginRuntimeClient(params?: { internalDeliveryMediaUrls?: string[]; internalDeliverySuppressText?: boolean; pluginRuntimeOwnerId?: string; + pluginSubagentRequester?: PluginSubagentRequesterContext; runtimePluginToolGrant?: RuntimePluginToolGrant; delegatedToolPolicyHandoff?: boolean; sessionCreation?: TrustedSessionCreation; @@ -56,6 +58,9 @@ export function createSyntheticPluginRuntimeClient(params?: { : {}), ...(params?.scopes?.includes(APPROVALS_SCOPE) ? { approvalRuntime: true } : {}), ...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}), + ...(params?.pluginSubagentRequester + ? { pluginSubagentRequester: params.pluginSubagentRequester } + : {}), ...(params?.runtimePluginToolGrant ? { runtimePluginToolGrant: params.runtimePluginToolGrant } : {}), diff --git a/src/gateway/server-plugin-subagent-runtime.ts b/src/gateway/server-plugin-subagent-runtime.ts new file mode 100644 index 000000000000..d468e98f24f6 --- /dev/null +++ b/src/gateway/server-plugin-subagent-runtime.ts @@ -0,0 +1,51 @@ +import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs"; +import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js"; +import type { PluginRuntime } from "../plugins/runtime/types.js"; + +export function normalizePluginSubagentAllowedModelRef(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + if (trimmed === "*") { + return "*"; + } + const parsed = parseModelCatalogRef(trimmed); + if (!parsed) { + return null; + } + const normalized = normalizeModelRef(parsed.provider, parsed.modelId); + return `${normalized.provider}/${normalized.model}`; +} + +export function resolvePluginSubagentRequestedModelRef(params: { + provider?: string; + model?: string; +}): string | null { + if (params.provider && params.model) { + const normalizedRequest = normalizeModelRef(params.provider, params.model); + return `${normalizedRequest.provider}/${normalizedRequest.model}`; + } + const rawModel = params.model?.trim(); + if (!rawModel || !rawModel.includes("/")) { + return null; + } + const parsed = parseModelRef(rawModel, ""); + if (!parsed?.provider || !parsed.model) { + return null; + } + return `${parsed.provider}/${parsed.model}`; +} + +export function normalizePluginSubagentRunRuntime( + value: unknown, +): Awaited>["runtime"] { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const record = value as Record; + const harness = typeof record.harness === "string" ? record.harness.trim() : ""; + const provider = typeof record.provider === "string" ? record.provider.trim() : ""; + const model = typeof record.model === "string" ? record.model.trim() : ""; + return harness && provider && model ? { harness, provider, model } : undefined; +} diff --git a/src/gateway/server-plugins.subagent-ended-hook.test.ts b/src/gateway/server-plugins.subagent-ended-hook.test.ts index be9542f92449..9366059d9f75 100644 --- a/src/gateway/server-plugins.subagent-ended-hook.test.ts +++ b/src/gateway/server-plugins.subagent-ended-hook.test.ts @@ -19,6 +19,8 @@ vi.mock("./server-methods.js", () => ({ type ServerPluginsModule = typeof import("./server-plugins.js"); type GatewayRequestScopeModule = typeof import("../plugins/runtime/gateway-request-scope.js"); +type SubagentRequesterContextModule = + typeof import("../plugins/runtime/subagent-requester-context.js"); function createTestCfg(): OpenClawConfig { return { @@ -41,6 +43,10 @@ async function loadGatewayScope(): Promise { return await import("../plugins/runtime/gateway-request-scope.js"); } +async function loadSubagentRequesterContext(): Promise { + return await import("../plugins/runtime/subagent-requester-context.js"); +} + function lastGatewayRequest(): HandleGatewayRequestOptions { const call = handleGatewayRequest.mock.calls.at(-1)?.[0]; if (!call) { @@ -73,22 +79,126 @@ afterEach(async () => { describe("createGatewaySubagentRuntime.run subagent_ended tracking (#59164)", () => { test("marks plugin SDK subagent runs for Gateway-owned subagent tracking", async () => { const serverPlugins = await loadServerPlugins(); + const requesterContext = await loadSubagentRequesterContext(); const runtime = serverPlugins.createGatewaySubagentRuntime(); serverPlugins.setFallbackGatewayContext( createTestContext("plugin-sdk-subagent", createTestCfg()), ); - - const result = await runtime.run({ - sessionKey: "agent:main:subagent:plugin-helper", - message: "summarize this transcript", - deliver: false, + const requester = requesterContext.createPluginSubagentRequesterContext({ + sessionKey: "agent:main:telegram:direct:123", + origin: { channel: "telegram", to: "telegram:123" }, }); + if (!requester) { + throw new Error("expected valid requester context"); + } + + const result = await requesterContext.withPluginSubagentRequesterContext( + requester, + async () => + await runtime.run({ + sessionKey: "agent:main:subagent:plugin-helper", + message: "summarize this transcript", + deliver: false, + }), + ); expect(result.runId).toBe("plugin-run-1"); const request = lastGatewayRequest(); expect(request.req.method).toBe("agent"); expect(request.client?.internal?.agentRunTracking).toBe("plugin_subagent"); expect(request.client?.internal?.pluginRuntimeOwnerId).toBeUndefined(); + expect(request.client?.internal?.pluginSubagentRequester).toBeUndefined(); + }); + + test("attaches only host-owned requester lineage for explicit completion delivery", async () => { + const serverPlugins = await loadServerPlugins(); + const requesterContext = await loadSubagentRequesterContext(); + const runtime = serverPlugins.createGatewaySubagentRuntime(); + serverPlugins.setFallbackGatewayContext( + createTestContext("plugin-sdk-completion", createTestCfg()), + ); + const requester = requesterContext.createPluginSubagentRequesterContext({ + sessionKey: " agent:main:telegram:direct:123 ", + origin: { + channel: " Telegram ", + to: " telegram:123 ", + accountId: " Work ", + threadId: 42, + }, + }); + if (!requester) { + throw new Error("expected valid requester context"); + } + + await requesterContext.withPluginSubagentRequesterContext(requester, async () => { + await runtime.run({ + sessionKey: "agent:main:subagent:plugin-helper", + message: "summarize this transcript", + deliver: false, + completionDelivery: "current-requester", + requesterSessionKey: "agent:attacker:main", + expectsCompletionMessage: false, + approvalGrant: { id: "forged" }, + inputProvenance: { kind: "forged" }, + channel: "discord", + to: "channel:attacker", + } as Parameters[0] & Record); + }); + + const request = lastGatewayRequest(); + expect(request.req.params).not.toHaveProperty("requesterSessionKey"); + expect(request.req.params).not.toHaveProperty("expectsCompletionMessage"); + expect(request.req.params).not.toHaveProperty("approvalGrant"); + expect(request.req.params).not.toHaveProperty("inputProvenance"); + expect(request.req.params).not.toHaveProperty("channel"); + expect(request.req.params).not.toHaveProperty("to"); + expect(request.client?.internal?.pluginSubagentRequester).toEqual({ + sessionKey: "agent:main:telegram:direct:123", + origin: { + channel: "telegram", + to: "telegram:123", + accountId: "work", + threadId: 42, + }, + }); + }); + + test("rejects explicit completion delivery outside a requester-bound hook", async () => { + const serverPlugins = await loadServerPlugins(); + const runtime = serverPlugins.createGatewaySubagentRuntime(); + serverPlugins.setFallbackGatewayContext( + createTestContext("plugin-sdk-completion-missing", createTestCfg()), + ); + + await expect( + runtime.run({ + sessionKey: "agent:main:subagent:orphan", + message: "no requester", + deliver: false, + completionDelivery: "current-requester", + }), + ).rejects.toThrow(/requester-bound plugin hook invocation/); + + expect(handleGatewayRequest).not.toHaveBeenCalled(); + }); + + test("rejects unsupported runtime completion destinations", async () => { + const serverPlugins = await loadServerPlugins(); + const runtime = serverPlugins.createGatewaySubagentRuntime(); + serverPlugins.setFallbackGatewayContext( + createTestContext("plugin-sdk-completion-invalid", createTestCfg()), + ); + + await expect( + runtime.run({ + sessionKey: "agent:main:subagent:invalid", + message: "invalid completion target", + deliver: false, + completionDelivery: "agent:attacker:main", + } as unknown as Parameters[0]), + ).rejects.toThrow(/Unsupported plugin subagent completionDelivery/); + + expect(handleGatewayRequest).not.toHaveBeenCalled(); }); test("preserves plugin identity on the tracked Gateway agent request", async () => { diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 0e8bf4dd41f8..f53c8e19562e 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -2,9 +2,7 @@ // Loads plugin registries and builds fallback request context for non-WS paths. import { randomUUID } from "node:crypto"; import { performance } from "node:perf_hooks"; -import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js"; import type { AmbientEnvTriggerPolicy } from "../channels/config-presence.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -18,6 +16,10 @@ import type { PluginRegistryParams } from "../plugins/registry-types.js"; import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import { createPluginRuntimeLoaderLogger } from "../plugins/runtime/load-context.js"; +import { + resolvePluginSubagentCompletionRequester, + type PluginSubagentRequesterContext, +} from "../plugins/runtime/subagent-requester-context.js"; import type { RuntimePluginToolGrant } from "../plugins/runtime/tool-grant.js"; import type { PluginRuntime, RuntimeGatewayRequestOptions } from "../plugins/runtime/types.js"; import type { PluginLogger, PluginOrigin } from "../plugins/types.js"; @@ -41,6 +43,11 @@ import { mergePluginRuntimeClientInternal, resolvePluginSubagentToolsAlsoAllow, } from "./server-plugin-runtime-client.js"; +import { + normalizePluginSubagentAllowedModelRef, + normalizePluginSubagentRunRuntime, + resolvePluginSubagentRequestedModelRef, +} from "./server-plugin-subagent-runtime.js"; import { projectGatewayRuntimeNodes } from "./server-plugins-node-runtime.js"; export { @@ -71,22 +78,6 @@ const getPluginSubagentPolicyState = () => policies: {}, })); -function normalizeAllowedModelRef(raw: string): string | null { - const trimmed = raw.trim(); - if (!trimmed) { - return null; - } - if (trimmed === "*") { - return "*"; - } - const parsed = parseModelCatalogRef(trimmed); - if (!parsed) { - return null; - } - const normalized = normalizeModelRef(parsed.provider, parsed.modelId); - return `${normalized.provider}/${normalized.model}`; -} - export function setPluginSubagentOverridePolicies(cfg: OpenClawConfig): void { const pluginSubagentPolicyState = getPluginSubagentPolicyState(); const normalized = normalizePluginsConfig(cfg.plugins); @@ -98,7 +89,7 @@ export function setPluginSubagentOverridePolicies(cfg: OpenClawConfig): void { const allowedModels = new Set(); let allowAnyModel = false; for (const modelRef of configuredAllowedModels) { - const normalizedModelRef = normalizeAllowedModelRef(modelRef); + const normalizedModelRef = normalizePluginSubagentAllowedModelRef(modelRef); if (!normalizedModelRef) { continue; } @@ -161,7 +152,7 @@ function authorizeFallbackModelOverride(params: { if (policy.allowedModels.size === 0) { return { allowed: true }; } - const requestedModelRef = resolveRequestedFallbackModelRef(params); + const requestedModelRef = resolvePluginSubagentRequestedModelRef(params); if (!requestedModelRef) { return { allowed: false, @@ -178,25 +169,6 @@ function authorizeFallbackModelOverride(params: { }; } -function resolveRequestedFallbackModelRef(params: { - provider?: string; - model?: string; -}): string | null { - if (params.provider && params.model) { - const normalizedRequest = normalizeModelRef(params.provider, params.model); - return `${normalizedRequest.provider}/${normalizedRequest.model}`; - } - const rawModel = params.model?.trim(); - if (!rawModel || !rawModel.includes("/")) { - return null; - } - const parsed = parseModelRef(rawModel, ""); - if (!parsed?.provider || !parsed.model) { - return null; - } - return `${parsed.provider}/${parsed.model}`; -} - // ── Internal gateway dispatch for plugin runtime ──────────────────── function hasAdminScope(client: GatewayRequestOptions["client"] | undefined): boolean { @@ -247,6 +219,7 @@ type DispatchGatewayMethodInProcessOptions = { internalDeliverySuppressText?: boolean; onAccepted?: (payload: unknown) => void; pluginRuntimeOwnerId?: string; + pluginSubagentRequester?: PluginSubagentRequesterContext; runtimePluginToolGrant?: RuntimePluginToolGrant; delegatedToolPolicyHandoff?: boolean; sessionCreation?: TrustedSessionCreation; @@ -288,6 +261,9 @@ export async function dispatchGatewayMethodInProcessRaw( internalDeliveryMediaUrls: options?.internalDeliveryMediaUrls, internalDeliverySuppressText: options?.internalDeliverySuppressText, ...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}), + ...(options?.pluginSubagentRequester + ? { pluginSubagentRequester: options.pluginSubagentRequester } + : {}), ...(options?.runtimePluginToolGrant ? { runtimePluginToolGrant: options.runtimePluginToolGrant } : {}), @@ -299,12 +275,16 @@ export async function dispatchGatewayMethodInProcessRaw( scope?.client, pluginRuntimeOwnerId || options?.agentRunTracking || + options?.pluginSubagentRequester || options?.runtimePluginToolGrant || options?.delegatedToolPolicyHandoff || scope?.client?.internal?.delegatedToolPolicyHandoff ? { ...(options?.agentRunTracking ? { agentRunTracking: options.agentRunTracking } : {}), ...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}), + ...(options?.pluginSubagentRequester + ? { pluginSubagentRequester: options.pluginSubagentRequester } + : {}), runtimePluginToolGrant: options?.runtimePluginToolGrant, delegatedToolPolicyHandoff: options?.delegatedToolPolicyHandoff === true ? (true as const) : undefined, @@ -364,19 +344,6 @@ export async function dispatchTrustedPluginGatewayMethod( const PLUGIN_SUBAGENT_SESSION_MESSAGES_MAX_LIMIT = 1_000; -function normalizeSubagentRunRuntime( - value: unknown, -): Awaited>["runtime"] { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - const record = value as Record; - const harness = typeof record.harness === "string" ? record.harness.trim() : ""; - const provider = typeof record.provider === "string" ? record.provider.trim() : ""; - const model = typeof record.model === "string" ? record.model.trim() : ""; - return harness && provider && model ? { harness, provider, model } : undefined; -} - export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { const getSessionMessages: PluginRuntime["subagent"]["getSessionMessages"] = async (params) => { const limit = @@ -395,6 +362,9 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { return { async run(params) { + const pluginSubagentRequester = resolvePluginSubagentCompletionRequester( + params.completionDelivery, + ); const scope = getPluginRuntimeGatewayRequestScope(); const pluginId = typeof scope?.pluginId === "string" && scope.pluginId.trim() @@ -445,6 +415,7 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { allowSyntheticModelOverride, agentRunTracking: "plugin_subagent", ...(pluginId ? { pluginRuntimeOwnerId: pluginId } : {}), + ...(pluginSubagentRequester ? { pluginSubagentRequester } : {}), ...(runtimePluginToolGrant ? { runtimePluginToolGrant } : {}), }, ); @@ -452,7 +423,7 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { if (typeof runId !== "string" || !runId) { throw new Error("Gateway agent method returned an invalid runId."); } - const runtime = normalizeSubagentRunRuntime(payload?.runtime); + const runtime = normalizePluginSubagentRunRuntime(payload?.runtime); return { runId, ...(runtime ? { runtime } : {}) }; }, async waitForRun(params) { diff --git a/src/plugins/hooks.before-dispatch.test.ts b/src/plugins/hooks.before-dispatch.test.ts new file mode 100644 index 000000000000..9951d9fbc11f --- /dev/null +++ b/src/plugins/hooks.before-dispatch.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { createHookRunner } from "./hooks.js"; +import { createMockPluginRegistry } from "./hooks.test-fixtures.js"; +import { + createPluginSubagentRequesterContext, + resolvePluginSubagentCompletionRequester, + type PluginSubagentRequesterContext, +} from "./runtime/subagent-requester-context.js"; + +function deferred() { + let resolve: () => void = () => {}; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function getActiveRequester(): PluginSubagentRequesterContext | undefined { + try { + return resolvePluginSubagentCompletionRequester("current-requester"); + } catch { + return undefined; + } +} + +describe("before_dispatch requester authority", () => { + it("expires each plugin scope before the next handler settles", async () => { + const requester = createPluginSubagentRequesterContext({ + sessionKey: "agent:main:telegram:direct:123", + origin: { channel: "telegram", to: "telegram:123" }, + }); + if (!requester) { + throw new Error("expected valid requester context"); + } + + const detachedGate = deferred(); + const secondStarted = deferred(); + const releaseSecond = deferred(); + let detachedRead: Promise | undefined; + const registry = createMockPluginRegistry([ + { + hookName: "before_dispatch", + pluginId: "first", + priority: 100, + handler: async () => { + expect(getActiveRequester()).toBe(requester); + detachedRead = (async () => { + await detachedGate.promise; + return getActiveRequester(); + })(); + return { handled: false }; + }, + }, + { + hookName: "before_dispatch", + pluginId: "second", + priority: 0, + handler: async () => { + expect(getActiveRequester()).toBe(requester); + secondStarted.resolve(); + await releaseSecond.promise; + return { handled: false }; + }, + }, + ]); + + const run = createHookRunner(registry).runBeforeDispatch({ content: "hello" }, {}, requester); + await secondStarted.promise; + detachedGate.resolve(); + if (!detachedRead) { + throw new Error("expected detached requester read"); + } + await expect(detachedRead).resolves.toBeUndefined(); + + releaseSecond.resolve(); + await expect(run).resolves.toBeUndefined(); + expect(getActiveRequester()).toBeUndefined(); + }); +}); diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index e1bfed7c0f33..645a56c3c3ad 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -109,6 +109,10 @@ import type { PluginHookSkillProposalEvaluateResult, PluginHookSkillProposalEvaluationOutcome, } from "./hook-types.js"; +import { + type PluginSubagentRequesterContext, + withPluginSubagentRequesterContext, +} from "./runtime/subagent-requester-context.js"; import { createPluginToolMatcherScope, pluginToolMatcherCoversTool, @@ -775,6 +779,7 @@ export function createHookRunner( hookName: K, event: Parameters["handler"]>>[0], ctx: Parameters["handler"]>>[1], + runHandler?: (run: () => Promise) => Promise, ): Promise { const hooks = getHooksForName(registry, hookName, ctx); if (hooks.length === 0) { @@ -783,7 +788,7 @@ export function createHookRunner( logger?.debug?.(`[hooks] running ${hookName} (${hooks.length} handlers, first-claim wins)`); - return await runClaimingHooksList(hooks, hookName, event, ctx); + return await runClaimingHooksList(hooks, hookName, event, ctx, runHandler); } async function runClaimingHookForPlugin< @@ -815,14 +820,18 @@ export function createHookRunner( hookName: K, event: Parameters["handler"]>>[0], ctx: Parameters["handler"]>>[1], + runHandler?: (run: () => Promise) => Promise, ): Promise { for (const hook of hooks) { try { - const promise = Promise.resolve( - (hook.handler as (event: unknown, ctx: unknown) => Promise)(event, ctx), - ); - const timeoutMs = getClaimingHookTimeoutMs(hook); - const handlerResult = timeoutMs ? await withHookTimeout(promise, timeoutMs) : await promise; + const invokeHandler = async (): Promise => { + const promise = Promise.resolve( + (hook.handler as (event: unknown, ctx: unknown) => Promise)(event, ctx), + ); + const timeoutMs = getClaimingHookTimeoutMs(hook); + return timeoutMs ? await withHookTimeout(promise, timeoutMs) : await promise; + }; + const handlerResult = runHandler ? await runHandler(invokeHandler) : await invokeHandler(); if (handlerResult?.handled) { return handlerResult; } @@ -1152,11 +1161,17 @@ export function createHookRunner( async function runBeforeDispatch( event: PluginHookBeforeDispatchEvent, ctx: PluginHookBeforeDispatchContext, + requester?: PluginSubagentRequesterContext, ): Promise { + const runHandler = requester + ? (run: () => Promise) => + withPluginSubagentRequesterContext(requester, run) + : undefined; return runClaimingHook<"before_dispatch", PluginHookBeforeDispatchResult>( "before_dispatch", event, ctx, + runHandler, ); } diff --git a/src/plugins/runtime/subagent-requester-context.test.ts b/src/plugins/runtime/subagent-requester-context.test.ts new file mode 100644 index 000000000000..9dcf696223c6 --- /dev/null +++ b/src/plugins/runtime/subagent-requester-context.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + createPluginSubagentRequesterContext, + resolvePluginSubagentCompletionRequester, + type PluginSubagentRequesterContext, + withPluginSubagentRequesterContext, +} from "./subagent-requester-context.js"; + +function getActiveRequester(): PluginSubagentRequesterContext | undefined { + try { + return resolvePluginSubagentCompletionRequester("current-requester"); + } catch { + return undefined; + } +} + +describe("plugin subagent requester context", () => { + it("normalizes and freezes host-owned requester lineage", () => { + const requester = createPluginSubagentRequesterContext({ + sessionKey: " agent:main:telegram:direct:123 ", + origin: { + channel: " Telegram ", + to: " telegram:123 ", + accountId: " Work ", + threadId: 42, + }, + }); + + expect(requester).toEqual({ + sessionKey: "agent:main:telegram:direct:123", + origin: { + channel: "telegram", + to: "telegram:123", + accountId: "work", + threadId: 42, + }, + }); + expect(Object.isFrozen(requester)).toBe(true); + expect(Object.isFrozen(requester?.origin)).toBe(true); + }); + + it("rejects missing or invalid requester lineage", () => { + expect( + createPluginSubagentRequesterContext({ + origin: { channel: "telegram", to: "telegram:123" }, + }), + ).toBeUndefined(); + expect( + createPluginSubagentRequesterContext({ + sessionKey: "agent:main:telegram:direct:123", + origin: { channel: "telegram" }, + }), + ).toBeUndefined(); + }); + + it("expires requester authority when the hook invocation returns", async () => { + const requester = createPluginSubagentRequesterContext({ + sessionKey: "agent:main:telegram:direct:123", + origin: { channel: "telegram", to: "telegram:123" }, + }); + if (!requester) { + throw new Error("expected valid requester context"); + } + + let releaseDetachedRead: (() => void) | undefined; + const detachedGate = new Promise((resolve) => { + releaseDetachedRead = resolve; + }); + let detachedRead: Promise | undefined; + await withPluginSubagentRequesterContext(requester, async () => { + expect(getActiveRequester()).toBe(requester); + detachedRead = (async () => { + await detachedGate; + return getActiveRequester(); + })(); + }); + + releaseDetachedRead?.(); + await expect(detachedRead).resolves.toBeUndefined(); + expect(getActiveRequester()).toBeUndefined(); + }); +}); diff --git a/src/plugins/runtime/subagent-requester-context.ts b/src/plugins/runtime/subagent-requester-context.ts new file mode 100644 index 000000000000..3f93c7f945d9 --- /dev/null +++ b/src/plugins/runtime/subagent-requester-context.ts @@ -0,0 +1,78 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; +import { + normalizeDeliveryContext, + type DeliveryContext, +} from "../../utils/delivery-context.shared.js"; + +export type PluginSubagentRequesterContext = Readonly<{ + sessionKey: string; + origin: Readonly; +}>; + +type PluginSubagentRequesterScope = { + active: boolean; + requester: PluginSubagentRequesterContext; +}; + +const PLUGIN_SUBAGENT_REQUESTER_SCOPE_KEY: unique symbol = Symbol.for( + "openclaw.pluginSubagentRequesterScope", +); + +const pluginSubagentRequesterScope = resolveGlobalSingleton< + AsyncLocalStorage +>(PLUGIN_SUBAGENT_REQUESTER_SCOPE_KEY, () => new AsyncLocalStorage()); + +export function createPluginSubagentRequesterContext(params: { + sessionKey?: string; + origin?: DeliveryContext; +}): PluginSubagentRequesterContext | undefined { + const sessionKey = normalizeOptionalString(params.sessionKey); + const origin = normalizeDeliveryContext(params.origin); + if (!sessionKey || !origin?.channel || !origin.to) { + return undefined; + } + return Object.freeze({ + sessionKey, + origin: Object.freeze({ ...origin }), + }); +} + +export async function withPluginSubagentRequesterContext( + requester: PluginSubagentRequesterContext, + run: () => Promise, +): Promise { + const scope: PluginSubagentRequesterScope = { active: true, requester }; + return await pluginSubagentRequesterScope.run(scope, async () => { + try { + return await run(); + } finally { + // Detached hook work must not retain requester authority after the invocation ends. + scope.active = false; + } + }); +} + +function getPluginSubagentRequesterContext(): PluginSubagentRequesterContext | undefined { + const scope = pluginSubagentRequesterScope.getStore(); + return scope?.active === true ? scope.requester : undefined; +} + +export function resolvePluginSubagentCompletionRequester( + completionDelivery: unknown, +): PluginSubagentRequesterContext | undefined { + if (completionDelivery !== undefined && completionDelivery !== "current-requester") { + throw new Error("Unsupported plugin subagent completionDelivery value."); + } + if (completionDelivery === undefined) { + return undefined; + } + const requester = getPluginSubagentRequesterContext(); + if (!requester) { + throw new Error( + 'completionDelivery "current-requester" requires an active requester-bound plugin hook invocation.', + ); + } + return requester; +} diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 7c6502caa104..2236170bc542 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -23,6 +23,8 @@ type SubagentRunParams = { lane?: string; lightContext?: boolean; deliver?: boolean; + /** Deliver the completion to the authenticated requester of the current hook invocation. */ + completionDelivery?: "current-requester"; idempotencyKey?: string; cwd?: string; };