返回 DeepSeek-Reasonix
session_permission_preset_test.go
根目录 / internal / control / session_permission_preset_test.go
1 package control
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sync/atomic"
10 "testing"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/agent/testutil"
14 "reasonix/internal/event"
15 "reasonix/internal/session"
16 "reasonix/internal/tool"
17 )
18
19 func TestCanonicalPermissionPresetFollowsSessionIdentity(t *testing.T) {
20 restoreSandbox := SetPresetSandboxForTest(true)
21 t.Cleanup(restoreSandbox)
22 service, err := session.NewService("desktop", session.NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions")))
23 if err != nil {
24 t.Fatal(err)
25 }
26 first, err := service.Create(t.Context(), session.CreateOptions{SessionID: "first"})
27 if err != nil {
28 t.Fatal(err)
29 }
30 second, err := service.Create(t.Context(), session.CreateOptions{SessionID: "second"})
31 if err != nil {
32 t.Fatal(err)
33 }
34 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
35 c := newOwnedTestController(t, Options{Executor: exec, Sink: event.Discard, SessionService: service, SessionRuntime: first, ExclusiveSession: true})
36 c.EnableServeSessionPermissionPresets(true)
37 initial := c.PermissionSnapshot()
38 if initial.SessionID != "first" || initial.Preset != ToolApprovalWorkspaceWrite {
39 t.Fatalf("initial snapshot = %+v", initial)
40 }
41 sameChoice, _, err := c.SetSessionPermissionPreset(t.Context(), ToolApprovalWorkspaceWrite, initial.Revision)
42 if err != nil {
43 t.Fatal(err)
44 }
45 if sameChoice.Revision <= initial.Revision {
46 t.Fatalf("explicit same-value choice did not invalidate stale revision: %+v", sameChoice)
47 }
48 firstChoice, _, err := c.SetSessionPermissionPreset(t.Context(), ToolApprovalDangerFullAccess, sameChoice.Revision)
49 if err != nil {
50 t.Fatal(err)
51 }
52 if firstChoice.SessionID != "first" || firstChoice.Preset != ToolApprovalDangerFullAccess {
53 t.Fatalf("first choice = %+v", firstChoice)
54 }
55 if _, _, err := c.SetSessionPermissionPreset(t.Context(), ToolApprovalReadOnly, initial.Revision); err == nil {
56 t.Fatal("stale permission revision was accepted")
57 }
58 if _, err := c.OpenSession(t.Context(), second.Ref()); err != nil {
59 t.Fatal(err)
60 }
61 other := c.PermissionSnapshot()
62 if other.SessionID != "second" || other.Preset != ToolApprovalWorkspaceWrite || other.Revision <= firstChoice.Revision {
63 t.Fatalf("new session inherited the first preset: %+v", other)
64 }
65 if _, _, err := c.SetSessionPermissionPreset(t.Context(), ToolApprovalReadOnly, other.Revision); err != nil {
66 t.Fatal(err)
67 }
68 if _, err := c.OpenSession(t.Context(), first.Ref()); err != nil {
69 t.Fatal(err)
70 }
71 if got := c.PermissionSnapshot(); got.SessionID != "first" || got.Preset != ToolApprovalDangerFullAccess {
72 t.Fatalf("first preset was not restored: %+v", got)
73 }
74 if _, err := c.OpenSession(t.Context(), second.Ref()); err != nil {
75 t.Fatal(err)
76 }
77 if got := c.PermissionSnapshot(); got.SessionID != "second" || got.Preset != ToolApprovalReadOnly {
78 t.Fatalf("second preset was not restored: %+v", got)
79 }
80 broken, err := service.Create(t.Context(), session.CreateOptions{SessionID: "broken"})
81 if err != nil {
82 t.Fatal(err)
83 }
84 if _, err := broken.Session().AppendBatch(t.Context(), "invalid-plan", []session.Event{{Kind: "plan/state", Payload: []byte(`{"enabled":"invalid"}`)}}); err != nil {
85 t.Fatal(err)
86 }
87 if _, err := c.OpenSession(t.Context(), broken.Ref()); err == nil {
88 t.Fatal("invalid session projection was opened")
89 }
90 if got := c.PermissionSnapshot(); got.SessionID != "second" || got.Preset != ToolApprovalReadOnly {
91 t.Fatalf("failed switch changed the permission snapshot: %+v", got)
92 }
93 }
94
95 func TestCanonicalComposerProfilePersistsLegacyPermissionAlias(t *testing.T) {
96 restoreSandbox := SetPresetSandboxForTest(true)
97 t.Cleanup(restoreSandbox)
98 service, err := session.NewService("desktop", session.NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions")))
99 if err != nil {
100 t.Fatal(err)
101 }
102 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "composer"})
103 if err != nil {
104 t.Fatal(err)
105 }
106 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
107 c := newOwnedTestController(t, Options{Executor: exec, Sink: event.Discard, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true})
108 c.EnableServeSessionPermissionPresets(true)
109 if _, err := c.ApplyComposerProfileAtDurable(t.Context(), false, "ask", "", c.PermissionSnapshot().Revision); err != nil {
110 t.Fatal(err)
111 }
112 if got := runtime.Session().ExplicitPermissionPreset(); got != ToolApprovalReadOnly {
113 t.Fatalf("legacy composer alias was not persisted canonically: %q", got)
114 }
115 }
116
117 func TestCanonicalPermissionDowngradeFailsClosedAfterFlushError(t *testing.T) {
118 restoreSandbox := SetPresetSandboxForTest(true)
119 t.Cleanup(restoreSandbox)
120 var failSync atomic.Bool
121 store, err := session.CreateWithOptions(filepath.Join(t.TempDir(), "session"), "sync-failure", session.OpenOptions{
122 Sync: func(file *os.File) error {
123 if failSync.Load() {
124 return errors.New("injected sync failure")
125 }
126 return file.Sync()
127 },
128 })
129 if err != nil {
130 t.Fatal(err)
131 }
132 service, err := session.NewService("desktop", failingFlushPersistence{session: store})
133 if err != nil {
134 t.Fatal(err)
135 }
136 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "sync-failure"})
137 if err != nil {
138 t.Fatal(err)
139 }
140 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
141 c := newOwnedTestController(t, Options{Executor: exec, Sink: event.Discard, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true})
142 c.EnableServeSessionPermissionPresets(true)
143 full, _, err := c.SetSessionPermissionPreset(t.Context(), ToolApprovalDangerFullAccess, c.PermissionSnapshot().Revision)
144 if err != nil {
145 t.Fatal(err)
146 }
147 failSync.Store(true)
148 after, _, err := c.SetSessionPermissionPreset(t.Context(), ToolApprovalReadOnly, full.Revision)
149 if err == nil {
150 t.Fatal("failed fsync returned a successful permission choice")
151 }
152 if after.Preset != ToolApprovalReadOnly || after.Revision <= full.Revision {
153 t.Fatalf("failed downgrade left full access active: %+v", after)
154 }
155 rejected, _, err := c.SetSessionPermissionPreset(t.Context(), ToolApprovalDangerFullAccess, after.Revision)
156 if err == nil || rejected.Preset != ToolApprovalReadOnly {
157 t.Fatalf("failed upgrade exposed full access: snapshot=%+v err=%v", rejected, err)
158 }
159 if _, err := c.publishSessionRuntime(runtime, agent.NewSession("system"), false); err == nil {
160 t.Fatal("a session with an unflushed broad preset was published")
161 }
162 if got := c.PermissionSnapshot().Preset; got != ToolApprovalReadOnly {
163 t.Fatalf("failed publication changed the enforced preset to %q", got)
164 }
165 rebuilt := newOwnedTestController(t, Options{
166 Executor: agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard),
167 Sink: event.Discard, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true,
168 })
169 rebuilt.EnableServeSessionPermissionPresets(true)
170 if got := rebuilt.PermissionSnapshot().Preset; got != ToolApprovalReadOnly {
171 t.Fatalf("rebuilt controller exposed unflushed preset %q", got)
172 }
173 }
174
175 func TestCanonicalPermissionSnapshotNeverMixesSessionAndPreset(t *testing.T) {
176 restoreSandbox := SetPresetSandboxForTest(true)
177 t.Cleanup(restoreSandbox)
178 service, err := session.NewService("desktop", session.NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions")))
179 if err != nil {
180 t.Fatal(err)
181 }
182 first, err := service.Create(t.Context(), session.CreateOptions{SessionID: "first"})
183 if err != nil {
184 t.Fatal(err)
185 }
186 second, err := service.Create(t.Context(), session.CreateOptions{SessionID: "second"})
187 if err != nil {
188 t.Fatal(err)
189 }
190 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
191 c := newOwnedTestController(t, Options{Executor: exec, Sink: event.Discard, SessionService: service, SessionRuntime: first, ExclusiveSession: true})
192 c.EnableServeSessionPermissionPresets(true)
193 if _, _, err := c.SetSessionPermissionPreset(t.Context(), ToolApprovalDangerFullAccess, c.PermissionSnapshot().Revision); err != nil {
194 t.Fatal(err)
195 }
196 if _, err := c.OpenSession(t.Context(), second.Ref()); err != nil {
197 t.Fatal(err)
198 }
199 if _, _, err := c.SetSessionPermissionPreset(t.Context(), ToolApprovalReadOnly, c.PermissionSnapshot().Revision); err != nil {
200 t.Fatal(err)
201 }
202 done := make(chan struct{})
203 observed := make(chan error, 1)
204 go func() {
205 defer close(observed)
206 for {
207 select {
208 case <-done:
209 return
210 default:
211 }
212 snapshot := c.PermissionSnapshot()
213 if (snapshot.SessionID == "first" && snapshot.Preset != ToolApprovalDangerFullAccess) ||
214 (snapshot.SessionID == "second" && snapshot.Preset != ToolApprovalReadOnly) {
215 observed <- fmt.Errorf("mixed permission snapshot: %+v", snapshot)
216 return
217 }
218 }
219 }()
220 for range 20 {
221 if _, err := c.OpenSession(t.Context(), first.Ref()); err != nil {
222 close(done)
223 t.Fatal(err)
224 }
225 if _, err := c.OpenSession(t.Context(), second.Ref()); err != nil {
226 close(done)
227 t.Fatal(err)
228 }
229 }
230 close(done)
231 if err := <-observed; err != nil {
232 t.Fatal(err)
233 }
234 }
235
236 func TestCanonicalPermissionPresetRestoresAfterServiceRestart(t *testing.T) {
237 restoreSandbox := SetPresetSandboxForTest(true)
238 t.Cleanup(restoreSandbox)
239 root := filepath.Join(t.TempDir(), "sessions")
240 service, err := session.NewService("desktop", session.NewFilesystemPersistence(root))
241 if err != nil {
242 t.Fatal(err)
243 }
244 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "reopened"})
245 if err != nil {
246 t.Fatal(err)
247 }
248 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
249 c := New(Options{Executor: exec, Sink: event.Discard, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true})
250 c.EnableServeSessionPermissionPresets(true)
251 before := c.PermissionSnapshot()
252 if _, _, err := c.SetSessionPermissionPreset(t.Context(), ToolApprovalDangerFullAccess, before.Revision); err != nil {
253 t.Fatal(err)
254 }
255 c.Close()
256 <-c.closeFinalized
257 if err := service.CloseAll(context.Background()); err != nil {
258 t.Fatal(err)
259 }
260 restarted, err := session.NewService("desktop", session.NewFilesystemPersistence(root))
261 if err != nil {
262 t.Fatal(err)
263 }
264 t.Cleanup(func() { _ = restarted.CloseAll(context.Background()) })
265 binding, err := restarted.Open(t.Context(), runtime.Ref())
266 if err != nil {
267 t.Fatal(err)
268 }
269 defer binding.Release(context.Background())
270 exec = agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
271 reopened := newOwnedTestController(t, Options{Executor: exec, Sink: event.Discard, SessionService: restarted, SessionRuntime: binding.Runtime(), ExclusiveSession: true})
272 reopened.EnableServeSessionPermissionPresets(true)
273 if got := reopened.PermissionSnapshot(); got.SessionID != "reopened" || got.Preset != ToolApprovalDangerFullAccess {
274 t.Fatalf("restarted controller did not restore durable preset: %+v", got)
275 }
276 }
277
278 func TestUnmanagedCanonicalSessionKeepsReadOnlyAcrossTransitions(t *testing.T) {
279 restoreSandbox := SetPresetSandboxForTest(true)
280 t.Cleanup(restoreSandbox)
281 service, err := session.NewService("desktop", session.NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions")))
282 if err != nil {
283 t.Fatal(err)
284 }
285 first, err := service.Create(t.Context(), session.CreateOptions{SessionID: "first"})
286 if err != nil {
287 t.Fatal(err)
288 }
289 c := newOwnedTestController(t, Options{
290 Executor: agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard),
291 Sink: event.Discard, SessionService: service, SessionRuntime: first, ExclusiveSession: true,
292 })
293 if _, _, err := c.SetSessionPermissionPreset(t.Context(), ToolApprovalDangerFullAccess, c.PermissionSnapshot().Revision); err != nil {
294 t.Fatal(err)
295 }
296 c.SetToolApprovalMode(ToolApprovalReadOnly)
297 if err := c.rotateExclusiveSession(false); err != nil {
298 t.Fatal(err)
299 }
300 if got := c.ToolApprovalMode(); got != ToolApprovalReadOnly {
301 t.Fatalf("new session widened read-only to %q", got)
302 }
303 if _, err := c.OpenSession(t.Context(), first.Ref()); err != nil {
304 t.Fatal(err)
305 }
306 if got := c.ToolApprovalMode(); got != ToolApprovalReadOnly {
307 t.Fatalf("resume widened read-only to stored preset %q", got)
308 }
309 }
310
311 func TestServeForkStartsNoBroaderThanInheritedReadOnly(t *testing.T) {
312 service, _, c := newForkTargetsHarness(t, "readonly-parent", testutil.Turn{Text: "answer"})
313 c.EnableServeSessionPermissionPresets(true)
314 if _, _, err := c.SetSessionPermissionPreset(t.Context(), ToolApprovalReadOnly, c.PermissionSnapshot().Revision); err != nil {
315 t.Fatal(err)
316 }
317 if err := c.RunTurn(t.Context(), "one"); err != nil {
318 t.Fatal(err)
319 }
320 targets, err := c.ForkTargets()
321 if err != nil {
322 t.Fatal(err)
323 }
324 target := targets.Targets[0]
325 result, err := service.CreateFork(t.Context(), session.ForkRequest{
326 Source: targets.Source, TurnID: target.TurnID, BoundarySequence: target.BoundarySequence,
327 OperationID: "fork-readonly",
328 })
329 if err != nil {
330 t.Fatal(err)
331 }
332 if _, err := c.OpenSession(t.Context(), result.Child); err != nil {
333 t.Fatal(err)
334 }
335 if got := c.ToolApprovalMode(); got != ToolApprovalReadOnly {
336 t.Fatalf("fork widened inherited read-only to %q", got)
337 }
338 }
339
340 func TestServeRebuildOptInPreservesLiveModeUntilSessionSwitch(t *testing.T) {
341 restoreSandbox := SetPresetSandboxForTest(true)
342 t.Cleanup(restoreSandbox)
343 service, err := session.NewService("serve-test", session.NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions")))
344 if err != nil {
345 t.Fatal(err)
346 }
347 first, err := service.Create(t.Context(), session.CreateOptions{SessionID: "first"})
348 if err != nil {
349 t.Fatal(err)
350 }
351 second, err := service.Create(t.Context(), session.CreateOptions{SessionID: "second"})
352 if err != nil {
353 t.Fatal(err)
354 }
355 c := newOwnedTestController(t, Options{
356 Executor: agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard),
357 Sink: event.Discard, SessionService: service, SessionRuntime: first, ExclusiveSession: true,
358 })
359 if _, _, err := c.SetSessionPermissionPreset(t.Context(), ToolApprovalDangerFullAccess, c.PermissionSnapshot().Revision); err != nil {
360 t.Fatal(err)
361 }
362 c.SetToolApprovalMode(ToolApprovalReadOnly)
363 c.EnableServeSessionPermissionPresets(false)
364 if got := c.ToolApprovalMode(); got != ToolApprovalReadOnly {
365 t.Fatalf("rebuild opt-in widened live mode to %q", got)
366 }
367 if _, err := c.OpenSession(t.Context(), second.Ref()); err != nil {
368 t.Fatal(err)
369 }
370 if got := c.ToolApprovalMode(); got != ToolApprovalWorkspaceWrite {
371 t.Fatalf("new remote session preset = %q", got)
372 }
373 if _, err := c.OpenSession(t.Context(), first.Ref()); err != nil {
374 t.Fatal(err)
375 }
376 if got := c.ToolApprovalMode(); got != ToolApprovalDangerFullAccess {
377 t.Fatalf("stored remote preset was not restored: %q", got)
378 }
379 }
380
380 lines GO