mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(plugins): deliver subagent completion to current requester (#116091)
* fix(plugins): deliver subagent completion to requester Co-authored-by: ambitioncn <36698505+ambitioncn@users.noreply.github.com> * test(qa): register current-requester plugin fixture * fix(plugins): scope requester authority per hook --------- Co-authored-by: ambitioncn <36698505+ambitioncn@users.noreply.github.com>
This commit is contained in:
@@ -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 === "."
|
||||
|
||||
@@ -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.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -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<void>> = [];
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "qa-current-requester-subagent",
|
||||
"activation": {
|
||||
"onStartup": true
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<void> {
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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-" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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<ReturnType<PluginRuntime["subagent"]["run"]>>["runtime"] {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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<GatewayRequestScopeModule> {
|
||||
return await import("../plugins/runtime/gateway-request-scope.js");
|
||||
}
|
||||
|
||||
async function loadSubagentRequesterContext(): Promise<SubagentRequesterContextModule> {
|
||||
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<typeof runtime.run>[0] & Record<string, unknown>);
|
||||
});
|
||||
|
||||
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<typeof runtime.run>[0]),
|
||||
).rejects.toThrow(/Unsupported plugin subagent completionDelivery/);
|
||||
|
||||
expect(handleGatewayRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("preserves plugin identity on the tracked Gateway agent request", async () => {
|
||||
|
||||
@@ -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<string>();
|
||||
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<T>(
|
||||
|
||||
const PLUGIN_SUBAGENT_SESSION_MESSAGES_MAX_LIMIT = 1_000;
|
||||
|
||||
function normalizeSubagentRunRuntime(
|
||||
value: unknown,
|
||||
): Awaited<ReturnType<PluginRuntime["subagent"]["run"]>>["runtime"] {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
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) {
|
||||
|
||||
@@ -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<void>((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<PluginSubagentRequesterContext | undefined> | 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();
|
||||
});
|
||||
});
|
||||
+21
-6
@@ -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<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
|
||||
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
|
||||
runHandler?: (run: () => Promise<TResult | void>) => Promise<TResult | void>,
|
||||
): Promise<TResult | undefined> {
|
||||
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<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
|
||||
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
|
||||
runHandler?: (run: () => Promise<TResult | void>) => Promise<TResult | void>,
|
||||
): Promise<TResult | undefined> {
|
||||
for (const hook of hooks) {
|
||||
try {
|
||||
const promise = Promise.resolve(
|
||||
(hook.handler as (event: unknown, ctx: unknown) => Promise<TResult | void>)(event, ctx),
|
||||
);
|
||||
const timeoutMs = getClaimingHookTimeoutMs(hook);
|
||||
const handlerResult = timeoutMs ? await withHookTimeout(promise, timeoutMs) : await promise;
|
||||
const invokeHandler = async (): Promise<TResult | void> => {
|
||||
const promise = Promise.resolve(
|
||||
(hook.handler as (event: unknown, ctx: unknown) => Promise<TResult | void>)(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<PluginHookBeforeDispatchResult | undefined> {
|
||||
const runHandler = requester
|
||||
? (run: () => Promise<PluginHookBeforeDispatchResult | void>) =>
|
||||
withPluginSubagentRequesterContext(requester, run)
|
||||
: undefined;
|
||||
return runClaimingHook<"before_dispatch", PluginHookBeforeDispatchResult>(
|
||||
"before_dispatch",
|
||||
event,
|
||||
ctx,
|
||||
runHandler,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
releaseDetachedRead = resolve;
|
||||
});
|
||||
let detachedRead: Promise<PluginSubagentRequesterContext | undefined> | undefined;
|
||||
await withPluginSubagentRequesterContext(requester, async () => {
|
||||
expect(getActiveRequester()).toBe(requester);
|
||||
detachedRead = (async () => {
|
||||
await detachedGate;
|
||||
return getActiveRequester();
|
||||
})();
|
||||
});
|
||||
|
||||
releaseDetachedRead?.();
|
||||
await expect(detachedRead).resolves.toBeUndefined();
|
||||
expect(getActiveRequester()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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<DeliveryContext>;
|
||||
}>;
|
||||
|
||||
type PluginSubagentRequesterScope = {
|
||||
active: boolean;
|
||||
requester: PluginSubagentRequesterContext;
|
||||
};
|
||||
|
||||
const PLUGIN_SUBAGENT_REQUESTER_SCOPE_KEY: unique symbol = Symbol.for(
|
||||
"openclaw.pluginSubagentRequesterScope",
|
||||
);
|
||||
|
||||
const pluginSubagentRequesterScope = resolveGlobalSingleton<
|
||||
AsyncLocalStorage<PluginSubagentRequesterScope>
|
||||
>(PLUGIN_SUBAGENT_REQUESTER_SCOPE_KEY, () => new AsyncLocalStorage<PluginSubagentRequesterScope>());
|
||||
|
||||
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<T>(
|
||||
requester: PluginSubagentRequesterContext,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user