返回 DeepSeek-Reasonix
permission-preset-session-fence.test.tsx
根目录 / desktop / frontend / src / __tests__ / permission-preset-session-fence.test.tsx
1 // Run: tsx src/__tests__/permission-preset-session-fence.test.tsx
2 //
3 // A preset choice is read from one session's permission snapshot. When the tab
4 // navigates to another session before the choice is applied, the choice must
5 // neither apply to nor be recorded for the session the tab now shows. The
6 // permission revision cannot tell the two apart: every freshly opened session
7 // starts from the same number.
8
9 import { JSDOM } from "jsdom";
10 import React, { act } from "react";
11 import { createRoot } from "react-dom/client";
12 import { useController } from "../lib/useController";
13 import type { AppBindings } from "../lib/bridge";
14 import type { ContextInfo, EffortInfo, Meta, TabMeta } from "../lib/types";
15 import { installDesktopHostStub } from "./desktopHostStub";
16
17 let failed = 0;
18 function eq(actual: unknown, expected: unknown, label: string) {
19 const pass = JSON.stringify(actual) === JSON.stringify(expected);
20 process.stdout.write(` ${pass ? "PASS" : "FAIL"} ${label}${pass ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}\n`);
21 if (!pass) failed += 1;
22 }
23
24 console.log("\npermission preset session fence");
25
26 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
27 pretendToBeVisual: true,
28 url: "http://localhost/",
29 });
30 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
31 globalThis.window = dom.window as unknown as Window & typeof globalThis;
32 globalThis.document = dom.window.document;
33 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
34 globalThis.Node = dom.window.Node;
35 globalThis.HTMLElement = dom.window.HTMLElement;
36 globalThis.Event = dom.window.Event;
37 globalThis.CustomEvent = dom.window.CustomEvent;
38 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
39 globalThis.MouseEvent = dom.window.MouseEvent;
40 globalThis.localStorage = dom.window.localStorage;
41 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
42 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
43
44 const flushPromises = () => new Promise<void>((resolvePromise) => setTimeout(resolvePromise, 0));
45
46 async function waitFor(label: string, predicate: () => boolean) {
47 for (let attempt = 0; attempt < 50; attempt += 1) {
48 await act(async () => {
49 await flushPromises();
50 });
51 if (predicate()) return;
52 }
53 throw new Error(`timed out waiting for ${label}`);
54 }
55
56 // The host side of one tab: the session it shows, each session's live preset
57 // and revision, and the per-session records a restart would restore.
58 // session-b sits at the revision session-a reaches after its first choice.
59 const sessions: Record<string, { preset: string; revision: number }> = {
60 "session-a": { preset: "workspace-write", revision: 2 },
61 "session-b": { preset: "workspace-write", revision: 3 },
62 };
63 const records: Record<string, string> = {};
64 let current = "session-a";
65 let navigateAfterSnapshot = false;
66 let setCalls = 0;
67 let metaReads = 0;
68
69 function applyChoice(preset: string, expectedRevision: number) {
70 const live = sessions[current];
71 if (live.revision !== expectedRevision) throw new Error("permission revision changed");
72 live.preset = preset;
73 live.revision += 1;
74 records[current] = preset;
75 }
76
77 function tabMeta(): TabMeta {
78 return {
79 id: "tab-a", scope: "project", workspaceRoot: "/repo", workspaceName: "repo", workspacePath: "/repo",
80 topicId: "topic-a", topicTitle: "General", sessionId: current, label: "model", ready: true,
81 running: false, cancellable: false, mode: "normal", toolApprovalMode: sessions[current].preset,
82 tokenMode: "full", active: true, cwd: "/repo",
83 } as TabMeta;
84 }
85
86 function metaForTab(): Meta {
87 return {
88 label: "model", ready: true, eventChannel: "agent:event", cwd: "/repo", workspaceRoot: "/repo",
89 workspaceName: "repo", workspacePath: "/repo", autoApproveTools: false, bypass: false,
90 collaborationMode: "normal", toolApprovalMode: sessions[current].preset, tokenMode: "full",
91 goal: "", goalStatus: "stopped", session: { hostId: "local", sessionId: current },
92 } as Meta;
93 }
94
95 const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
96 const effortInfo: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
97
98 installDesktopHostStub(({
99 main: {
100 App: {
101 RegisterNavigationIntent: async () => {},
102 ListTabs: async () => [tabMeta()],
103 MetaForTab: async () => {
104 metaReads += 1;
105 return metaForTab();
106 },
107 ContextUsageForTab: async () => context,
108 EffortForTab: async () => effortInfo,
109 BalanceForTab: async () => ({ available: false, display: "" }),
110 JobsForTab: async () => [],
111 CheckpointsForTab: async () => [],
112 ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
113 HistoryForTab: async () => [],
114 HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
115 HistoryCheckpointTurnsForTab: async () => [],
116 ReplayPendingPrompts: async () => {},
117 SetActiveTab: async () => {},
118 PermissionSnapshotForTab: async () => {
119 const snapshot = {
120 sessionId: current, generation: 1, revision: sessions[current].revision, preset: sessions[current].preset,
121 workspaceRoot: "/repo", grants: [],
122 capabilities: { backend: "seatbelt", enforcement: "full", supportedPresets: ["read-only", "workspace-write", "danger-full-access"] },
123 };
124 if (navigateAfterSnapshot) current = "session-b";
125 return snapshot;
126 },
127 SetPermissionPresetForTab: async (...args: unknown[]) => {
128 setCalls += 1;
129 if (args.length < 4) {
130 // A caller that names no session is fenced by the revision alone.
131 const [, preset, expectedRevision] = args as [string, string, number];
132 applyChoice(preset, expectedRevision);
133 } else {
134 const [, expectedSessionID, preset, expectedRevision] = args as [string, string, string, number];
135 if (expectedSessionID !== current) throw new Error("reasonix_error:permission_session_changed");
136 applyChoice(preset, expectedRevision);
137 }
138 return { sessionId: current, generation: 1, revision: sessions[current].revision, preset: sessions[current].preset, workspaceRoot: "/repo", grants: [] };
139 },
140 SetComposerProfileForTab: async () => [],
141 } as Partial<AppBindings> as AppBindings,
142 },
143 }).main.App);
144
145 let controller: ReturnType<typeof useController> | undefined;
146 function Probe() {
147 controller = useController();
148 return null;
149 }
150
151 const rootEl = document.getElementById("root");
152 if (!rootEl) throw new Error("missing root");
153 const root = createRoot(rootEl);
154 await act(async () => {
155 root.render(<Probe />);
156 await flushPromises();
157 });
158 await waitFor("active tab", () => controller?.activeTabId === "tab-a");
159
160 await act(async () => {
161 await controller?.setToolApprovalModeForTab("tab-a", "read-only");
162 await flushPromises();
163 });
164 eq(sessions["session-a"].preset, "read-only", "a choice read and applied in the same session lands on it");
165 eq(records["session-a"], "read-only", "that choice is recorded for the session it was made in");
166
167 navigateAfterSnapshot = true;
168 const setCallsBefore = setCalls;
169 const metaReadsBefore = metaReads;
170 let thrown: unknown;
171 await act(async () => {
172 try {
173 await controller?.setToolApprovalModeForTab("tab-a", "danger-full-access");
174 } catch (error) {
175 thrown = error;
176 }
177 await flushPromises();
178 });
179 eq(current, "session-b", "the tab navigated between the snapshot and the set");
180 eq(sessions["session-b"].preset, "workspace-write", "the stale choice is not applied to the session the tab now shows");
181 eq(records["session-b"], undefined, "the stale choice is not recorded for the session the tab now shows");
182 eq(sessions["session-a"].preset, "read-only", "the session the choice was read from is untouched");
183 eq(setCalls - setCallsBefore, 1, "the refused choice is not retried on the new session");
184 eq(thrown === undefined, true, "a session change is a settled refusal, not a failure the caller sees");
185 eq(metaReads > metaReadsBefore, true, "the refusal re-reads the tab's state");
186
187 await act(async () => {
188 root.unmount();
189 });
190 if (failed > 0) {
191 console.log(`\n${failed} failed`);
192 process.exit(1);
193 }
194 console.log("\nall passed");
195 process.exit(0);
196
196 lines Plain Text