Files
openclaw/src/shared/session-method-scopes.test.ts
Peter Steinberger bbbd70542b feat(sessions): recover offline device placements (#126284)
* feat(sessions): recover offline device placements

* chore(protocol): refresh session placement models

* perf(ui): lazy-load session placement recovery

* test(ui): remove dropdown timing assertion

* test(ui): await committed cloud recovery route

* fix(sessions): complete explicit abandonment locally

* fix(sessions): fence lists by runner availability

* fix(ui): preserve canonical session freshness

* fix(sessions): preserve recovery contracts after rebase

* test(ui): await durable cloud recovery entry

* test(gateway): complete current runner fixtures

* fix(gateway): publish runner availability edges

* fix(ui): preserve shared session freshness

* fix(ui): preserve canonical sidebar session state

* fix(sessions): preserve abandoned partials and run-owned replies

* fix(sessions): resume durable abandonment retries

* test(gateway): compose provisioning replay with runner availability

* fix(sessions): publish recovered move transitions
2026-08-20 09:59:59 -07:00

257 lines
8.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { resolveDynamicSessionMutationRequiredScope } from "./session-method-scopes.js";
describe("resolveDynamicSessionMutationRequiredScope", () => {
it("keeps explicit restart recovery at write scope", () => {
expect(resolveDynamicSessionMutationRequiredScope("sessions.recover")).toBe("operator.write");
});
it.each([
{ agentId: "main", message: "hello", worktree: true },
{ agentId: "main", message: "hello", projectId: "openclaw" },
])("keeps ordinary session creation write-scoped %#", (params) => {
expect(resolveDynamicSessionMutationRequiredScope("sessions.create", params)).toBe(
"operator.write",
);
});
it.each([
{ incognito: true },
{ key: "agent:main:dashboard:incognito-123" },
{ parentSessionKey: "agent:main:subagent:incognito-123" },
{ execNode: "node-1" },
])("requires admin for privileged session creation params %#", (params) => {
expect(resolveDynamicSessionMutationRequiredScope("sessions.create", params)).toBe(
"operator.admin",
);
});
it("leaves Gateway cwd containment to the state-aware create handler", () => {
expect(
resolveDynamicSessionMutationRequiredScope("sessions.create", {
cwd: "/configured/workspace/packages/app",
}),
).toBe("operator.write");
});
it.each(["read-only", "guarded", "workspace"])(
"keeps sessions.create permission mode %s write-scoped",
(permissionMode) => {
expect(
resolveDynamicSessionMutationRequiredScope("sessions.create", { permissionMode }),
).toBe("operator.write");
},
);
it("requires admin to create a full-access session", () => {
expect(
resolveDynamicSessionMutationRequiredScope("sessions.create", { permissionMode: "full" }),
).toBe("operator.admin");
});
it.each([
{ name: "model set", patch: { model: "openai/gpt-5.6-luna" } },
{ name: "model reset", patch: { model: null } },
{ name: "icon set", patch: { icon: "🦞" } },
{ name: "icon reset", patch: { icon: null } },
{
name: "safe mixed patch",
patch: { label: "Renamed", archived: true, model: "openai/gpt-5.6-luna" },
},
{
name: "CAS envelope",
patch: { expectedSessionId: "session-1", expectedLifecycleRevision: "revision-1" },
},
])("keeps $name write-scoped", ({ patch }) => {
expect(
resolveDynamicSessionMutationRequiredScope("sessions.patch", {
key: "agent:main:thread",
agentId: "main",
...patch,
}),
).toBe("operator.write");
});
it.each(["read-only", "guarded", "workspace"])(
"keeps sessions.patch permission mode %s write-scoped",
(permissionMode) => {
expect(
resolveDynamicSessionMutationRequiredScope("sessions.patch", {
key: "agent:main:thread",
permissionMode,
}),
).toBe("operator.write");
},
);
it("requires admin to patch a session to full access", () => {
expect(
resolveDynamicSessionMutationRequiredScope("sessions.patch", {
key: "agent:main:thread",
permissionMode: "full",
}),
).toBe("operator.admin");
expect(
resolveDynamicSessionMutationRequiredScope("sessions.patchMany", {
targets: [{ key: "agent:main:thread" }],
patch: { permissionMode: "full" },
}),
).toBe("operator.admin");
});
it.each([
{ thinkingLevel: "high" },
{ fastMode: true },
{ verboseLevel: "full" },
{ reasoningLevel: "high" },
{ model: "openai/gpt-5.6-luna", thinkingLevel: "high" },
{ model: null, futureField: true },
])("keeps privileged or unknown patch fields admin-scoped %#", (patch) => {
expect(
resolveDynamicSessionMutationRequiredScope("sessions.patch", {
key: "agent:main:thread",
...patch,
}),
).toBe("operator.admin");
});
it("scopes sessions.patchMany from the shared patch only", () => {
expect(
resolveDynamicSessionMutationRequiredScope("sessions.patchMany", {
targets: [
{
key: "agent:main:thread",
agentId: "main",
expectedSessionId: "session-1",
expectedLifecycleRevision: "revision-1",
},
],
patch: {
label: "Renamed",
icon: "🦞",
archived: true,
unread: false,
model: "openai/gpt-5.6-luna",
},
}),
).toBe("operator.write");
expect(
resolveDynamicSessionMutationRequiredScope("sessions.patchMany", {
targets: [{ key: "agent:main:thread" }],
patch: { model: null },
}),
).toBe("operator.write");
for (const patch of [
{ statusNote: "Working" },
{ thinkingLevel: "high" },
{ model: "openai/gpt-5.6-luna", fastMode: true },
{ futureField: true },
]) {
expect(
resolveDynamicSessionMutationRequiredScope("sessions.patchMany", {
targets: [{ key: "agent:main:thread" }],
patch,
}),
).toBe("operator.admin");
}
expect(resolveDynamicSessionMutationRequiredScope("sessions.patchMany")).toBe("operator.write");
expect(resolveDynamicSessionMutationRequiredScope("sessions.patchMany", {})).toBe(
"operator.write",
);
});
it("allows write-scoped deletion only for safe archived-only requests", () => {
expect(
resolveDynamicSessionMutationRequiredScope("sessions.delete", {
key: "agent:main:archived",
deleteTranscript: true,
archivedOnly: true,
expectedSessionId: "session-1",
}),
).toBe("operator.write");
for (const params of [
undefined,
null,
[],
{ key: "agent:main:active", deleteTranscript: true },
{ key: "agent:main:archived", archivedOnly: "yes" },
{
key: "agent:main:archived",
archivedOnly: true,
expectedSessionId: "session-1",
emitLifecycleHooks: false,
},
{
key: "agent:main:archived",
archivedOnly: true,
expectedSessionId: "session-1",
futureField: true,
},
]) {
expect(resolveDynamicSessionMutationRequiredScope("sessions.delete", params)).toBe(
"operator.admin",
);
}
});
it.each([
[{ key: "agent:main:thread", profileId: "development" }, "operator.admin"],
[{ key: "agent:main:thread", profileId: " " }, "operator.admin"],
[{ key: "agent:main:thread", deviceId: "device-1" }, "operator.write"],
[
{ key: "agent:main:thread", profileId: "development", deviceId: "device-1" },
"operator.write",
],
] as const)("classifies dispatch target %j as %s", (params, expected) => {
expect(resolveDynamicSessionMutationRequiredScope("sessions.dispatch", params)).toBe(expected);
});
const moveExpected = {
generation: 1,
environmentId: "environment-1",
ownerEpoch: 1,
};
it.each([
[
{ key: "agent:main:thread", expected: moveExpected, target: { kind: "gateway" } },
"operator.write",
],
[
{
key: "agent:main:thread",
expected: moveExpected,
target: { kind: "gateway" },
abandonSource: true,
},
"operator.write",
],
[
{
key: "agent:main:thread",
expected: moveExpected,
target: { kind: "device", deviceId: "device-1" },
},
"operator.write",
],
[
{
key: "agent:main:thread",
expected: moveExpected,
target: { kind: "profile", profileId: "development" },
},
"operator.admin",
],
[
{ key: "agent:main:thread", target: { kind: "profile", profileId: "development" } },
"operator.write",
],
] as const)("classifies move target %j as %s", (params, expected) => {
expect(resolveDynamicSessionMutationRequiredScope("sessions.move", params)).toBe(expected);
});
it("does not duplicate static method policy from the core descriptor table", () => {
expect(resolveDynamicSessionMutationRequiredScope("sessions.groups.put")).toBeUndefined();
expect(resolveDynamicSessionMutationRequiredScope("sessions.list")).toBeUndefined();
});
});