From 72f7d7e4ea7a4d761e1af4860e7ec223c07c6f6c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 10:36:06 +0100 Subject: [PATCH] fix(gateway): scope plugin subagent cleanup ownership --- CHANGELOG.md | 1 + docs/plugins/architecture-internals.md | 1 + docs/plugins/sdk-runtime.md | 2 + src/config/sessions/types.ts | 2 + src/gateway/server-methods/agent.test.ts | 88 +++++++++++++++++++ src/gateway/server-methods/agent.ts | 5 ++ src/gateway/server-methods/sessions.ts | 27 ++++++ src/gateway/server-methods/shared-types.ts | 1 + src/gateway/server-plugins.test.ts | 86 ++++++++++++++++++ src/gateway/server-plugins.ts | 78 +++++++++++++--- ...sessions.gateway-server-sessions-a.test.ts | 54 ++++++++++++ 11 files changed, 335 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa68ff74183..329a1e659fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes - CLI/plugins: preserve unversioned ClawHub install specs so `plugins update` can follow newer ClawHub releases instead of pinning to the initially resolved version. Fixes #63010; supersedes #58426. Thanks @kangsen1234 and @robinspt. +- Memory-core/subagents: tag plugin-created subagent sessions with their plugin owner so dreaming narrative cleanup can delete its own ephemeral sessions without granting broad admin session deletion. Fixes #72712. Thanks @BSG2000. - Gateway/models: move local-provider pricing opt-outs, OpenRouter/LiteLLM aliases, and proxy passthrough pricing lookup into plugin manifest metadata so core no longer carries extension-specific pricing tables. Thanks @codex. - CLI/update: honor `OPENCLAW_NO_AUTO_UPDATE=1` as a gateway startup kill-switch for configured background package auto-updates, so operators can hold a deliberate downgrade during incident recovery without editing config first. Fixes #72715. Thanks @Xivi08. - Agents/Claude CLI: force live-session launches to include `--output-format stream-json` whenever OpenClaw adds `--input-format stream-json`, so new Claude CLI sessions no longer fail immediately while reusable sessions keep working. Fixes #72206. Thanks @kwangwonkoh and @Xivi08. diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index 75e831ad0fa..355d7cb5271 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -491,6 +491,7 @@ Notes: - For plugin-owned fallback runs, operators must opt in with `plugins.entries..subagent.allowModelOverride: true`. - Use `plugins.entries..subagent.allowedModels` to restrict trusted plugins to specific canonical `provider/model` targets, or `"*"` to allow any target explicitly. - Untrusted plugin subagent runs still work, but override requests are rejected instead of silently falling back. +- Plugin-created subagent sessions are tagged with the creating plugin id. Fallback `api.runtime.subagent.deleteSession(...)` may delete those owned sessions only; arbitrary session deletion still requires an admin-scoped Gateway request. For web search, plugins can consume the shared runtime helper instead of reaching into the agent tool wiring: diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 95e99a69132..be423cf8e87 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -117,6 +117,8 @@ register(api) { Model overrides (`provider`/`model`) require operator opt-in via `plugins.entries..subagent.allowModelOverride: true` in config. Untrusted plugins can still run subagents, but override requests 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. + List connected nodes and invoke a node-host command from Gateway-loaded plugin code or from plugin CLI commands. Use this when a plugin owns local work on a paired device, for example a browser or audio bridge on another Mac. diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 60f13c63dac..e4e2e2e38de 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -145,6 +145,8 @@ export type SessionEntry = { subagentRole?: "orchestrator" | "leaf"; /** Explicit control scope assigned at spawn time for subagent control decisions. */ subagentControlScope?: "children" | "none"; + /** Plugin id that created this session through api.runtime.subagent. */ + pluginOwnerId?: string; systemSent?: boolean; abortedLastRun?: boolean; /** Timestamp (ms) when the current sessionId first became active. */ diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index e2911d91f45..47f71416ee8 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -405,6 +405,94 @@ describe("gateway agent handler", () => { expect(capturedEntry?.acp).toEqual(existingAcpMeta); }); + it("tags newly-created plugin runtime sessions with the plugin owner", async () => { + const sessionKey = "agent:main:dreaming-narrative-light-workspace-1"; + mocks.loadSessionEntry.mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + entry: undefined, + canonicalKey: sessionKey, + }); + + let capturedEntry: Record | undefined; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = {}; + const result = await updater(store); + capturedEntry = store[sessionKey] as Record; + return result; + }); + + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + await invokeAgent( + { + message: "write a narrative", + sessionKey, + idempotencyKey: "plugin-runtime-owner", + }, + { + client: { + internal: { + pluginRuntimeOwnerId: "memory-core", + }, + } as never, + }, + ); + + expect(mocks.updateSessionStore).toHaveBeenCalled(); + expect(capturedEntry?.pluginOwnerId).toBe("memory-core"); + }); + + it("does not claim stale pre-existing sessions for plugin runtime cleanup", async () => { + const sessionKey = "agent:main:existing-user-session"; + const existingEntry = { + sessionId: "stale-session", + updatedAt: 1, + pluginOwnerId: "other-plugin", + }; + mocks.loadSessionEntry.mockReturnValue({ + cfg: {}, + storePath: "/tmp/sessions.json", + entry: existingEntry, + canonicalKey: sessionKey, + }); + + let capturedEntry: Record | undefined; + mocks.updateSessionStore.mockImplementation(async (_path, updater) => { + const store: Record = { + [sessionKey]: { ...existingEntry }, + }; + const result = await updater(store); + capturedEntry = store[sessionKey] as Record; + return result; + }); + + mocks.agentCommand.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { durationMs: 100 }, + }); + + await invokeAgent( + { + message: "write a narrative", + sessionKey, + idempotencyKey: "plugin-runtime-existing-owner", + }, + { + client: { + internal: { + pluginRuntimeOwnerId: "memory-core", + }, + } as never, + }, + ); + + expect(capturedEntry?.pluginOwnerId).toBe("other-plugin"); + }); + it("forwards provider and model overrides for admin-scoped callers", async () => { primeMainAgentRun(); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index f6e288445df..e5047cf2283 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -806,6 +806,10 @@ export const agentHandlers: GatewayRequestHandlers = { request.bootstrapContextRunKind !== "heartbeat" && !request.internalEvents?.length; const labelValue = normalizeOptionalString(request.label) || entry?.label; + const pluginOwnerId = + entry === undefined + ? normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId) + : normalizeOptionalString(entry.pluginOwnerId); const sessionAgent = resolveAgentIdFromSessionKey(canonicalKey); spawnedByValue = canonicalizeSpawnedByForAgent(cfg, sessionAgent, entry?.spawnedBy); let inheritedGroup: @@ -882,6 +886,7 @@ export const agentHandlers: GatewayRequestHandlers = { groupId: resolvedGroupId ?? entry?.groupId, groupChannel: resolvedGroupChannel ?? entry?.groupChannel, space: resolvedGroupSpace ?? entry?.space, + ...(pluginOwnerId ? { pluginOwnerId } : {}), cliSessionIds: entry?.cliSessionIds, cliSessionBindings: entry?.cliSessionBindings, claudeCliSessionId: entry?.claudeCliSessionId, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 6f3ee233696..fd9d2c33945 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -120,6 +120,30 @@ function requireSessionKey(key: unknown, respond: RespondFn): string | null { return normalized; } +function rejectPluginRuntimeDeleteMismatch(params: { + client: GatewayClient | null; + key: string; + entry: SessionEntry | undefined; + respond: RespondFn; +}): boolean { + const pluginOwnerId = normalizeOptionalString(params.client?.internal?.pluginRuntimeOwnerId); + if (!pluginOwnerId || !params.entry) { + return false; + } + if (normalizeOptionalString(params.entry.pluginOwnerId) === pluginOwnerId) { + return false; + } + params.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Plugin "${pluginOwnerId}" cannot delete session "${params.key}" because it did not create it.`, + ), + ); + return true; +} + function resolveGatewaySessionTargetFromKey(key: string) { const cfg = loadConfig(); const target = resolveGatewaySessionStoreTarget({ cfg, key }); @@ -1406,6 +1430,9 @@ export const sessionsHandlers: GatewayRequestHandlers = { } = await loadSessionsRuntimeModule(); const { entry, legacyKey, canonicalKey } = loadSessionEntry(key); + if (rejectPluginRuntimeDeleteMismatch({ client, key: canonicalKey ?? key, entry, respond })) { + return; + } const mutationCleanupError = await cleanupSessionBeforeMutation({ cfg, key, diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 65bc863582d..32b9b05876b 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -26,6 +26,7 @@ export type GatewayClient = { isDeviceTokenAuth?: boolean; internal?: { allowModelOverride?: boolean; + pluginRuntimeOwnerId?: string; }; }; diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 0807496b8b2..3467386a097 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -206,6 +206,11 @@ function getLastDispatchedClientScopes(): string[] { return Array.isArray(scopes) ? scopes : []; } +function getLastDispatchedClientInternal(): Record { + const call = handleGatewayRequest.mock.calls.at(-1)?.[0]; + return (call?.client?.internal ?? {}) as Record; +} + function getLastPluginLoadLogger(): { info: (message: string) => void; warn: (message: string) => void; @@ -789,6 +794,24 @@ describe("loadGatewayPlugins", () => { }); }); + test("tags plugin fallback subagent runs with the creating plugin id", async () => { + const serverPlugins = serverPluginsModule; + const runtime = await createSubagentRuntime(serverPlugins); + serverPlugins.setFallbackGatewayContext(createTestContext("fallback-plugin-owner")); + + await gatewayRequestScopeModule.withPluginRuntimePluginIdScope("memory-core", () => + runtime.run({ + sessionKey: "dreaming-narrative-light-workspace-1", + message: "write a narrative", + deliver: false, + }), + ); + + expect(getLastDispatchedClientInternal()).toMatchObject({ + pluginRuntimeOwnerId: "memory-core", + }); + }); + test("includes docs guidance when a plugin fallback override is not trusted", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); @@ -946,6 +969,39 @@ describe("loadGatewayPlugins", () => { expect(getLastDispatchedClientScopes()).not.toContain("operator.admin"); }); + test("uses owner-scoped synthetic admin for plugin-created session cleanup", async () => { + const serverPlugins = serverPluginsModule; + const runtime = await createSubagentRuntime(serverPlugins); + serverPlugins.setFallbackGatewayContext(createTestContext("fallback-plugin-delete-session")); + + handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => { + const scopes = Array.isArray(opts.client?.connect?.scopes) ? opts.client.connect.scopes : []; + const auth = methodScopesModule.authorizeOperatorScopesForMethod("sessions.delete", scopes); + if (!auth.allowed) { + opts.respond(false, undefined, { + code: "INVALID_REQUEST", + message: `missing scope: ${auth.missingScope}`, + }); + return; + } + opts.respond(true, {}); + }); + + await expect( + gatewayRequestScopeModule.withPluginRuntimePluginIdScope("memory-core", () => + runtime.deleteSession({ + sessionKey: "dreaming-narrative-light-workspace-1", + deleteTranscript: true, + }), + ), + ).resolves.toBeUndefined(); + + expect(getLastDispatchedClientScopes()).toEqual(["operator.admin"]); + expect(getLastDispatchedClientInternal()).toMatchObject({ + pluginRuntimeOwnerId: "memory-core", + }); + }); + test("allows session deletion when the request scope already has admin", async () => { const serverPlugins = serverPluginsModule; const runtime = await createSubagentRuntime(serverPlugins); @@ -971,6 +1027,36 @@ describe("loadGatewayPlugins", () => { expect(getLastDispatchedClientScopes()).toEqual(["operator.admin"]); }); + test("keeps plugin owner metadata on admin-scoped plugin session cleanup", async () => { + const serverPlugins = serverPluginsModule; + const runtime = await createSubagentRuntime(serverPlugins); + const scope = { + context: createTestContext("request-scope-plugin-delete-session"), + client: { + connect: { + scopes: ["operator.admin"], + }, + } as GatewayRequestOptions["client"], + isWebchatConnect: () => false, + } satisfies PluginRuntimeGatewayRequestScope; + + await expect( + gatewayRequestScopeModule.withPluginRuntimeGatewayRequestScope(scope, () => + gatewayRequestScopeModule.withPluginRuntimePluginIdScope("memory-core", () => + runtime.deleteSession({ + sessionKey: "dreaming-narrative-light-workspace-1", + deleteTranscript: true, + }), + ), + ), + ).resolves.toBeUndefined(); + + expect(getLastDispatchedClientScopes()).toEqual(["operator.admin"]); + expect(getLastDispatchedClientInternal()).toMatchObject({ + pluginRuntimeOwnerId: "memory-core", + }); + }); + test("can prefer setup-runtime channel plugins during startup loads", async () => { loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayPluginsForTest({ diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 2494525b145..6bc76a0ede1 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -218,8 +218,13 @@ function resolveRequestedFallbackModelRef(params: { function createSyntheticOperatorClient(params?: { allowModelOverride?: boolean; + pluginRuntimeOwnerId?: string; scopes?: string[]; }): GatewayRequestOptions["client"] { + const pluginRuntimeOwnerId = + typeof params?.pluginRuntimeOwnerId === "string" && params.pluginRuntimeOwnerId.trim() + ? params.pluginRuntimeOwnerId.trim() + : undefined; return { connect: { minProtocol: PROTOCOL_VERSION, @@ -235,11 +240,12 @@ function createSyntheticOperatorClient(params?: { }, internal: { allowModelOverride: params?.allowModelOverride === true, + ...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}), }, }; } -function hasAdminScope(client: GatewayRequestOptions["client"]): boolean { +function hasAdminScope(client: GatewayRequestOptions["client"] | undefined): boolean { const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : []; return scopes.includes(ADMIN_SCOPE); } @@ -248,11 +254,29 @@ function canClientUseModelOverride(client: GatewayRequestOptions["client"]): boo return hasAdminScope(client) || client?.internal?.allowModelOverride === true; } +function mergeGatewayClientInternal( + client: GatewayRequestOptions["client"] | undefined, + internal: NonNullable["internal"], +): GatewayRequestOptions["client"] { + if (!client || !internal) { + return client ?? null; + } + return { + ...client, + internal: { + ...client.internal, + ...internal, + }, + }; +} + async function dispatchGatewayMethod( method: string, params: Record, options?: { allowSyntheticModelOverride?: boolean; + forceSyntheticClient?: boolean; + pluginRuntimeOwnerId?: string; syntheticScopes?: string[]; }, ): Promise { @@ -267,6 +291,19 @@ async function dispatchGatewayMethod( let result: { ok: boolean; payload?: unknown; error?: ErrorShape } | undefined; const { handleGatewayRequest } = await import("./server-methods.js"); + const pluginRuntimeOwnerId = + typeof options?.pluginRuntimeOwnerId === "string" && options.pluginRuntimeOwnerId.trim() + ? options.pluginRuntimeOwnerId.trim() + : undefined; + const syntheticClient = createSyntheticOperatorClient({ + allowModelOverride: options?.allowSyntheticModelOverride === true, + ...(pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : {}), + scopes: options?.syntheticScopes, + }); + const scopedClient = mergeGatewayClientInternal( + scope?.client, + pluginRuntimeOwnerId ? { pluginRuntimeOwnerId } : undefined, + ); await handleGatewayRequest({ req: { type: "req", @@ -275,11 +312,7 @@ async function dispatchGatewayMethod( params, }, client: - scope?.client ?? - createSyntheticOperatorClient({ - allowModelOverride: options?.allowSyntheticModelOverride === true, - scopes: options?.syntheticScopes, - }), + options?.forceSyntheticClient === true ? syntheticClient : (scopedClient ?? syntheticClient), isWebchatConnect, respond: (ok, payload, error) => { if (!result) { @@ -310,6 +343,10 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { return { async run(params) { const scope = getPluginRuntimeGatewayRequestScope(); + const pluginId = + typeof scope?.pluginId === "string" && scope.pluginId.trim() + ? scope.pluginId.trim() + : undefined; const overrideRequested = Boolean(params.provider || params.model); const hasRequestScopeClient = Boolean(scope?.client); let allowOverride = hasRequestScopeClient && canClientUseModelOverride(scope?.client ?? null); @@ -348,6 +385,7 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { }, { allowSyntheticModelOverride, + ...(pluginId ? { pluginRuntimeOwnerId: pluginId } : {}), }, ); const runId = payload?.runId; @@ -378,10 +416,30 @@ export function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { return getSessionMessages(params); }, async deleteSession(params) { - await dispatchGatewayMethod("sessions.delete", { - key: params.sessionKey, - deleteTranscript: params.deleteTranscript ?? true, - }); + const scope = getPluginRuntimeGatewayRequestScope(); + const pluginId = + typeof scope?.pluginId === "string" && scope.pluginId.trim() + ? scope.pluginId.trim() + : undefined; + const pluginOwnedCleanupOptions = pluginId + ? { + pluginRuntimeOwnerId: pluginId, + ...(!hasAdminScope(scope?.client) + ? { + forceSyntheticClient: true, + syntheticScopes: [ADMIN_SCOPE], + } + : {}), + } + : undefined; + await dispatchGatewayMethod( + "sessions.delete", + { + key: params.sessionKey, + deleteTranscript: params.deleteTranscript ?? true, + }, + pluginOwnedCleanupOptions, + ); }, }; } diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 8962cf0a1a9..310570df846 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -2500,6 +2500,60 @@ describe("gateway server sessions", () => { }); }); + test("sessions.delete limits plugin-runtime cleanup to sessions owned by that plugin", async () => { + const { dir } = await createSessionStoreDir(); + await writeSingleLineSession(dir, "sess-owned", "owned"); + await writeSingleLineSession(dir, "sess-foreign", "foreign"); + + await writeSessionStore({ + entries: { + "agent:main:dreaming-narrative-owned": { + sessionId: "sess-owned", + updatedAt: Date.now(), + pluginOwnerId: "memory-core", + }, + "agent:main:dreaming-narrative-foreign": { + sessionId: "sess-foreign", + updatedAt: Date.now(), + pluginOwnerId: "other-plugin", + }, + }, + }); + + const pluginClient = { + connect: { + scopes: ["operator.admin"], + }, + internal: { + pluginRuntimeOwnerId: "memory-core", + }, + } as never; + + const denied = await directSessionReq( + "sessions.delete", + { + key: "agent:main:dreaming-narrative-foreign", + }, + { + client: pluginClient, + }, + ); + expect(denied.ok).toBe(false); + expect(denied.error?.message).toContain("did not create it"); + + const deleted = await directSessionReq<{ ok: true; deleted: boolean }>( + "sessions.delete", + { + key: "agent:main:dreaming-narrative-owned", + }, + { + client: pluginClient, + }, + ); + expect(deleted.ok).toBe(true); + expect(deleted.payload?.deleted).toBe(true); + }); + test("sessions.delete closes ACP runtime handles before removing ACP sessions", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-main", "hello");