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