fix(gateway): scope plugin subagent cleanup ownership

This commit is contained in:
Peter Steinberger
2026-04-27 10:36:06 +01:00
parent 600df95c8c
commit 72f7d7e4ea
11 changed files with 335 additions and 10 deletions

View File

@@ -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.

View File

@@ -491,6 +491,7 @@ Notes:
- For plugin-owned fallback runs, operators must opt in with `plugins.entries.<id>.subagent.allowModelOverride: true`.
- Use `plugins.entries.<id>.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:

View File

@@ -117,6 +117,8 @@ register(api) {
Model overrides (`provider`/`model`) require operator opt-in via `plugins.entries.<id>.subagent.allowModelOverride: true` in config. Untrusted plugins can still run subagents, but override requests are rejected.
</Warning>
`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>
<Accordion title="api.runtime.nodes">
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.

View File

@@ -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. */

View File

@@ -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<string, unknown> | undefined;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {};
const result = await updater(store);
capturedEntry = store[sessionKey] as Record<string, unknown>;
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<string, unknown> | undefined;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {
[sessionKey]: { ...existingEntry },
};
const result = await updater(store);
capturedEntry = store[sessionKey] as Record<string, unknown>;
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();

View File

@@ -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,

View File

@@ -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,

View File

@@ -26,6 +26,7 @@ export type GatewayClient = {
isDeviceTokenAuth?: boolean;
internal?: {
allowModelOverride?: boolean;
pluginRuntimeOwnerId?: string;
};
};

View File

@@ -206,6 +206,11 @@ function getLastDispatchedClientScopes(): string[] {
return Array.isArray(scopes) ? scopes : [];
}
function getLastDispatchedClientInternal(): Record<string, unknown> {
const call = handleGatewayRequest.mock.calls.at(-1)?.[0];
return (call?.client?.internal ?? {}) as Record<string, unknown>;
}
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({

View File

@@ -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<GatewayRequestOptions["client"]>["internal"],
): GatewayRequestOptions["client"] {
if (!client || !internal) {
return client ?? null;
}
return {
...client,
internal: {
...client.internal,
...internal,
},
};
}
async function dispatchGatewayMethod<T>(
method: string,
params: Record<string, unknown>,
options?: {
allowSyntheticModelOverride?: boolean;
forceSyntheticClient?: boolean;
pluginRuntimeOwnerId?: string;
syntheticScopes?: string[];
},
): Promise<T> {
@@ -267,6 +291,19 @@ async function dispatchGatewayMethod<T>(
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<T>(
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,
);
},
};
}

View File

@@ -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");