session_cwd_scenario_test.go
1 package daemon 2 3 import ( 4 "context" 5 "os" 6 "path/filepath" 7 "testing" 8 9 "github.com/Kocoro-lab/ShanClaw/internal/cwdctx" 10 ) 11 12 // Scenario: regression guard against the class of drift that produced the 13 // note.com Slack session incident. A cloud-routed request arrives with no 14 // CWD, the daemon allocates scratch under ~/.shannon/tmp/sessions/<id>/, 15 // wires it into the context, tools read/write against it, and session close 16 // reclaims the space. Any single hop failing here used to produce a 17 // 120-second `bash find` hang on the production session. 18 // 19 // Individual hops have their own unit tests (session_cwd_test.go for the 20 // allocator, internal/cwdctx for ResolveFilesystemPath, internal/tools for 21 // the MCP path-rewrite). This scenario pins down the integration between 22 // daemon allocator, cwdctx propagation, and session-close cleanup so future 23 // refactors cannot silently unlink them. 24 func TestScenario_CloudSessionCWD_EndToEnd(t *testing.T) { 25 shannonDir := t.TempDir() 26 const sessionID = "scenario-slack-session" 27 28 // 1) Allocator — the runner calls this when request/resumed/agent CWD 29 // are all empty and the source is cloud-routed. 30 cwd, err := ensureCloudSessionTmpDir(shannonDir, sessionID, "slack") 31 if err != nil || cwd == "" { 32 t.Fatalf("allocator failed: cwd=%q err=%v", cwd, err) 33 } 34 expected := filepath.Join(shannonDir, "tmp", "sessions", sessionID) 35 if cwd != expected { 36 t.Errorf("unexpected scratch path: got %q, want %q", cwd, expected) 37 } 38 39 // 2) Wire the scratch dir into a context the way runner.go does via 40 // cwdctx.WithSessionCWD. 41 ctx := cwdctx.WithSessionCWD(context.Background(), cwd) 42 43 // 3) Simulate a file-producing tool (browser_snapshot, screenshot, etc.) 44 // writing into the scratch dir. The MCP adapter's path rewrite (tested 45 // in internal/tools) is what guarantees the absolute path lands here; 46 // we short-circuit that hop and write directly so the scenario focuses 47 // on daemon-owned behavior. 48 absFile := filepath.Join(cwd, "note_editor.md") 49 if err := os.WriteFile(absFile, []byte("snapshot content"), 0o600); err != nil { 50 t.Fatalf("simulated tool write: %v", err) 51 } 52 53 // 4) Now a downstream agent call (file_read, bash, etc.) passes the 54 // same relative filename the model originally used. cwdctx must 55 // resolve it against the scratch dir rather than erroring with 56 // ErrNoSessionCWD — that error is the exact production failure mode 57 // this scenario guards against. 58 resolved, err := cwdctx.ResolveFilesystemPath(ctx, "note_editor.md") 59 if err != nil { 60 t.Fatalf("ResolveFilesystemPath on relative name failed: %v", err) 61 } 62 if resolved != absFile { 63 t.Errorf("resolver returned %q, want %q", resolved, absFile) 64 } 65 data, err := os.ReadFile(resolved) 66 if err != nil || string(data) != "snapshot content" { 67 t.Errorf("round-trip read mismatch: data=%q err=%v", data, err) 68 } 69 70 // 5) Session close. The cleanup callback is what sessMgr.OnSessionClose 71 // invokes on cache eviction / daemon shutdown. 72 cloudSessionTmpCleanup(cwd)() 73 if _, err := os.Stat(cwd); !os.IsNotExist(err) { 74 t.Fatalf("scratch dir still present after cleanup: %v", err) 75 } 76 77 // 6) A subsequent resume of the same session must re-allocate cleanly — 78 // the scratch is deliberately NOT persisted to sess.CWD, so the resume 79 // path re-creates the directory rather than looking up a dead value. 80 // This invariant is the PR's fix for the reviewer-flagged lifecycle bug 81 // (persisting a now-deleted path would break ValidateCWD on resume). 82 reallocated, err := ensureCloudSessionTmpDir(shannonDir, sessionID, "slack") 83 if err != nil || reallocated != cwd { 84 t.Errorf("resume re-alloc: got (%q, %v), want (%q, nil)", reallocated, err, cwd) 85 } 86 if _, err := os.Stat(reallocated); err != nil { 87 t.Errorf("re-allocated dir not usable: %v", err) 88 } 89 } 90 91 // Negative scenario: a non-cloud source (e.g. Desktop, CLI) that arrives 92 // with no CWD keeps the "no filesystem scope" contract — the allocator 93 // returns empty and filesystem tools enforce ErrNoSessionCWD. Regressing 94 // this would silently invent a working directory for every local request 95 // and poison the scope-checking behavior the filesystem tools rely on. 96 func TestScenario_NonCloudSource_KeepsNoScopeContract(t *testing.T) { 97 shannonDir := t.TempDir() 98 cwd, err := ensureCloudSessionTmpDir(shannonDir, "some-desktop-session", "desktop") 99 if err != nil { 100 t.Fatalf("unexpected error: %v", err) 101 } 102 if cwd != "" { 103 t.Fatalf("desktop source got an allocated scratch: %q", cwd) 104 } 105 106 // With no session CWD on ctx, relative-path filesystem resolution must 107 // fail loudly — no silent fallback to $HOME or the daemon process cwd. 108 ctx := context.Background() 109 if _, err := cwdctx.ResolveFilesystemPath(ctx, "anything.txt"); err == nil { 110 t.Error("ResolveFilesystemPath should refuse relative path without session CWD") 111 } 112 }