mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
fix(gateway): scope plugin subagent cleanup ownership
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -26,6 +26,7 @@ export type GatewayClient = {
|
||||
isDeviceTokenAuth?: boolean;
|
||||
internal?: {
|
||||
allowModelOverride?: boolean;
|
||||
pluginRuntimeOwnerId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user