返回 DeepSeek-Reasonix
store_test.go
根目录 / internal / sessioninbox / store_test.go
1 package sessioninbox
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "testing"
12 "time"
13
14 "reasonix/internal/fileutil"
15 "reasonix/internal/store"
16 )
17
18 func TestEnqueueSnapshotAndRead(t *testing.T) {
19 dir := t.TempDir()
20 session := filepath.Join(dir, "s.jsonl")
21 if err := os.WriteFile(session, []byte("{}\n"), 0o644); err != nil {
22 t.Fatal(err)
23 }
24 s, err := Open(session, Limits{})
25 if err != nil {
26 t.Fatal(err)
27 }
28 defer s.Close()
29
30 rec, err := s.Enqueue(EnqueueRequest{
31 Intent: IntentFollowup,
32 Envelope: PromptEnvelope{
33 DisplayText: "hello world",
34 SubmitText: "hello world",
35 },
36 Source: "test",
37 })
38 if err != nil {
39 t.Fatal(err)
40 }
41 if rec.ItemID == "" || rec.Position != 1 {
42 t.Fatalf("receipt = %+v", rec)
43 }
44 snap := s.Snapshot()
45 if len(snap.Items) != 1 || snap.Items[0].Preview == "" {
46 t.Fatalf("snapshot = %+v", snap)
47 }
48 // Body must not appear in snapshot metadata beyond preview.
49 if strings.Contains(snap.Items[0].Preview, "\x00") {
50 t.Fatal("unexpected binary in preview")
51 }
52 meta, env, err := s.ReadItem(rec.ItemID)
53 if err != nil {
54 t.Fatal(err)
55 }
56 if meta.ID != rec.ItemID || env.SubmitText != "hello world" {
57 t.Fatalf("read = meta=%+v env=%+v", meta, env)
58 }
59 // Unix: dir 0700. Windows reports 0777 and does not enforce owner-only bits.
60 if runtime.GOOS != "windows" {
61 info, err := os.Stat(store.SessionInboxDir(session))
62 if err != nil {
63 t.Fatal(err)
64 }
65 if info.Mode().Perm() != 0o700 {
66 t.Fatalf("inbox dir perm = %o", info.Mode().Perm())
67 }
68 }
69 }
70
71 func TestIdempotentEnqueue(t *testing.T) {
72 dir := t.TempDir()
73 session := filepath.Join(dir, "s.jsonl")
74 _ = os.WriteFile(session, []byte("{}\n"), 0o644)
75 s, err := Open(session, Limits{})
76 if err != nil {
77 t.Fatal(err)
78 }
79 defer s.Close()
80 a, err := s.Enqueue(EnqueueRequest{
81 Intent: IntentFollowup,
82 Envelope: PromptEnvelope{SubmitText: "x"},
83 Idempotency: "msg-1",
84 })
85 if err != nil {
86 t.Fatal(err)
87 }
88 b, err := s.Enqueue(EnqueueRequest{
89 Intent: IntentFollowup,
90 Envelope: PromptEnvelope{SubmitText: "x"},
91 Idempotency: "msg-1",
92 })
93 if err != nil {
94 t.Fatal(err)
95 }
96 if a.ItemID != b.ItemID || !b.Idempotent {
97 t.Fatalf("idempotency failed: a=%+v b=%+v", a, b)
98 }
99 if len(s.Snapshot().Items) != 1 {
100 t.Fatalf("want 1 item, got %d", len(s.Snapshot().Items))
101 }
102 }
103
104 func TestIdempotencyConflictRejectsDifferentInput(t *testing.T) {
105 session := filepath.Join(t.TempDir(), "s.jsonl")
106 s, err := Open(session, Limits{})
107 if err != nil {
108 t.Fatal(err)
109 }
110 defer s.Close()
111 if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "first"}, Idempotency: "msg-1"}); err != nil {
112 t.Fatal(err)
113 }
114 if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "different"}, Idempotency: "msg-1"}); !errors.Is(err, ErrIdempotencyConflict) {
115 t.Fatalf("different input error = %v, want ErrIdempotencyConflict", err)
116 }
117 }
118
119 func TestIdempotencyHashTreatsLegacyAndModernInvocationAsEquivalent(t *testing.T) {
120 legacy, err := idempotencyRequestHash(completeEnqueueEnvelope(PromptEnvelope{
121 DisplayText: "/init", Invocation: &StructuredInvocation{Name: "init"},
122 }))
123 if err != nil {
124 t.Fatal(err)
125 }
126 modern, err := idempotencyRequestHash(completeEnqueueEnvelope(PromptEnvelope{
127 DisplayText: "/init", Invocations: []StructuredInvocation{{Name: "init", Kind: "skill", Offset: 7}},
128 }))
129 if err != nil {
130 t.Fatal(err)
131 }
132 if legacy != modern {
133 t.Fatalf("legacy hash %q != modern hash %q", legacy, modern)
134 }
135 }
136
137 func TestIdempotencyReceiptSurvivesAckAndReopen(t *testing.T) {
138 session := filepath.Join(t.TempDir(), "s.jsonl")
139 s, err := Open(session, Limits{})
140 if err != nil {
141 t.Fatal(err)
142 }
143 first, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "write once"}, Idempotency: "msg-1"})
144 if err != nil {
145 t.Fatal(err)
146 }
147 if err := s.ClaimItem(first.ItemID); err != nil {
148 t.Fatal(err)
149 }
150 if err := s.AckDequeue(first.ItemID); err != nil {
151 t.Fatal(err)
152 }
153 s.Close()
154
155 reopened, err := Open(session, Limits{})
156 if err != nil {
157 t.Fatal(err)
158 }
159 defer reopened.Close()
160 retry, err := reopened.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "write once"}, Idempotency: "msg-1"})
161 if err != nil {
162 t.Fatal(err)
163 }
164 if !retry.Idempotent || retry.ItemID != first.ItemID || len(reopened.Snapshot().Items) != 0 {
165 t.Fatalf("completed retry = %+v items=%+v", retry, reopened.Snapshot().Items)
166 }
167 if _, err := reopened.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "write twice"}, Idempotency: "msg-1"}); !errors.Is(err, ErrIdempotencyConflict) {
168 t.Fatalf("completed conflicting retry error = %v", err)
169 }
170 }
171
172 func TestCollectAliasReceiptSurvivesAck(t *testing.T) {
173 session := filepath.Join(t.TempDir(), "s.jsonl")
174 s, err := Open(session, Limits{})
175 if err != nil {
176 t.Fatal(err)
177 }
178 defer s.Close()
179 first, err := s.Enqueue(EnqueueRequest{
180 Envelope: PromptEnvelope{SubmitText: "first"}, Idempotency: "msg-1",
181 })
182 if err != nil {
183 t.Fatal(err)
184 }
185 secondRequest := PromptEnvelope{SubmitText: "second", Source: "bot", Extra: map[string]string{"route": "chat-1"}}
186 if _, err := s.UpdateItemWithIdempotency(
187 first.ItemID,
188 PromptEnvelope{SubmitText: "first\nsecond"},
189 "msg-2",
190 secondRequest,
191 ); err != nil {
192 t.Fatal(err)
193 }
194 if err := s.ClaimItem(first.ItemID); err != nil {
195 t.Fatal(err)
196 }
197 if err := s.AckDequeue(first.ItemID); err != nil {
198 t.Fatal(err)
199 }
200 retry, err := s.Enqueue(EnqueueRequest{Envelope: secondRequest, Idempotency: "msg-2"})
201 if err != nil {
202 t.Fatal(err)
203 }
204 if !retry.Idempotent || retry.ItemID != first.ItemID || len(s.Snapshot().Items) != 0 {
205 t.Fatalf("completed collect replay = %+v snapshot=%+v", retry, s.Snapshot())
206 }
207 }
208
209 func TestV1ManifestMigratesIdempotencyFingerprint(t *testing.T) {
210 session := filepath.Join(t.TempDir(), "s.jsonl")
211 s, err := Open(session, Limits{})
212 if err != nil {
213 t.Fatal(err)
214 }
215 first, err := s.Enqueue(EnqueueRequest{
216 Envelope: PromptEnvelope{SubmitText: "legacy durable input"}, Idempotency: "legacy-msg-1",
217 })
218 if err != nil {
219 t.Fatal(err)
220 }
221 s.Close()
222
223 manifestPath := filepath.Join(store.SessionInboxDir(session), manifestName)
224 data, err := os.ReadFile(manifestPath)
225 if err != nil {
226 t.Fatal(err)
227 }
228 var legacy map[string]any
229 if err := json.Unmarshal(data, &legacy); err != nil {
230 t.Fatal(err)
231 }
232 legacy["schemaVersion"] = float64(1)
233 delete(legacy, "idempotencyHashes")
234 delete(legacy, "receipts")
235 data, err = json.MarshalIndent(legacy, "", " ")
236 if err != nil {
237 t.Fatal(err)
238 }
239 if err := os.WriteFile(manifestPath, append(data, '\n'), 0o600); err != nil {
240 t.Fatal(err)
241 }
242
243 reopened, err := Open(session, Limits{})
244 if err != nil {
245 t.Fatal(err)
246 }
247 defer reopened.Close()
248 retry, err := reopened.Enqueue(EnqueueRequest{
249 Envelope: PromptEnvelope{SubmitText: "legacy durable input"}, Idempotency: "legacy-msg-1",
250 })
251 if err != nil {
252 t.Fatal(err)
253 }
254 if !retry.Idempotent || retry.ItemID != first.ItemID {
255 t.Fatalf("migrated replay = %+v, first = %+v", retry, first)
256 }
257 if reopened.man.SchemaVersion != SchemaVersion || !validSHA256(reopened.man.IdempotencyHashes["legacy-msg-1"]) {
258 t.Fatalf("manifest was not upgraded with fingerprint: %+v", reopened.man)
259 }
260 }
261
262 func TestManifestBlobPathEscapeIsQuarantinedWithoutTouchingTarget(t *testing.T) {
263 session := filepath.Join(t.TempDir(), "s.jsonl")
264 inboxDir := store.SessionInboxDir(session)
265 if err := os.MkdirAll(filepath.Join(inboxDir, blobsDirName), 0o700); err != nil {
266 t.Fatal(err)
267 }
268 target := filepath.Join(inboxDir, "outside.json")
269 if err := os.WriteFile(target, []byte("do-not-touch"), 0o600); err != nil {
270 t.Fatal(err)
271 }
272 bad := manifest{
273 SchemaVersion: SchemaVersion,
274 RunID: ProcessRunID(),
275 Items: []InboxItemMeta{{
276 ID: newRandomID(),
277 BlobName: "../outside",
278 Intent: IntentFollowup,
279 State: StateQueued,
280 CreatedAt: time.Now().UTC(),
281 UpdatedAt: time.Now().UTC(),
282 }},
283 }
284 data, err := json.Marshal(bad)
285 if err != nil {
286 t.Fatal(err)
287 }
288 if err := os.WriteFile(filepath.Join(inboxDir, manifestName), data, 0o600); err != nil {
289 t.Fatal(err)
290 }
291
292 s, err := Open(session, Limits{})
293 if err != nil {
294 t.Fatal(err)
295 }
296 defer s.Close()
297 if snap := s.Snapshot(); !snap.Paused || !snap.Recovered || len(snap.Items) != 0 {
298 t.Fatalf("invalid manifest was not quarantined: %+v", snap)
299 }
300 got, err := os.ReadFile(target)
301 if err != nil || string(got) != "do-not-touch" {
302 t.Fatalf("path escape target changed: %q err=%v", got, err)
303 }
304 if _, err := s.blobPath("../outside"); err == nil {
305 t.Fatal("blobPath accepted a parent traversal")
306 }
307 }
308
309 func TestValidateManifestRejectsSemanticCorruption(t *testing.T) {
310 id := newRandomID()
311 base := InboxItemMeta{
312 ID: id, BlobName: id, Intent: IntentFollowup, State: StateQueued,
313 CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
314 }
315 for name, mutate := range map[string]func(*manifest){
316 "negative size": func(m *manifest) { m.Items[0].ByteSize = -1 },
317 "duplicate id": func(m *manifest) { m.Items = append(m.Items, m.Items[0]) },
318 "invalid state": func(m *manifest) { m.Items[0].State = InboxState("mystery") },
319 "orphan idempotency": func(m *manifest) {
320 m.Idempotency["msg-1"] = newRandomID()
321 m.IdempotencyHashes["msg-1"] = strings.Repeat("a", 64)
322 },
323 } {
324 t.Run(name, func(t *testing.T) {
325 m := emptyManifest(ProcessRunID())
326 m.Items = []InboxItemMeta{base}
327 mutate(m)
328 if err := validateManifest(m, false); err == nil {
329 t.Fatalf("semantic corruption %q was accepted", name)
330 }
331 })
332 }
333 }
334
335 func TestStoreInstancesReloadManifestBeforeMutation(t *testing.T) {
336 dir := t.TempDir()
337 session := filepath.Join(dir, "s.jsonl")
338 _ = os.WriteFile(session, []byte("{}\n"), 0o644)
339 first, err := Open(session, Limits{})
340 if err != nil {
341 t.Fatal(err)
342 }
343 defer first.Close()
344 second, err := Open(session, Limits{})
345 if err != nil {
346 t.Fatal(err)
347 }
348 defer second.Close()
349
350 a, err := first.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "from first"}})
351 if err != nil {
352 t.Fatal(err)
353 }
354 b, err := second.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "from second"}})
355 if err != nil {
356 t.Fatal(err)
357 }
358 items := first.Snapshot().Items
359 if len(items) != 2 || items[0].ID != a.ItemID || items[1].ID != b.ItemID {
360 t.Fatalf("cross-store writes lost or reordered an item: %+v", items)
361 }
362 }
363
364 func TestCapacityLimits(t *testing.T) {
365 dir := t.TempDir()
366 session := filepath.Join(dir, "s.jsonl")
367 _ = os.WriteFile(session, []byte("{}\n"), 0o644)
368 s, err := Open(session, Limits{MaxItems: 2, MaxItemBytes: 200, MaxTotalBytes: 1 << 20})
369 if err != nil {
370 t.Fatal(err)
371 }
372 defer s.Close()
373 if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: strings.Repeat("a", 400)}}); !errors.Is(err, ErrItemTooLarge) {
374 t.Fatalf("item too large: %v", err)
375 }
376 if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "one"}}); err != nil {
377 t.Fatal(err)
378 }
379 if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "two"}}); err != nil {
380 t.Fatal(err)
381 }
382 if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "three"}}); !errors.Is(err, ErrCapacityItems) {
383 t.Fatalf("cap items: %v", err)
384 }
385 }
386
387 func TestDeleteThenBlobGone(t *testing.T) {
388 dir := t.TempDir()
389 session := filepath.Join(dir, "s.jsonl")
390 _ = os.WriteFile(session, []byte("{}\n"), 0o644)
391 s, err := Open(session, Limits{})
392 if err != nil {
393 t.Fatal(err)
394 }
395 defer s.Close()
396 rec, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "bye"}})
397 if err := s.DeleteItem(rec.ItemID); err != nil {
398 t.Fatal(err)
399 }
400 if _, err := os.Stat(filepath.Join(store.SessionInboxDir(session), "blobs", rec.ItemID+".json")); !os.IsNotExist(err) {
401 t.Fatalf("blob should be gone, err=%v", err)
402 }
403 }
404
405 func TestCrashAfterBlobBeforeManifestLeavesNoValidItem(t *testing.T) {
406 dir := t.TempDir()
407 session := filepath.Join(dir, "s.jsonl")
408 _ = os.WriteFile(session, []byte("{}\n"), 0o644)
409 s, err := Open(session, Limits{})
410 if err != nil {
411 t.Fatal(err)
412 }
413 // Inject crash after blob rename, before manifest commit.
414 fileutil.CrashPoint = func(op, path string) {
415 if op == "inbox-manifest-write" {
416 panic("inject crash before manifest")
417 }
418 }
419 t.Cleanup(func() { fileutil.CrashPoint = nil })
420 func() {
421 defer func() { _ = recover() }()
422 _, _ = s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "orphan"}})
423 }()
424 fileutil.CrashPoint = nil
425 // Re-open: no valid items; orphan blob may exist and is GC'd/quarantined.
426 s2, err := Open(session, Limits{})
427 if err != nil {
428 t.Fatal(err)
429 }
430 defer s2.Close()
431 if n := len(s2.Snapshot().Items); n != 0 {
432 t.Fatalf("want 0 valid items after crash, got %d", n)
433 }
434 }
435
436 func TestUpdateCrashPointsPreserveCompleteRevision(t *testing.T) {
437 tests := []struct {
438 crashOp string
439 want string
440 }{
441 {crashOp: "inbox-blob-write", want: "old body"},
442 {crashOp: "inbox-blob-rename", want: "old body"},
443 {crashOp: "inbox-manifest-write", want: "old body"},
444 {crashOp: "inbox-manifest-commit", want: "new body"},
445 }
446 for _, tt := range tests {
447 t.Run(tt.crashOp, func(t *testing.T) {
448 dir := t.TempDir()
449 session := filepath.Join(dir, "s.jsonl")
450 s, err := Open(session, Limits{})
451 if err != nil {
452 t.Fatal(err)
453 }
454 rec, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "old body"}})
455 if err != nil {
456 t.Fatal(err)
457 }
458 fileutil.CrashPoint = func(op, _ string) {
459 if op == tt.crashOp {
460 panic("injected update crash")
461 }
462 }
463 func() {
464 defer func() { _ = recover() }()
465 _, _ = s.UpdateItem(rec.ItemID, PromptEnvelope{SubmitText: "new body"})
466 }()
467 fileutil.CrashPoint = nil
468 s.Close()
469
470 reopened, err := Open(session, Limits{})
471 if err != nil {
472 t.Fatal(err)
473 }
474 defer reopened.Close()
475 _, env, err := reopened.ReadItem(rec.ItemID)
476 if err != nil {
477 t.Fatal(err)
478 }
479 if env.SubmitText != tt.want {
480 t.Fatalf("recovered body = %q, want %q", env.SubmitText, tt.want)
481 }
482 })
483 }
484 t.Cleanup(func() { fileutil.CrashPoint = nil })
485 }
486
487 func TestCrossProcessRecoveryPauses(t *testing.T) {
488 dir := t.TempDir()
489 session := filepath.Join(dir, "s.jsonl")
490 _ = os.WriteFile(session, []byte("{}\n"), 0o644)
491 s, err := Open(session, Limits{})
492 if err != nil {
493 t.Fatal(err)
494 }
495 rec, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "work"}})
496 if err != nil {
497 t.Fatal(err)
498 }
499 if err := s.SetState(rec.ItemID, StateRunning, ""); err != nil {
500 t.Fatal(err)
501 }
502 s.Close()
503
504 // Simulate another process by rewriting runID in a fresh Open with different ProcessRunID.
505 // Open always uses ProcessRunID(); force recovery by editing manifest runId.
506 manPath := filepath.Join(store.SessionInboxDir(session), "manifest.json")
507 data, _ := os.ReadFile(manPath)
508 data = []byte(strings.Replace(string(data), ProcessRunID(), "other-run-id-0000", 1))
509 _ = os.WriteFile(manPath, data, 0o600)
510
511 s2, err := Open(session, Limits{})
512 if err != nil {
513 t.Fatal(err)
514 }
515 defer s2.Close()
516 snap := s2.Snapshot()
517 if !snap.Paused || !snap.Recovered {
518 t.Fatalf("want paused+recovered, got %+v", snap)
519 }
520 if len(snap.Items) != 1 || snap.Items[0].State != StateUncertain {
521 t.Fatalf("want uncertain item, got %+v", snap.Items)
522 }
523 }
524
525 func TestPreviewDoesNotMaterializeHugeBody(t *testing.T) {
526 huge := strings.Repeat("x", 1<<20)
527 p := PreviewText(huge, 40)
528 if len(p) > 80 {
529 t.Fatalf("preview too long: %d", len(p))
530 }
531 if !strings.HasSuffix(p, "…") {
532 t.Fatalf("want ellipsis, got %q", p)
533 }
534 }
535
536 func TestUpdateUsesImmutableBlobRevision(t *testing.T) {
537 dir := t.TempDir()
538 session := filepath.Join(dir, "s.jsonl")
539 _ = os.WriteFile(session, []byte("{}\n"), 0o644)
540 s, err := Open(session, Limits{})
541 if err != nil {
542 t.Fatal(err)
543 }
544 defer s.Close()
545 rec, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "original"}})
546 if err != nil {
547 t.Fatal(err)
548 }
549 meta, _, err := s.ReadItem(rec.ItemID)
550 if err != nil {
551 t.Fatal(err)
552 }
553 oldBlob := blobNameFor(meta)
554 updated, err := s.UpdateItem(rec.ItemID, PromptEnvelope{SubmitText: "revised"})
555 if err != nil {
556 t.Fatal(err)
557 }
558 if blobNameFor(updated) == oldBlob {
559 t.Fatal("update must write a new blob name, not overwrite in place")
560 }
561 oldPath, err := s.blobPath(oldBlob)
562 if err != nil {
563 t.Fatal(err)
564 }
565 if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
566 t.Fatalf("old blob should be removed after successful update, err=%v", err)
567 }
568 _, env, err := s.ReadItem(rec.ItemID)
569 if err != nil || env.SubmitText != "revised" {
570 t.Fatalf("read after update = %+v err=%v", env, err)
571 }
572 }
573
574 func TestCorruptManifestSalvagesBlobs(t *testing.T) {
575 dir := t.TempDir()
576 session := filepath.Join(dir, "s.jsonl")
577 _ = os.WriteFile(session, []byte("{}\n"), 0o644)
578 s, err := Open(session, Limits{})
579 if err != nil {
580 t.Fatal(err)
581 }
582 if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "keep-me"}}); err != nil {
583 t.Fatal(err)
584 }
585 s.Close()
586
587 // Corrupt the manifest.
588 manPath := filepath.Join(store.SessionInboxDir(session), "manifest.json")
589 if err := os.WriteFile(manPath, []byte("{not-json"), 0o600); err != nil {
590 t.Fatal(err)
591 }
592 s2, err := Open(session, Limits{})
593 if err != nil {
594 t.Fatal(err)
595 }
596 defer s2.Close()
597 snap := s2.Snapshot()
598 if !snap.Paused || !snap.Recovered {
599 t.Fatalf("want paused+recovered after corrupt manifest, got %+v", snap)
600 }
601 if len(snap.Items) == 0 || snap.RecoveredN == 0 {
602 t.Fatalf("salvage must surface blobs, got items=%d recoveredN=%d", len(snap.Items), snap.RecoveredN)
603 }
604 }
605
606 func TestCorruptManifestSalvageRefusesSymlinkBlob(t *testing.T) {
607 if runtime.GOOS == "windows" {
608 t.Skip("symlink creation may require elevated privileges")
609 }
610 session := filepath.Join(t.TempDir(), "s.jsonl")
611 inboxDir := store.SessionInboxDir(session)
612 blobsDir := filepath.Join(inboxDir, blobsDirName)
613 if err := os.MkdirAll(blobsDir, 0o700); err != nil {
614 t.Fatal(err)
615 }
616 target := filepath.Join(t.TempDir(), "external.json")
617 if err := os.WriteFile(target, []byte(`{"submitText":"external secret"}`), 0o600); err != nil {
618 t.Fatal(err)
619 }
620 if err := os.Symlink(target, filepath.Join(blobsDir, newRandomID()+blobSuffix)); err != nil {
621 t.Fatal(err)
622 }
623 if err := os.WriteFile(filepath.Join(inboxDir, manifestName), []byte("{broken"), 0o600); err != nil {
624 t.Fatal(err)
625 }
626 s, err := Open(session, Limits{})
627 if err != nil {
628 t.Fatal(err)
629 }
630 defer s.Close()
631 if got := s.Snapshot(); !got.Paused || !got.Recovered || len(got.Items) != 0 {
632 t.Fatalf("symlink blob was salvaged: %+v", got)
633 }
634 data, err := os.ReadFile(target)
635 if err != nil || !strings.Contains(string(data), "external secret") {
636 t.Fatalf("external symlink target changed: %q err=%v", data, err)
637 }
638 }
639
640 func TestEnqueueRefusesSymlinkBlobsDirectory(t *testing.T) {
641 if runtime.GOOS == "windows" {
642 t.Skip("symlink creation may require elevated privileges")
643 }
644 session := filepath.Join(t.TempDir(), "s.jsonl")
645 s, err := Open(session, Limits{})
646 if err != nil {
647 t.Fatal(err)
648 }
649 target := t.TempDir()
650 if err := os.Symlink(target, filepath.Join(store.SessionInboxDir(session), blobsDirName)); err != nil {
651 t.Fatal(err)
652 }
653 if _, err := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "must stay scoped"}}); err == nil {
654 t.Fatal("enqueue accepted a symlink blobs directory")
655 }
656 entries, err := os.ReadDir(target)
657 if err != nil {
658 t.Fatal(err)
659 }
660 if len(entries) != 0 {
661 t.Fatalf("enqueue wrote through symlink: %+v", entries)
662 }
663 }
664
665 func TestFreezeRefsRejectsWorkspaceEscape(t *testing.T) {
666 ws := t.TempDir()
667 refs, err := FreezeRefs(context.Background(), ws, []string{"/etc/passwd"})
668 if err != nil {
669 t.Fatal(err)
670 }
671 if len(refs) != 1 {
672 t.Fatalf("want 1 ref, got %d", len(refs))
673 }
674 if !strings.Contains(string(refs[0].Content), "outside workspace") && !strings.Contains(string(refs[0].Content), "freeze failed") {
675 t.Fatalf("want workspace escape rejection, got %q", refs[0].Content)
676 }
677 }
678
679 func TestFreezeRefsRejectsSymlinkEscape(t *testing.T) {
680 ws := t.TempDir()
681 external := t.TempDir()
682 secret := filepath.Join(external, "secret.txt")
683 if err := os.WriteFile(secret, []byte("outside-secret"), 0o600); err != nil {
684 t.Fatal(err)
685 }
686 link := filepath.Join(ws, "linked-secret.txt")
687 if err := os.Symlink(secret, link); err != nil {
688 t.Skipf("symlink unsupported: %v", err)
689 }
690 refs, err := FreezeRefs(context.Background(), ws, []string{"linked-secret.txt"})
691 if err != nil {
692 t.Fatal(err)
693 }
694 if len(refs) != 1 {
695 t.Fatalf("want one blocked marker, got %d", len(refs))
696 }
697 if strings.Contains(string(refs[0].Content), "outside-secret") {
698 t.Fatal("workspace-local symlink leaked content from outside the workspace")
699 }
700 if !strings.Contains(string(refs[0].Content), "path escapes workspace") {
701 t.Fatalf("want symlink escape rejection, got %q", refs[0].Content)
702 }
703 }
704
705 func TestApplyFrozenRefsIsDeterministic(t *testing.T) {
706 bodies := map[string]string{
707 "z/file.txt": "z-body",
708 "a/file.txt": "a-body",
709 }
710 first := ApplyFrozenRefs("inspect refs", bodies)
711 for range 20 {
712 if got := ApplyFrozenRefs("inspect refs", bodies); got != first {
713 t.Fatalf("frozen reference serialization changed between calls:\n%s\n---\n%s", first, got)
714 }
715 }
716 if strings.Index(first, "@a/file.txt") > strings.Index(first, "@z/file.txt") {
717 t.Fatalf("frozen references are not sorted: %q", first)
718 }
719 }
720
721 func TestMoveAndPause(t *testing.T) {
722 dir := t.TempDir()
723 session := filepath.Join(dir, "s.jsonl")
724 _ = os.WriteFile(session, []byte("{}\n"), 0o644)
725 s, err := Open(session, Limits{})
726 if err != nil {
727 t.Fatal(err)
728 }
729 defer s.Close()
730 a, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "a"}})
731 b, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "b"}})
732 if err := s.MoveItem(b.ItemID, 0); err != nil {
733 t.Fatal(err)
734 }
735 items := s.Snapshot().Items
736 if items[0].ID != b.ItemID || items[1].ID != a.ItemID {
737 t.Fatalf("order = %v", items)
738 }
739 if err := s.SetPaused(true); err != nil {
740 t.Fatal(err)
741 }
742 if _, ok := s.NextQueued(); ok {
743 t.Fatal("paused inbox must not dispatch")
744 }
745 }
746
747 func TestDiscardPendingItemsIsScopedAndAtomic(t *testing.T) {
748 dir := t.TempDir()
749 session := filepath.Join(dir, "s.jsonl")
750 _ = os.WriteFile(session, []byte("{}\n"), 0o644)
751 s, err := Open(session, Limits{})
752 if err != nil {
753 t.Fatal(err)
754 }
755 defer s.Close()
756 a, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "a"}})
757 b, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "b"}})
758 c, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "c"}})
759
760 if err := s.SetState(b.ItemID, StateRunning, ""); err != nil {
761 t.Fatal(err)
762 }
763 if err := s.DiscardPendingItems([]string{a.ItemID, b.ItemID}); !errors.Is(err, ErrInvalidState) {
764 t.Fatalf("discard admitted item error = %v, want ErrInvalidState", err)
765 }
766 if got := len(s.Snapshot().Items); got != 3 {
767 t.Fatalf("failed batch discard changed manifest: got %d items", got)
768 }
769
770 if err := s.DiscardPendingItems([]string{a.ItemID, "already-consumed"}); err != nil {
771 t.Fatal(err)
772 }
773 items := s.Snapshot().Items
774 if len(items) != 2 || items[0].ID != b.ItemID || items[1].ID != c.ItemID {
775 t.Fatalf("scoped discard left items = %+v", items)
776 }
777 }
778
779 func TestDiscardPendingItemsEnforcesSourceOwnership(t *testing.T) {
780 dir := t.TempDir()
781 session := filepath.Join(dir, "s.jsonl")
782 _ = os.WriteFile(session, []byte("{}\n"), 0o644)
783 s, err := Open(session, Limits{})
784 if err != nil {
785 t.Fatal(err)
786 }
787 defer s.Close()
788 desktop, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "desktop"}, Source: "desktop"})
789 bot, _ := s.Enqueue(EnqueueRequest{Envelope: PromptEnvelope{SubmitText: "bot"}, Source: "bot"})
790 if err := s.SetState(bot.ItemID, StateRunning, ""); err != nil {
791 t.Fatal(err)
792 }
793 if err := s.DiscardPendingItemsOwned([]string{desktop.ItemID, bot.ItemID}, "desktop"); err != nil {
794 t.Fatal(err)
795 }
796 items := s.Snapshot().Items
797 if len(items) != 1 || items[0].ID != bot.ItemID || items[0].State != StateRunning {
798 t.Fatalf("source-scoped discard changed foreign work: %+v", items)
799 }
800 }
801
801 lines GO