/ internal / daemon / session_cwd_scenario_test.go
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  }