From b1fea70d8484ac160b22b18e97d3ad2cab2fd7b0 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 17 May 2026 15:00:18 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(web,gateway):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E4=BC=9A=E8=AF=9D=E5=88=87=E6=8D=A2=E7=AB=9E?= =?UTF-8?q?=E6=80=81=E5=B9=B6=E6=94=B6=E6=95=9B=E6=96=87=E4=BB=B6=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E5=B7=A5=E4=BD=9C=E5=8C=BA=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/tui-gateway-contract-matrix.md | 6 + internal/cli/gateway_runtime_bridge.go | 66 ++++- internal/cli/gateway_runtime_bridge_test.go | 227 ++++++++++++++---- web/package-lock.json | 98 ++++---- web/src/stores/useSessionStore.test.ts | 69 ++++++ web/src/stores/useSessionStore.ts | 22 +- web/src/stores/useWorkspaceStore.test.ts | 25 ++ web/src/stores/useWorkspaceStore.ts | 21 +- web/src/utils/eventBridge.test.ts | 59 +++++ web/src/utils/eventBridge.ts | 19 +- 10 files changed, 499 insertions(+), 113 deletions(-) diff --git a/docs/reference/tui-gateway-contract-matrix.md b/docs/reference/tui-gateway-contract-matrix.md index 6e62abac8..4df27ac80 100644 --- a/docs/reference/tui-gateway-contract-matrix.md +++ b/docs/reference/tui-gateway-contract-matrix.md @@ -64,3 +64,9 @@ Primary gateway codes used for UI mapping: - No multi-version payload decoding. - No alias method fallback. - No legacy field fallback in event payload. + +## Workspace Boundary For File Preview + +- For `gateway.listFiles`, `gateway.readFile`, `gateway.listGitDiffFiles`, and `gateway.readGitDiffFile`, server-side root resolution is always constrained by the current workspace boundary. +- Request-level `workdir` is kept for protocol compatibility, but runtime implementation does not trust it as an override root. +- When a stored session workdir is outside the current workspace root, the request is rejected with a controlled boundary error. diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go index d4cee138c..64b3c5762 100644 --- a/internal/cli/gateway_runtime_bridge.go +++ b/internal/cli/gateway_runtime_bridge.go @@ -1972,36 +1972,84 @@ func isRuntimeNotFoundError(err error) bool { return errors.Is(err, agentsession.ErrSessionNotFound) || errors.Is(err, os.ErrNotExist) } -// resolveListFilesRoot 按请求、会话、全局配置的优先级确定文件树根目录。 +// resolveListFilesRoot 解析文件预览根目录并强制收敛在当前工作区边界内。 +// 兼容保留请求中的 workdir 字段,但实现层不会信任该字段,避免客户端绕过边界。 func (b *gatewayRuntimePortBridge) resolveListFilesRoot( ctx context.Context, input gateway.ListFilesInput, ) (string, error) { - root := strings.TrimSpace(input.Workdir) - if root == "" && strings.TrimSpace(input.SessionID) != "" && b.sessionStore != nil { + workspaceRoot, err := b.resolveWorkspaceRootForFileAccess() + if err != nil { + return "", err + } + + root := workspaceRoot + if strings.TrimSpace(input.SessionID) != "" && b.sessionStore != nil { session, err := b.loadStoredSession(ctx, strings.TrimSpace(input.SessionID)) if err != nil && !isRuntimeNotFoundError(err) { return "", err } - root = strings.TrimSpace(session.Workdir) + sessionRoot := strings.TrimSpace(session.Workdir) + if sessionRoot != "" { + if !isPathWithinRoot(sessionRoot, workspaceRoot) { + return "", fmt.Errorf("gateway runtime bridge: session workdir escapes current workspace root") + } + root = sessionRoot + } } - if root == "" { - root = strings.TrimSpace(b.currentConfig().Workdir) + absolute, err := filepath.Abs(filepath.Clean(root)) + if err != nil { + return "", err } + return filepath.Clean(absolute), nil +} + +// resolveWorkspaceRootForFileAccess 返回当前工作区文件访问根目录。 +// 优先使用 runtime 配置中的 workdir,缺失时回退到当前进程工作目录。 +func (b *gatewayRuntimePortBridge) resolveWorkspaceRootForFileAccess() (string, error) { + root := strings.TrimSpace(b.currentConfig().Workdir) if root == "" { - var err error - root, err = os.Getwd() + cwd, err := os.Getwd() if err != nil { return "", err } + root = cwd } - absolute, err := filepath.Abs(root) + absolute, err := filepath.Abs(filepath.Clean(root)) if err != nil { return "", err } return filepath.Clean(absolute), nil } +// isPathWithinRoot 判断 candidate 是否位于 root 目录内(含自身),同时处理符号链接场景。 +func isPathWithinRoot(candidate string, root string) bool { + candidateAbs, err := filepath.Abs(filepath.Clean(candidate)) + if err != nil { + return false + } + rootAbs, err := filepath.Abs(filepath.Clean(root)) + if err != nil { + return false + } + candidateForCheck := candidateAbs + if resolvedCandidate, resolveErr := filepath.EvalSymlinks(candidateAbs); resolveErr == nil { + candidateForCheck = resolvedCandidate + } + rootForCheck := rootAbs + if resolvedRoot, resolveErr := filepath.EvalSymlinks(rootAbs); resolveErr == nil { + rootForCheck = resolvedRoot + } + relative, err := filepath.Rel(rootForCheck, candidateForCheck) + if err != nil { + return false + } + if relative == "." { + return true + } + return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) +} + // loadStoredSession 通过可选的会话加载接口读取持久会话。 func (b *gatewayRuntimePortBridge) loadStoredSession(ctx context.Context, sessionID string) (agentsession.Session, error) { if b == nil || b.sessionStore == nil { diff --git a/internal/cli/gateway_runtime_bridge_test.go b/internal/cli/gateway_runtime_bridge_test.go index a7395649d..fc381308b 100644 --- a/internal/cli/gateway_runtime_bridge_test.go +++ b/internal/cli/gateway_runtime_bridge_test.go @@ -17,6 +17,7 @@ import ( configstate "neo-code/internal/config/state" "neo-code/internal/gateway" providertypes "neo-code/internal/provider/types" + "neo-code/internal/repository" agentruntime "neo-code/internal/runtime" agentsession "neo-code/internal/session" "neo-code/internal/skills" @@ -1791,48 +1792,47 @@ func TestResolveListFilesRootPriorities(t *testing.T) { t.Fatalf("mkdir: %v", err) } - // priority 1: input.Workdir - bridge1, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) - defer bridge1.Close() - root, err := bridge1.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{Workdir: subDir}) - if err != nil { - t.Fatalf("resolve with workdir: %v", err) - } - if root != subDir { - t.Fatalf("root = %q, want %q", root, subDir) - } - - // priority 2: session workdir (store implements bridgeSessionLoader) + // priority 1: session workdir (must stay inside current workspace root) loaderStore := &bridgeSessionStoreWithLoader{ bridgeSessionStoreStub: bridgeSessionStoreStub{}, session: agentsession.Session{Workdir: subDir}, } - bridge2, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore) - defer bridge2.Close() - root, err = bridge2.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{SessionID: "s-1"}) + cfgMgr1 := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge1, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore, cfgMgr1, nil) + defer bridge1.Close() + root, err := bridge1.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{SessionID: "s-1"}) if err != nil { t.Fatalf("resolve with session: %v", err) } - if root != subDir { - t.Fatalf("root = %q, want %q", root, subDir) + if root != filepath.Clean(subDir) { + t.Fatalf("root = %q, want %q", root, filepath.Clean(subDir)) } - // priority 3: config workdir - cfgMgr := &configManagerStub{cfg: config.Config{Workdir: subDir}} - bridge3, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) - defer bridge3.Close() - root, err = bridge3.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{}) + // priority 2: config workdir + cfgMgr2 := &configManagerStub{cfg: config.Config{Workdir: subDir}} + bridge2, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr2, nil) + defer bridge2.Close() + root, err = bridge2.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{}) if err != nil { t.Fatalf("resolve with config: %v", err) } - if root != subDir { - t.Fatalf("root = %q, want %q", root, subDir) + if root != filepath.Clean(subDir) { + t.Fatalf("root = %q, want %q", root, filepath.Clean(subDir)) } - // priority 4: os.Getwd - bridge4, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) - defer bridge4.Close() - root, err = bridge4.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{}) + // input.Workdir should be ignored and not override workspace root + root, err = bridge2.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{Workdir: t.TempDir()}) + if err != nil { + t.Fatalf("resolve with ignored workdir: %v", err) + } + if root != filepath.Clean(subDir) { + t.Fatalf("root = %q, want %q", root, filepath.Clean(subDir)) + } + + // priority 3: os.Getwd fallback + bridge3, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + defer bridge3.Close() + root, err = bridge3.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{}) if err != nil { t.Fatalf("resolve with getwd: %v", err) } @@ -1858,27 +1858,45 @@ func TestResolveListFilesRootSessionNotFound(t *testing.T) { bridgeSessionStoreStub: bridgeSessionStoreStub{}, loadErr: agentsession.ErrSessionNotFound, } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore) + cfgRoot := t.TempDir() + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: cfgRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore, cfgMgr, nil) defer bridge.Close() root, err := bridge.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{SessionID: "s-1"}) if err != nil { t.Fatalf("resolve with not-found session should not error: %v", err) } - wd, _ := os.Getwd() - absWd, _ := filepath.Abs(wd) - if root != filepath.Clean(absWd) { - t.Fatalf("root = %q, want %q", root, filepath.Clean(absWd)) + if root != filepath.Clean(cfgRoot) { + t.Fatalf("root = %q, want %q", root, filepath.Clean(cfgRoot)) + } +} + +func TestResolveListFilesRootRejectsSessionWorkdirEscapingWorkspaceRoot(t *testing.T) { + workspaceRoot := t.TempDir() + outsideRoot := t.TempDir() + loaderStore := &bridgeSessionStoreWithLoader{ + bridgeSessionStoreStub: bridgeSessionStoreStub{}, + session: agentsession.Session{Workdir: outsideRoot}, + } + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: workspaceRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore, cfgMgr, nil) + defer bridge.Close() + + _, err := bridge.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{SessionID: "s-1"}) + if err == nil || !strings.Contains(err.Error(), "escapes current workspace root") { + t.Fatalf("expected workspace boundary error, got %v", err) } } func TestGatewayRuntimePortBridgeListFilesReadDirFail(t *testing.T) { - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgRoot := t.TempDir() + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: cfgRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() _, err := bridge.ListFiles(context.Background(), gateway.ListFilesInput{ SubjectID: testBridgeSubjectID, - Workdir: t.TempDir(), Path: "nonexistent-dir", }) if err == nil { @@ -1894,12 +1912,12 @@ func TestGatewayRuntimePortBridgeListFilesFiltersAndSorts(t *testing.T) { _ = os.MkdirAll(filepath.Join(tmpDir, "Zdir"), 0755) _ = os.MkdirAll(filepath.Join(tmpDir, "adir"), 0755) - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() entries, err := bridge.ListFiles(context.Background(), gateway.ListFilesInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, }) if err != nil { t.Fatalf("list files: %v", err) @@ -1922,6 +1940,32 @@ func TestGatewayRuntimePortBridgeListFilesFiltersAndSorts(t *testing.T) { } } +func TestGatewayRuntimePortBridgeListFilesIgnoresInputWorkdir(t *testing.T) { + workspaceRoot := t.TempDir() + outsideRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(workspaceRoot, "inside.txt"), []byte("inside"), 0644); err != nil { + t.Fatalf("write inside file: %v", err) + } + if err := os.WriteFile(filepath.Join(outsideRoot, "outside.txt"), []byte("outside"), 0644); err != nil { + t.Fatalf("write outside file: %v", err) + } + + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: workspaceRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) + defer bridge.Close() + + entries, err := bridge.ListFiles(context.Background(), gateway.ListFilesInput{ + SubjectID: testBridgeSubjectID, + Workdir: outsideRoot, + }) + if err != nil { + t.Fatalf("ListFiles() error = %v", err) + } + if len(entries) != 1 || entries[0].Name != "inside.txt" { + t.Fatalf("entries = %+v, want only inside.txt from workspace root", entries) + } +} + func TestGatewayRuntimePortBridgeListGitDiffFilesExpandsUntrackedDirectory(t *testing.T) { tmpDir := t.TempDir() runGitTestCommand(t, tmpDir, "init") @@ -1939,12 +1983,12 @@ func TestGatewayRuntimePortBridgeListGitDiffFilesExpandsUntrackedDirectory(t *te t.Fatalf("write b.txt: %v", err) } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() result, err := bridge.ListGitDiffFiles(context.Background(), gateway.ListGitDiffFilesInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, }) if err != nil { t.Fatalf("ListGitDiffFiles() error = %v", err) @@ -1959,6 +2003,64 @@ func TestGatewayRuntimePortBridgeListGitDiffFilesExpandsUntrackedDirectory(t *te } } +func TestGatewayRuntimePortBridgeListGitDiffFilesIgnoresInputWorkdir(t *testing.T) { + workspaceRoot := t.TempDir() + runGitTestCommand(t, workspaceRoot, "init") + runGitTestCommand(t, workspaceRoot, "config", "user.name", "NeoCode Test") + runGitTestCommand(t, workspaceRoot, "config", "user.email", "test@example.com") + runGitTestCommand(t, workspaceRoot, "commit", "--allow-empty", "-m", "init") + if err := os.WriteFile(filepath.Join(workspaceRoot, "changed.txt"), []byte("x\n"), 0644); err != nil { + t.Fatalf("write changed file: %v", err) + } + + outsideRoot := t.TempDir() + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: workspaceRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) + defer bridge.Close() + + result, err := bridge.ListGitDiffFiles(context.Background(), gateway.ListGitDiffFilesInput{ + SubjectID: testBridgeSubjectID, + Workdir: outsideRoot, + }) + if err != nil { + t.Fatalf("ListGitDiffFiles() error = %v", err) + } + if !result.InGitRepo { + t.Fatalf("expected workspace root repo to be used, got non-repo result: %+v", result) + } + if result.TotalCount != 1 || len(result.Files) != 1 || result.Files[0].Path != "changed.txt" { + t.Fatalf("unexpected git diff result: %+v", result) + } +} + +func TestGatewayRuntimePortBridgeReadGitDiffFileIgnoresInputWorkdir(t *testing.T) { + workspaceRoot := t.TempDir() + runGitTestCommand(t, workspaceRoot, "init") + runGitTestCommand(t, workspaceRoot, "config", "user.name", "NeoCode Test") + runGitTestCommand(t, workspaceRoot, "config", "user.email", "test@example.com") + runGitTestCommand(t, workspaceRoot, "commit", "--allow-empty", "-m", "init") + if err := os.WriteFile(filepath.Join(workspaceRoot, "changed.txt"), []byte("line-1\n"), 0644); err != nil { + t.Fatalf("write changed file: %v", err) + } + + outsideRoot := t.TempDir() + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: workspaceRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) + defer bridge.Close() + + result, err := bridge.ReadGitDiffFile(context.Background(), gateway.ReadGitDiffFileInput{ + SubjectID: testBridgeSubjectID, + Workdir: outsideRoot, + Path: "changed.txt", + }) + if err != nil { + t.Fatalf("ReadGitDiffFile() error = %v", err) + } + if result.Path != "changed.txt" || result.Status != string(repository.StatusUntracked) { + t.Fatalf("unexpected read git diff result: %+v", result) + } +} + func runGitTestCommand(t *testing.T, workdir string, args ...string) { t.Helper() command := exec.Command("git", append([]string{"-C", workdir}, args...)...) @@ -1975,12 +2077,12 @@ func TestGatewayRuntimePortBridgeReadFileSuccess(t *testing.T) { t.Fatalf("write file: %v", err) } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() result, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, Path: "main.go", }) if err != nil { @@ -1997,18 +2099,45 @@ func TestGatewayRuntimePortBridgeReadFileSuccess(t *testing.T) { } } +func TestGatewayRuntimePortBridgeReadFileIgnoresInputWorkdir(t *testing.T) { + workspaceRoot := t.TempDir() + outsideRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(workspaceRoot, "main.go"), []byte("package main\n"), 0644); err != nil { + t.Fatalf("write workspace file: %v", err) + } + if err := os.WriteFile(filepath.Join(outsideRoot, "main.go"), []byte("package outside\n"), 0644); err != nil { + t.Fatalf("write outside file: %v", err) + } + + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: workspaceRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) + defer bridge.Close() + + result, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ + SubjectID: testBridgeSubjectID, + Workdir: outsideRoot, + Path: "main.go", + }) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if result.Content != "package main\n" { + t.Fatalf("content = %q, want workspace file content", result.Content) + } +} + func TestGatewayRuntimePortBridgeReadFileRejectsDirectory(t *testing.T) { tmpDir := t.TempDir() if err := os.MkdirAll(filepath.Join(tmpDir, "dir"), 0755); err != nil { t.Fatalf("mkdir: %v", err) } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() _, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, Path: "dir", }) if err == nil || !strings.Contains(err.Error(), "is a directory") { @@ -2019,12 +2148,12 @@ func TestGatewayRuntimePortBridgeReadFileRejectsDirectory(t *testing.T) { func TestGatewayRuntimePortBridgeReadFileRejectsEscapedPath(t *testing.T) { tmpDir := t.TempDir() - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() _, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, Path: "../secret.txt", }) if err == nil || !strings.Contains(err.Error(), "escapes workdir") { @@ -2040,12 +2169,12 @@ func TestGatewayRuntimePortBridgeReadFileTruncatesLargeFile(t *testing.T) { t.Fatalf("write file: %v", err) } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() result, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, Path: "large.txt", }) if err != nil { @@ -2063,12 +2192,12 @@ func TestGatewayRuntimePortBridgeReadFileMarksBinaryContent(t *testing.T) { t.Fatalf("write file: %v", err) } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() result, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, Path: "bin.dat", }) if err != nil { diff --git a/web/package-lock.json b/web/package-lock.json index 4365c0fb2..3ccdb3a69 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -144,7 +144,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -560,7 +559,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -609,7 +607,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -2426,7 +2423,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2828,7 +2826,6 @@ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "*" } @@ -2874,7 +2871,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2886,7 +2882,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2993,7 +2988,6 @@ "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.5", @@ -3199,7 +3193,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3366,6 +3359,7 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -3385,6 +3379,7 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -3407,6 +3402,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -3422,7 +3418,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", @@ -3430,6 +3427,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -3685,7 +3683,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4056,7 +4053,6 @@ "resolved": "https://registry.npmmirror.com/chevrotain/-/chevrotain-12.0.0.tgz", "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", @@ -4292,6 +4288,7 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -4432,6 +4429,7 @@ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -4445,6 +4443,7 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -4501,7 +4500,6 @@ "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.3.tgz", "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -4902,7 +4900,6 @@ "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -5268,7 +5265,6 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -5351,7 +5347,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.4.2", @@ -5436,7 +5433,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^20.9.0", @@ -5481,6 +5477,7 @@ "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "archiver": "^5.3.1", @@ -5494,6 +5491,7 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -5509,6 +5507,7 @@ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -5522,6 +5521,7 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -6090,7 +6090,8 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fs-extra": { "version": "8.1.0", @@ -6421,7 +6422,6 @@ "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -7088,7 +7088,8 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/isbinaryfile": { "version": "5.0.7", @@ -7396,6 +7397,7 @@ "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -7409,6 +7411,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -7424,7 +7427,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", @@ -7432,6 +7436,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -7454,14 +7459,16 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -7474,7 +7481,8 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -7488,14 +7496,16 @@ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/log-symbols": { "version": "4.1.0", @@ -7571,6 +7581,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8097,7 +8108,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -8715,8 +8725,7 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/mime": { "version": "2.6.0", @@ -8938,8 +8947,7 @@ "version": "0.52.2", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ms": { "version": "2.1.3", @@ -9101,6 +9109,7 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9492,6 +9501,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9507,6 +9517,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -9519,7 +9530,8 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/progress": { "version": "2.0.3", @@ -9601,7 +9613,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9614,7 +9625,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9628,7 +9638,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.17.0", @@ -9706,6 +9717,7 @@ "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -9715,7 +9727,8 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/readdir-glob/node_modules/brace-expansion": { "version": "2.1.0", @@ -9723,6 +9736,7 @@ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -9733,6 +9747,7 @@ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -10683,6 +10698,7 @@ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -10823,7 +10839,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10996,7 +11011,6 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -11247,7 +11261,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -11337,8 +11350,7 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -11364,7 +11376,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11378,7 +11389,6 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -11805,6 +11815,7 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -11820,6 +11831,7 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", diff --git a/web/src/stores/useSessionStore.test.ts b/web/src/stores/useSessionStore.test.ts index e86be9c36..ed2bdba6b 100644 --- a/web/src/stores/useSessionStore.test.ts +++ b/web/src/stores/useSessionStore.test.ts @@ -168,6 +168,25 @@ describe('useSessionStore', () => { expect(useChatStore.getState().messages[0].role).toBe('user') }) + it('switchSession keeps transitioning true until loadSession finishes', async () => { + const mockBindStream = vi.fn().mockResolvedValue({}) + let resolveLoad!: (value: any) => void + const mockLoadSession = vi.fn().mockImplementation( + () => new Promise((resolve) => { resolveLoad = resolve }), + ) + const mockAPI = { bindStream: mockBindStream, loadSession: mockLoadSession } as any + + const switchPromise = useSessionStore.getState().switchSession('sess-2', mockAPI) + await Promise.resolve() + + expect(useChatStore.getState().isTransitioning).toBe(true) + + resolveLoad({ payload: { messages: [] } }) + await switchPromise + + expect(useChatStore.getState().isTransitioning).toBe(false) + }) + it('fetchSessions auto-selects first session and binds stream', async () => { const setMessagesSpy = vi.spyOn(useChatStore.getState(), 'setMessages') const addMessageSpy = vi.spyOn(useChatStore.getState(), 'addMessage') @@ -217,6 +236,56 @@ describe('useSessionStore', () => { expect(mockBindStream).not.toHaveBeenCalled() }) + it('fetchSessions ignores stale late response from an older request', async () => { + let resolveFirst!: (value: any) => void + let resolveSecond!: (value: any) => void + const mockListSessions = vi + .fn() + .mockImplementationOnce( + () => new Promise((resolve) => { resolveFirst = resolve }), + ) + .mockImplementationOnce( + () => new Promise((resolve) => { resolveSecond = resolve }), + ) + const mockAPI = { + listSessions: mockListSessions, + bindStream: vi.fn().mockResolvedValue({}), + loadSession: vi.fn().mockResolvedValue({ payload: { messages: [] } }), + } as any + + useSessionStore.setState({ currentSessionId: 'sess-keep' }) + + const firstRequest = useSessionStore.getState().fetchSessions(mockAPI, true) + const secondRequest = useSessionStore.getState().fetchSessions(mockAPI, true) + + resolveSecond({ + payload: { + sessions: [{ + id: 'sess-new', + title: 'New', + created_at: '2026-05-10T01:00:00Z', + updated_at: '2026-05-10T01:00:00Z', + }], + }, + }) + await secondRequest + + resolveFirst({ + payload: { + sessions: [{ + id: 'sess-old', + title: 'Old', + created_at: '2026-05-09T01:00:00Z', + updated_at: '2026-05-09T01:00:00Z', + }], + }, + }) + await firstRequest + + const sessions = useSessionStore.getState().projects.flatMap((project) => project.sessions) + expect(sessions.map((session) => session.id)).toEqual(['sess-new']) + }) + it('fetchSessions uses the newer of created_at/updated_at as display time', async () => { const mockListSessions = vi.fn().mockResolvedValue({ payload: { diff --git a/web/src/stores/useSessionStore.ts b/web/src/stores/useSessionStore.ts index 9018e7e00..5a45dd439 100644 --- a/web/src/stores/useSessionStore.ts +++ b/web/src/stores/useSessionStore.ts @@ -261,6 +261,7 @@ export async function reloadSessionAfterCheckpointRestore( } let _fetchSessionsPromise: Promise | null = null +let _fetchSessionsSeq = 0 export const useSessionStore = create((set, get) => ({ projects: [], @@ -295,10 +296,10 @@ export const useSessionStore = create((set, get) => ({ const prevSessionId = get().currentSessionId try { - // 1. Set transitioning flag and clear messages FIRST (before bindStream) + // 1. Clear messages first, then enter transitioning state to keep event drop window effective const chatStore = useChatStore.getState() - chatStore.setTransitioning(true) chatStore.clearMessages() + chatStore.setTransitioning(true) useRuntimeInsightStore.getState().reset() useUIStore.getState().clearCheckpointRollbackUndo() @@ -379,6 +380,7 @@ export const useSessionStore = create((set, get) => ({ resetForWorkspaceSwitch: () => { _fetchSessionsPromise = null + _fetchSessionsSeq += 1 set({ _initialBindDone: false, loading: false }) }, @@ -393,24 +395,29 @@ export const useSessionStore = create((set, get) => ({ // 去重:若已有 fetch 在进行中,复用同一 promise(force 跳过去重) if (!force && _fetchSessionsPromise) return _fetchSessionsPromise - _fetchSessionsPromise = (async () => { + const requestSeq = ++_fetchSessionsSeq + const fetchPromise = (async () => { set({ loading: true }) try { const result = await gatewayAPI.listSessions() + if (requestSeq !== _fetchSessionsSeq) return const sessions = result.payload.sessions const projects = mapSessionsToProjects(sessions) set({ projects, loading: false }) const state = get() + if (requestSeq !== _fetchSessionsSeq) return if (!isValidSessionId(state.currentSessionId) && sessions.length > 0) { const firstSession = sessions[0] set({ currentSessionId: firstSession.id }) try { await gatewayAPI.bindStream({ session_id: firstSession.id, channel: 'all' }) + if (requestSeq !== _fetchSessionsSeq) return set({ _initialBindDone: true }) // Load historical messages for the auto-selected session (concurrently fetch todos + runtime snapshot) const sessionFrame = await loadSessionWithInsights(gatewayAPI, firstSession.id) + if (requestSeq !== _fetchSessionsSeq) return const sessionData = sessionFrame.payload as { messages?: BackendMessage[]; agent_mode?: string } if (sessionData.messages && sessionData.messages.length > 0) { const mapped = mapHistoryMessages(sessionData.messages) @@ -419,18 +426,23 @@ export const useSessionStore = create((set, get) => ({ const restoredMode = sessionData.agent_mode === 'plan' ? 'plan' : 'build' useChatStore.getState().setAgentMode(restoredMode) } catch (err) { + if (requestSeq !== _fetchSessionsSeq) return console.error('Auto bindStream or loadSession failed:', err) useUIStore.getState().showToast('Failed to load session', 'error') } } } catch (err) { + if (requestSeq !== _fetchSessionsSeq) return console.error('fetchSessions failed:', err) set({ projects: [], loading: false }) } finally { - _fetchSessionsPromise = null + if (requestSeq === _fetchSessionsSeq) { + _fetchSessionsPromise = null + } } })() - return _fetchSessionsPromise + _fetchSessionsPromise = fetchPromise + return fetchPromise }, })) diff --git a/web/src/stores/useWorkspaceStore.test.ts b/web/src/stores/useWorkspaceStore.test.ts index a55103e3b..c3d21664f 100644 --- a/web/src/stores/useWorkspaceStore.test.ts +++ b/web/src/stores/useWorkspaceStore.test.ts @@ -88,6 +88,31 @@ describe('useWorkspaceStore', () => { expect(useWorkspaceStore.getState().currentWorkspaceHash).toBe('w2') }) + it('switchWorkspace ignores stale late response from an older switch request', async () => { + let resolveA!: () => void + let resolveB!: () => void + const gatewayAPI = { + switchWorkspace: vi + .fn() + .mockImplementationOnce(() => new Promise((resolve) => { resolveA = resolve })) + .mockImplementationOnce(() => new Promise((resolve) => { resolveB = resolve })), + } as any + const fetchSessions = useSessionStore.getState().fetchSessions as any + + const switchA = useWorkspaceStore.getState().switchWorkspace('wA', gatewayAPI) + const switchB = useWorkspaceStore.getState().switchWorkspace('wB', gatewayAPI) + + resolveB() + await switchB + expect(useWorkspaceStore.getState().currentWorkspaceHash).toBe('wB') + + resolveA() + await switchA + expect(useWorkspaceStore.getState().currentWorkspaceHash).toBe('wB') + expect(fetchSessions).toHaveBeenCalledTimes(1) + expect(fetchSessions).toHaveBeenCalledWith(gatewayAPI, true) + }) + it('createWorkspace failure reports toast', async () => { const showToast = vi.fn() useUIStore.setState({ showToast } as any) diff --git a/web/src/stores/useWorkspaceStore.ts b/web/src/stores/useWorkspaceStore.ts index de94e148c..ff369c980 100644 --- a/web/src/stores/useWorkspaceStore.ts +++ b/web/src/stores/useWorkspaceStore.ts @@ -42,6 +42,7 @@ function mapAPIWorkspace(w: APIWorkspace): Workspace { } let _fetchWorkspacesPromise: Promise | null = null +let _workspaceSwitchSeq = 0 export const useWorkspaceStore = create((set, get) => ({ workspaces: [], @@ -88,6 +89,7 @@ export const useWorkspaceStore = create((set, get) => ({ } set({ loading: true }) + const switchSeq = ++_workspaceSwitchSeq // 先清空所有前端状态(防止重连 handler 读到旧 sessionId 竞态) useChatStore.getState().clearMessages() @@ -105,17 +107,21 @@ export const useWorkspaceStore = create((set, get) => ({ try { await gatewayAPI.switchWorkspace(hash) + if (switchSeq !== _workspaceSwitchSeq) return set({ currentWorkspaceHash: hash }) useGatewayStore.getState().notifyProviderChanged() // 加载新工作区的会话列表 await useSessionStore.getState().fetchSessions(gatewayAPI, true) } catch (err) { + if (switchSeq !== _workspaceSwitchSeq) return console.error('switchWorkspace failed:', err) useUIStore.getState().showToast('Failed to switch workspace', 'error') } finally { - useChatStore.getState().setTransitioning(false) - set({ loading: false }) + if (switchSeq === _workspaceSwitchSeq) { + useChatStore.getState().setTransitioning(false) + set({ loading: false }) + } } }, @@ -126,6 +132,7 @@ export const useWorkspaceStore = create((set, get) => ({ } set({ loading: true }) + const switchSeq = ++_workspaceSwitchSeq // 先清空所有前端状态(与 switchWorkspace 保持一致) useChatStore.getState().clearMessages() @@ -143,6 +150,7 @@ export const useWorkspaceStore = create((set, get) => ({ try { const result = await gatewayAPI.createWorkspace(path, name) + if (switchSeq !== _workspaceSwitchSeq) return const w = mapAPIWorkspace(result.payload.workspace) set((state) => ({ workspaces: [w, ...state.workspaces.filter((x) => x.hash !== w.hash)], @@ -150,19 +158,24 @@ export const useWorkspaceStore = create((set, get) => ({ // 通知后端切换到新工作区 await gatewayAPI.switchWorkspace(w.hash) + if (switchSeq !== _workspaceSwitchSeq) return set({ currentWorkspaceHash: w.hash }) useGatewayStore.getState().notifyProviderChanged() // 加载新工作区的会话列表 await useSessionStore.getState().fetchSessions(gatewayAPI, true) + if (switchSeq !== _workspaceSwitchSeq) return useUIStore.getState().showToast('Workspace created', 'success') } catch (err) { + if (switchSeq !== _workspaceSwitchSeq) return console.error('createWorkspace failed:', err) set({ loading: false }) useUIStore.getState().showToast('Failed to create workspace', 'error') } finally { - useChatStore.getState().setTransitioning(false) - set({ loading: false }) + if (switchSeq === _workspaceSwitchSeq) { + useChatStore.getState().setTransitioning(false) + set({ loading: false }) + } } }, diff --git a/web/src/utils/eventBridge.test.ts b/web/src/utils/eventBridge.test.ts index 55df0530e..ea7ad70aa 100644 --- a/web/src/utils/eventBridge.test.ts +++ b/web/src/utils/eventBridge.test.ts @@ -102,6 +102,65 @@ describe("eventBridge", () => { expect(useChatStore.getState().messages[0].content).toBe("Hello"); }); + it("drops stale session events after session switch for tool and chunk updates", () => { + const api = createMockGatewayAPI(); + useSessionStore.setState({ currentSessionId: "sess-new" } as any); + + handleGatewayEvent( + { + type: EventType.ToolStart, + payload: { + payload: { + runtime_event_type: EventType.ToolStart, + payload: { + name: "filesystem_write_file", + id: "tc-old", + arguments: '{"path":"stale.txt"}', + }, + }, + }, + session_id: "sess-old", + run_id: "run-old", + }, + api, + ); + + handleGatewayEvent( + { + type: EventType.ToolDiff, + payload: { + payload: { + runtime_event_type: EventType.ToolDiff, + payload: { + tool_name: "filesystem_write_file", + file_path: "stale.txt", + diff: "--- a/stale.txt\n+++ b/stale.txt\n@@ -0,0 +1 @@\n+old\n", + was_new: true, + }, + }, + }, + session_id: "sess-old", + run_id: "run-old", + }, + api, + ); + + handleGatewayEvent( + { + type: EventType.AgentChunk, + payload: { + payload: { runtime_event_type: EventType.AgentChunk, payload: "stale chunk" }, + }, + session_id: "sess-old", + run_id: "run-old", + }, + api, + ); + + expect(useChatStore.getState().messages).toHaveLength(0); + expect(useUIStore.getState().fileChanges).toHaveLength(0); + }); + it("AgentDone finalizes message from parts array", () => { const api = createMockGatewayAPI(); const store = useChatStore.getState(); diff --git a/web/src/utils/eventBridge.ts b/web/src/utils/eventBridge.ts index 24ed94f17..142f9011b 100644 --- a/web/src/utils/eventBridge.ts +++ b/web/src/utils/eventBridge.ts @@ -538,6 +538,10 @@ function normalizeUserQuestionRequestedPayload( } const CRITICAL_EVENTS = new Set([EventType.Error]); +const SESSION_AGNOSTIC_EVENTS = new Set([ + EventType.Error, + EventType.InputNormalized, +]); function strField(payload: unknown, key: string): string { return ((payload as PayloadRecord)?.[key] as string) ?? ""; @@ -632,6 +636,8 @@ export function handleGatewayEvent( (innerEnvelope?.runtime_event_type as string | undefined) ?? (payload.event_type as string | undefined); if (!eventType) return; + const frameSessionId = (frame.session_id || "").trim(); + const frameRunId = frame.run_id; // Discard non-critical events during workspace transition to avoid stale data // Only Error events are allowed through during transition @@ -642,6 +648,16 @@ export function handleGatewayEvent( return; } + const currentSessionId = useSessionStore.getState().currentSessionId.trim(); + if ( + frameSessionId && + currentSessionId && + frameSessionId !== currentSessionId && + !SESSION_AGNOSTIC_EVENTS.has(eventType) + ) { + return; + } + const eventPayload = innerEnvelope?.payload; const chatStore = useChatStore.getState(); @@ -649,9 +665,6 @@ export function handleGatewayEvent( const gwStore = useGatewayStore.getState(); const insightStore = useRuntimeInsightStore.getState(); - const frameSessionId = frame.session_id; - const frameRunId = frame.run_id; - /** 更新最新 verification 消息的 data 为 insightStore 当前最后一条 record */ function syncLatestVerificationToChat() { const history = useRuntimeInsightStore.getState().verificationHistory; From 0471e54c17124386cb5ff291301c4efbd7acaf88 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 17 May 2026 15:00:18 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(web,gateway):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E4=BC=9A=E8=AF=9D=E5=88=87=E6=8D=A2=E7=AB=9E?= =?UTF-8?q?=E6=80=81=E5=B9=B6=E6=94=B6=E6=95=9B=E6=96=87=E4=BB=B6=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E5=B7=A5=E4=BD=9C=E5=8C=BA=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/tui-gateway-contract-matrix.md | 6 + internal/cli/gateway_runtime_bridge.go | 66 ++++- internal/cli/gateway_runtime_bridge_test.go | 227 ++++++++++++++---- web/package-lock.json | 98 ++++---- web/src/stores/useSessionStore.test.ts | 69 ++++++ web/src/stores/useSessionStore.ts | 22 +- web/src/stores/useWorkspaceStore.test.ts | 25 ++ web/src/stores/useWorkspaceStore.ts | 21 +- web/src/utils/eventBridge.test.ts | 59 +++++ web/src/utils/eventBridge.ts | 19 +- 10 files changed, 499 insertions(+), 113 deletions(-) diff --git a/docs/reference/tui-gateway-contract-matrix.md b/docs/reference/tui-gateway-contract-matrix.md index 6e62abac8..4df27ac80 100644 --- a/docs/reference/tui-gateway-contract-matrix.md +++ b/docs/reference/tui-gateway-contract-matrix.md @@ -64,3 +64,9 @@ Primary gateway codes used for UI mapping: - No multi-version payload decoding. - No alias method fallback. - No legacy field fallback in event payload. + +## Workspace Boundary For File Preview + +- For `gateway.listFiles`, `gateway.readFile`, `gateway.listGitDiffFiles`, and `gateway.readGitDiffFile`, server-side root resolution is always constrained by the current workspace boundary. +- Request-level `workdir` is kept for protocol compatibility, but runtime implementation does not trust it as an override root. +- When a stored session workdir is outside the current workspace root, the request is rejected with a controlled boundary error. diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go index d4cee138c..64b3c5762 100644 --- a/internal/cli/gateway_runtime_bridge.go +++ b/internal/cli/gateway_runtime_bridge.go @@ -1972,36 +1972,84 @@ func isRuntimeNotFoundError(err error) bool { return errors.Is(err, agentsession.ErrSessionNotFound) || errors.Is(err, os.ErrNotExist) } -// resolveListFilesRoot 按请求、会话、全局配置的优先级确定文件树根目录。 +// resolveListFilesRoot 解析文件预览根目录并强制收敛在当前工作区边界内。 +// 兼容保留请求中的 workdir 字段,但实现层不会信任该字段,避免客户端绕过边界。 func (b *gatewayRuntimePortBridge) resolveListFilesRoot( ctx context.Context, input gateway.ListFilesInput, ) (string, error) { - root := strings.TrimSpace(input.Workdir) - if root == "" && strings.TrimSpace(input.SessionID) != "" && b.sessionStore != nil { + workspaceRoot, err := b.resolveWorkspaceRootForFileAccess() + if err != nil { + return "", err + } + + root := workspaceRoot + if strings.TrimSpace(input.SessionID) != "" && b.sessionStore != nil { session, err := b.loadStoredSession(ctx, strings.TrimSpace(input.SessionID)) if err != nil && !isRuntimeNotFoundError(err) { return "", err } - root = strings.TrimSpace(session.Workdir) + sessionRoot := strings.TrimSpace(session.Workdir) + if sessionRoot != "" { + if !isPathWithinRoot(sessionRoot, workspaceRoot) { + return "", fmt.Errorf("gateway runtime bridge: session workdir escapes current workspace root") + } + root = sessionRoot + } } - if root == "" { - root = strings.TrimSpace(b.currentConfig().Workdir) + absolute, err := filepath.Abs(filepath.Clean(root)) + if err != nil { + return "", err } + return filepath.Clean(absolute), nil +} + +// resolveWorkspaceRootForFileAccess 返回当前工作区文件访问根目录。 +// 优先使用 runtime 配置中的 workdir,缺失时回退到当前进程工作目录。 +func (b *gatewayRuntimePortBridge) resolveWorkspaceRootForFileAccess() (string, error) { + root := strings.TrimSpace(b.currentConfig().Workdir) if root == "" { - var err error - root, err = os.Getwd() + cwd, err := os.Getwd() if err != nil { return "", err } + root = cwd } - absolute, err := filepath.Abs(root) + absolute, err := filepath.Abs(filepath.Clean(root)) if err != nil { return "", err } return filepath.Clean(absolute), nil } +// isPathWithinRoot 判断 candidate 是否位于 root 目录内(含自身),同时处理符号链接场景。 +func isPathWithinRoot(candidate string, root string) bool { + candidateAbs, err := filepath.Abs(filepath.Clean(candidate)) + if err != nil { + return false + } + rootAbs, err := filepath.Abs(filepath.Clean(root)) + if err != nil { + return false + } + candidateForCheck := candidateAbs + if resolvedCandidate, resolveErr := filepath.EvalSymlinks(candidateAbs); resolveErr == nil { + candidateForCheck = resolvedCandidate + } + rootForCheck := rootAbs + if resolvedRoot, resolveErr := filepath.EvalSymlinks(rootAbs); resolveErr == nil { + rootForCheck = resolvedRoot + } + relative, err := filepath.Rel(rootForCheck, candidateForCheck) + if err != nil { + return false + } + if relative == "." { + return true + } + return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) +} + // loadStoredSession 通过可选的会话加载接口读取持久会话。 func (b *gatewayRuntimePortBridge) loadStoredSession(ctx context.Context, sessionID string) (agentsession.Session, error) { if b == nil || b.sessionStore == nil { diff --git a/internal/cli/gateway_runtime_bridge_test.go b/internal/cli/gateway_runtime_bridge_test.go index a7395649d..fc381308b 100644 --- a/internal/cli/gateway_runtime_bridge_test.go +++ b/internal/cli/gateway_runtime_bridge_test.go @@ -17,6 +17,7 @@ import ( configstate "neo-code/internal/config/state" "neo-code/internal/gateway" providertypes "neo-code/internal/provider/types" + "neo-code/internal/repository" agentruntime "neo-code/internal/runtime" agentsession "neo-code/internal/session" "neo-code/internal/skills" @@ -1791,48 +1792,47 @@ func TestResolveListFilesRootPriorities(t *testing.T) { t.Fatalf("mkdir: %v", err) } - // priority 1: input.Workdir - bridge1, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) - defer bridge1.Close() - root, err := bridge1.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{Workdir: subDir}) - if err != nil { - t.Fatalf("resolve with workdir: %v", err) - } - if root != subDir { - t.Fatalf("root = %q, want %q", root, subDir) - } - - // priority 2: session workdir (store implements bridgeSessionLoader) + // priority 1: session workdir (must stay inside current workspace root) loaderStore := &bridgeSessionStoreWithLoader{ bridgeSessionStoreStub: bridgeSessionStoreStub{}, session: agentsession.Session{Workdir: subDir}, } - bridge2, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore) - defer bridge2.Close() - root, err = bridge2.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{SessionID: "s-1"}) + cfgMgr1 := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge1, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore, cfgMgr1, nil) + defer bridge1.Close() + root, err := bridge1.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{SessionID: "s-1"}) if err != nil { t.Fatalf("resolve with session: %v", err) } - if root != subDir { - t.Fatalf("root = %q, want %q", root, subDir) + if root != filepath.Clean(subDir) { + t.Fatalf("root = %q, want %q", root, filepath.Clean(subDir)) } - // priority 3: config workdir - cfgMgr := &configManagerStub{cfg: config.Config{Workdir: subDir}} - bridge3, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) - defer bridge3.Close() - root, err = bridge3.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{}) + // priority 2: config workdir + cfgMgr2 := &configManagerStub{cfg: config.Config{Workdir: subDir}} + bridge2, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr2, nil) + defer bridge2.Close() + root, err = bridge2.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{}) if err != nil { t.Fatalf("resolve with config: %v", err) } - if root != subDir { - t.Fatalf("root = %q, want %q", root, subDir) + if root != filepath.Clean(subDir) { + t.Fatalf("root = %q, want %q", root, filepath.Clean(subDir)) } - // priority 4: os.Getwd - bridge4, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) - defer bridge4.Close() - root, err = bridge4.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{}) + // input.Workdir should be ignored and not override workspace root + root, err = bridge2.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{Workdir: t.TempDir()}) + if err != nil { + t.Fatalf("resolve with ignored workdir: %v", err) + } + if root != filepath.Clean(subDir) { + t.Fatalf("root = %q, want %q", root, filepath.Clean(subDir)) + } + + // priority 3: os.Getwd fallback + bridge3, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + defer bridge3.Close() + root, err = bridge3.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{}) if err != nil { t.Fatalf("resolve with getwd: %v", err) } @@ -1858,27 +1858,45 @@ func TestResolveListFilesRootSessionNotFound(t *testing.T) { bridgeSessionStoreStub: bridgeSessionStoreStub{}, loadErr: agentsession.ErrSessionNotFound, } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore) + cfgRoot := t.TempDir() + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: cfgRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore, cfgMgr, nil) defer bridge.Close() root, err := bridge.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{SessionID: "s-1"}) if err != nil { t.Fatalf("resolve with not-found session should not error: %v", err) } - wd, _ := os.Getwd() - absWd, _ := filepath.Abs(wd) - if root != filepath.Clean(absWd) { - t.Fatalf("root = %q, want %q", root, filepath.Clean(absWd)) + if root != filepath.Clean(cfgRoot) { + t.Fatalf("root = %q, want %q", root, filepath.Clean(cfgRoot)) + } +} + +func TestResolveListFilesRootRejectsSessionWorkdirEscapingWorkspaceRoot(t *testing.T) { + workspaceRoot := t.TempDir() + outsideRoot := t.TempDir() + loaderStore := &bridgeSessionStoreWithLoader{ + bridgeSessionStoreStub: bridgeSessionStoreStub{}, + session: agentsession.Session{Workdir: outsideRoot}, + } + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: workspaceRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, loaderStore, cfgMgr, nil) + defer bridge.Close() + + _, err := bridge.resolveListFilesRoot(context.Background(), gateway.ListFilesInput{SessionID: "s-1"}) + if err == nil || !strings.Contains(err.Error(), "escapes current workspace root") { + t.Fatalf("expected workspace boundary error, got %v", err) } } func TestGatewayRuntimePortBridgeListFilesReadDirFail(t *testing.T) { - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgRoot := t.TempDir() + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: cfgRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() _, err := bridge.ListFiles(context.Background(), gateway.ListFilesInput{ SubjectID: testBridgeSubjectID, - Workdir: t.TempDir(), Path: "nonexistent-dir", }) if err == nil { @@ -1894,12 +1912,12 @@ func TestGatewayRuntimePortBridgeListFilesFiltersAndSorts(t *testing.T) { _ = os.MkdirAll(filepath.Join(tmpDir, "Zdir"), 0755) _ = os.MkdirAll(filepath.Join(tmpDir, "adir"), 0755) - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() entries, err := bridge.ListFiles(context.Background(), gateway.ListFilesInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, }) if err != nil { t.Fatalf("list files: %v", err) @@ -1922,6 +1940,32 @@ func TestGatewayRuntimePortBridgeListFilesFiltersAndSorts(t *testing.T) { } } +func TestGatewayRuntimePortBridgeListFilesIgnoresInputWorkdir(t *testing.T) { + workspaceRoot := t.TempDir() + outsideRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(workspaceRoot, "inside.txt"), []byte("inside"), 0644); err != nil { + t.Fatalf("write inside file: %v", err) + } + if err := os.WriteFile(filepath.Join(outsideRoot, "outside.txt"), []byte("outside"), 0644); err != nil { + t.Fatalf("write outside file: %v", err) + } + + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: workspaceRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) + defer bridge.Close() + + entries, err := bridge.ListFiles(context.Background(), gateway.ListFilesInput{ + SubjectID: testBridgeSubjectID, + Workdir: outsideRoot, + }) + if err != nil { + t.Fatalf("ListFiles() error = %v", err) + } + if len(entries) != 1 || entries[0].Name != "inside.txt" { + t.Fatalf("entries = %+v, want only inside.txt from workspace root", entries) + } +} + func TestGatewayRuntimePortBridgeListGitDiffFilesExpandsUntrackedDirectory(t *testing.T) { tmpDir := t.TempDir() runGitTestCommand(t, tmpDir, "init") @@ -1939,12 +1983,12 @@ func TestGatewayRuntimePortBridgeListGitDiffFilesExpandsUntrackedDirectory(t *te t.Fatalf("write b.txt: %v", err) } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() result, err := bridge.ListGitDiffFiles(context.Background(), gateway.ListGitDiffFilesInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, }) if err != nil { t.Fatalf("ListGitDiffFiles() error = %v", err) @@ -1959,6 +2003,64 @@ func TestGatewayRuntimePortBridgeListGitDiffFilesExpandsUntrackedDirectory(t *te } } +func TestGatewayRuntimePortBridgeListGitDiffFilesIgnoresInputWorkdir(t *testing.T) { + workspaceRoot := t.TempDir() + runGitTestCommand(t, workspaceRoot, "init") + runGitTestCommand(t, workspaceRoot, "config", "user.name", "NeoCode Test") + runGitTestCommand(t, workspaceRoot, "config", "user.email", "test@example.com") + runGitTestCommand(t, workspaceRoot, "commit", "--allow-empty", "-m", "init") + if err := os.WriteFile(filepath.Join(workspaceRoot, "changed.txt"), []byte("x\n"), 0644); err != nil { + t.Fatalf("write changed file: %v", err) + } + + outsideRoot := t.TempDir() + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: workspaceRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) + defer bridge.Close() + + result, err := bridge.ListGitDiffFiles(context.Background(), gateway.ListGitDiffFilesInput{ + SubjectID: testBridgeSubjectID, + Workdir: outsideRoot, + }) + if err != nil { + t.Fatalf("ListGitDiffFiles() error = %v", err) + } + if !result.InGitRepo { + t.Fatalf("expected workspace root repo to be used, got non-repo result: %+v", result) + } + if result.TotalCount != 1 || len(result.Files) != 1 || result.Files[0].Path != "changed.txt" { + t.Fatalf("unexpected git diff result: %+v", result) + } +} + +func TestGatewayRuntimePortBridgeReadGitDiffFileIgnoresInputWorkdir(t *testing.T) { + workspaceRoot := t.TempDir() + runGitTestCommand(t, workspaceRoot, "init") + runGitTestCommand(t, workspaceRoot, "config", "user.name", "NeoCode Test") + runGitTestCommand(t, workspaceRoot, "config", "user.email", "test@example.com") + runGitTestCommand(t, workspaceRoot, "commit", "--allow-empty", "-m", "init") + if err := os.WriteFile(filepath.Join(workspaceRoot, "changed.txt"), []byte("line-1\n"), 0644); err != nil { + t.Fatalf("write changed file: %v", err) + } + + outsideRoot := t.TempDir() + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: workspaceRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) + defer bridge.Close() + + result, err := bridge.ReadGitDiffFile(context.Background(), gateway.ReadGitDiffFileInput{ + SubjectID: testBridgeSubjectID, + Workdir: outsideRoot, + Path: "changed.txt", + }) + if err != nil { + t.Fatalf("ReadGitDiffFile() error = %v", err) + } + if result.Path != "changed.txt" || result.Status != string(repository.StatusUntracked) { + t.Fatalf("unexpected read git diff result: %+v", result) + } +} + func runGitTestCommand(t *testing.T, workdir string, args ...string) { t.Helper() command := exec.Command("git", append([]string{"-C", workdir}, args...)...) @@ -1975,12 +2077,12 @@ func TestGatewayRuntimePortBridgeReadFileSuccess(t *testing.T) { t.Fatalf("write file: %v", err) } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() result, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, Path: "main.go", }) if err != nil { @@ -1997,18 +2099,45 @@ func TestGatewayRuntimePortBridgeReadFileSuccess(t *testing.T) { } } +func TestGatewayRuntimePortBridgeReadFileIgnoresInputWorkdir(t *testing.T) { + workspaceRoot := t.TempDir() + outsideRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(workspaceRoot, "main.go"), []byte("package main\n"), 0644); err != nil { + t.Fatalf("write workspace file: %v", err) + } + if err := os.WriteFile(filepath.Join(outsideRoot, "main.go"), []byte("package outside\n"), 0644); err != nil { + t.Fatalf("write outside file: %v", err) + } + + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: workspaceRoot}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) + defer bridge.Close() + + result, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ + SubjectID: testBridgeSubjectID, + Workdir: outsideRoot, + Path: "main.go", + }) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if result.Content != "package main\n" { + t.Fatalf("content = %q, want workspace file content", result.Content) + } +} + func TestGatewayRuntimePortBridgeReadFileRejectsDirectory(t *testing.T) { tmpDir := t.TempDir() if err := os.MkdirAll(filepath.Join(tmpDir, "dir"), 0755); err != nil { t.Fatalf("mkdir: %v", err) } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() _, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, Path: "dir", }) if err == nil || !strings.Contains(err.Error(), "is a directory") { @@ -2019,12 +2148,12 @@ func TestGatewayRuntimePortBridgeReadFileRejectsDirectory(t *testing.T) { func TestGatewayRuntimePortBridgeReadFileRejectsEscapedPath(t *testing.T) { tmpDir := t.TempDir() - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() _, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, Path: "../secret.txt", }) if err == nil || !strings.Contains(err.Error(), "escapes workdir") { @@ -2040,12 +2169,12 @@ func TestGatewayRuntimePortBridgeReadFileTruncatesLargeFile(t *testing.T) { t.Fatalf("write file: %v", err) } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() result, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, Path: "large.txt", }) if err != nil { @@ -2063,12 +2192,12 @@ func TestGatewayRuntimePortBridgeReadFileMarksBinaryContent(t *testing.T) { t.Fatalf("write file: %v", err) } - bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore) + cfgMgr := &configManagerStub{cfg: config.Config{Workdir: tmpDir}} + bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil) defer bridge.Close() result, err := bridge.ReadFile(context.Background(), gateway.ReadFileInput{ SubjectID: testBridgeSubjectID, - Workdir: tmpDir, Path: "bin.dat", }) if err != nil { diff --git a/web/package-lock.json b/web/package-lock.json index 4365c0fb2..3ccdb3a69 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -144,7 +144,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -560,7 +559,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -609,7 +607,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -2426,7 +2423,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2828,7 +2826,6 @@ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "*" } @@ -2874,7 +2871,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2886,7 +2882,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2993,7 +2988,6 @@ "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.5", @@ -3199,7 +3193,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3366,6 +3359,7 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -3385,6 +3379,7 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -3407,6 +3402,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -3422,7 +3418,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", @@ -3430,6 +3427,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -3685,7 +3683,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4056,7 +4053,6 @@ "resolved": "https://registry.npmmirror.com/chevrotain/-/chevrotain-12.0.0.tgz", "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", @@ -4292,6 +4288,7 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -4432,6 +4429,7 @@ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -4445,6 +4443,7 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -4501,7 +4500,6 @@ "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.3.tgz", "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -4902,7 +4900,6 @@ "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -5268,7 +5265,6 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -5351,7 +5347,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dompurify": { "version": "3.4.2", @@ -5436,7 +5433,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^20.9.0", @@ -5481,6 +5477,7 @@ "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "archiver": "^5.3.1", @@ -5494,6 +5491,7 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -5509,6 +5507,7 @@ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -5522,6 +5521,7 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -6090,7 +6090,8 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fs-extra": { "version": "8.1.0", @@ -6421,7 +6422,6 @@ "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -7088,7 +7088,8 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/isbinaryfile": { "version": "5.0.7", @@ -7396,6 +7397,7 @@ "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -7409,6 +7411,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -7424,7 +7427,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", @@ -7432,6 +7436,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -7454,14 +7459,16 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", @@ -7474,7 +7481,8 @@ "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -7488,14 +7496,16 @@ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/log-symbols": { "version": "4.1.0", @@ -7571,6 +7581,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8097,7 +8108,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -8715,8 +8725,7 @@ "url": "https://opencollective.com/unified" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/mime": { "version": "2.6.0", @@ -8938,8 +8947,7 @@ "version": "0.52.2", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ms": { "version": "2.1.3", @@ -9101,6 +9109,7 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9492,6 +9501,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9507,6 +9517,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -9519,7 +9530,8 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/progress": { "version": "2.0.3", @@ -9601,7 +9613,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9614,7 +9625,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9628,7 +9638,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.17.0", @@ -9706,6 +9717,7 @@ "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -9715,7 +9727,8 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/readdir-glob/node_modules/brace-expansion": { "version": "2.1.0", @@ -9723,6 +9736,7 @@ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -9733,6 +9747,7 @@ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -10683,6 +10698,7 @@ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -10823,7 +10839,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10996,7 +11011,6 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -11247,7 +11261,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -11337,8 +11350,7 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -11364,7 +11376,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11378,7 +11389,6 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -11805,6 +11815,7 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -11820,6 +11831,7 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", diff --git a/web/src/stores/useSessionStore.test.ts b/web/src/stores/useSessionStore.test.ts index e86be9c36..ed2bdba6b 100644 --- a/web/src/stores/useSessionStore.test.ts +++ b/web/src/stores/useSessionStore.test.ts @@ -168,6 +168,25 @@ describe('useSessionStore', () => { expect(useChatStore.getState().messages[0].role).toBe('user') }) + it('switchSession keeps transitioning true until loadSession finishes', async () => { + const mockBindStream = vi.fn().mockResolvedValue({}) + let resolveLoad!: (value: any) => void + const mockLoadSession = vi.fn().mockImplementation( + () => new Promise((resolve) => { resolveLoad = resolve }), + ) + const mockAPI = { bindStream: mockBindStream, loadSession: mockLoadSession } as any + + const switchPromise = useSessionStore.getState().switchSession('sess-2', mockAPI) + await Promise.resolve() + + expect(useChatStore.getState().isTransitioning).toBe(true) + + resolveLoad({ payload: { messages: [] } }) + await switchPromise + + expect(useChatStore.getState().isTransitioning).toBe(false) + }) + it('fetchSessions auto-selects first session and binds stream', async () => { const setMessagesSpy = vi.spyOn(useChatStore.getState(), 'setMessages') const addMessageSpy = vi.spyOn(useChatStore.getState(), 'addMessage') @@ -217,6 +236,56 @@ describe('useSessionStore', () => { expect(mockBindStream).not.toHaveBeenCalled() }) + it('fetchSessions ignores stale late response from an older request', async () => { + let resolveFirst!: (value: any) => void + let resolveSecond!: (value: any) => void + const mockListSessions = vi + .fn() + .mockImplementationOnce( + () => new Promise((resolve) => { resolveFirst = resolve }), + ) + .mockImplementationOnce( + () => new Promise((resolve) => { resolveSecond = resolve }), + ) + const mockAPI = { + listSessions: mockListSessions, + bindStream: vi.fn().mockResolvedValue({}), + loadSession: vi.fn().mockResolvedValue({ payload: { messages: [] } }), + } as any + + useSessionStore.setState({ currentSessionId: 'sess-keep' }) + + const firstRequest = useSessionStore.getState().fetchSessions(mockAPI, true) + const secondRequest = useSessionStore.getState().fetchSessions(mockAPI, true) + + resolveSecond({ + payload: { + sessions: [{ + id: 'sess-new', + title: 'New', + created_at: '2026-05-10T01:00:00Z', + updated_at: '2026-05-10T01:00:00Z', + }], + }, + }) + await secondRequest + + resolveFirst({ + payload: { + sessions: [{ + id: 'sess-old', + title: 'Old', + created_at: '2026-05-09T01:00:00Z', + updated_at: '2026-05-09T01:00:00Z', + }], + }, + }) + await firstRequest + + const sessions = useSessionStore.getState().projects.flatMap((project) => project.sessions) + expect(sessions.map((session) => session.id)).toEqual(['sess-new']) + }) + it('fetchSessions uses the newer of created_at/updated_at as display time', async () => { const mockListSessions = vi.fn().mockResolvedValue({ payload: { diff --git a/web/src/stores/useSessionStore.ts b/web/src/stores/useSessionStore.ts index 9018e7e00..5a45dd439 100644 --- a/web/src/stores/useSessionStore.ts +++ b/web/src/stores/useSessionStore.ts @@ -261,6 +261,7 @@ export async function reloadSessionAfterCheckpointRestore( } let _fetchSessionsPromise: Promise | null = null +let _fetchSessionsSeq = 0 export const useSessionStore = create((set, get) => ({ projects: [], @@ -295,10 +296,10 @@ export const useSessionStore = create((set, get) => ({ const prevSessionId = get().currentSessionId try { - // 1. Set transitioning flag and clear messages FIRST (before bindStream) + // 1. Clear messages first, then enter transitioning state to keep event drop window effective const chatStore = useChatStore.getState() - chatStore.setTransitioning(true) chatStore.clearMessages() + chatStore.setTransitioning(true) useRuntimeInsightStore.getState().reset() useUIStore.getState().clearCheckpointRollbackUndo() @@ -379,6 +380,7 @@ export const useSessionStore = create((set, get) => ({ resetForWorkspaceSwitch: () => { _fetchSessionsPromise = null + _fetchSessionsSeq += 1 set({ _initialBindDone: false, loading: false }) }, @@ -393,24 +395,29 @@ export const useSessionStore = create((set, get) => ({ // 去重:若已有 fetch 在进行中,复用同一 promise(force 跳过去重) if (!force && _fetchSessionsPromise) return _fetchSessionsPromise - _fetchSessionsPromise = (async () => { + const requestSeq = ++_fetchSessionsSeq + const fetchPromise = (async () => { set({ loading: true }) try { const result = await gatewayAPI.listSessions() + if (requestSeq !== _fetchSessionsSeq) return const sessions = result.payload.sessions const projects = mapSessionsToProjects(sessions) set({ projects, loading: false }) const state = get() + if (requestSeq !== _fetchSessionsSeq) return if (!isValidSessionId(state.currentSessionId) && sessions.length > 0) { const firstSession = sessions[0] set({ currentSessionId: firstSession.id }) try { await gatewayAPI.bindStream({ session_id: firstSession.id, channel: 'all' }) + if (requestSeq !== _fetchSessionsSeq) return set({ _initialBindDone: true }) // Load historical messages for the auto-selected session (concurrently fetch todos + runtime snapshot) const sessionFrame = await loadSessionWithInsights(gatewayAPI, firstSession.id) + if (requestSeq !== _fetchSessionsSeq) return const sessionData = sessionFrame.payload as { messages?: BackendMessage[]; agent_mode?: string } if (sessionData.messages && sessionData.messages.length > 0) { const mapped = mapHistoryMessages(sessionData.messages) @@ -419,18 +426,23 @@ export const useSessionStore = create((set, get) => ({ const restoredMode = sessionData.agent_mode === 'plan' ? 'plan' : 'build' useChatStore.getState().setAgentMode(restoredMode) } catch (err) { + if (requestSeq !== _fetchSessionsSeq) return console.error('Auto bindStream or loadSession failed:', err) useUIStore.getState().showToast('Failed to load session', 'error') } } } catch (err) { + if (requestSeq !== _fetchSessionsSeq) return console.error('fetchSessions failed:', err) set({ projects: [], loading: false }) } finally { - _fetchSessionsPromise = null + if (requestSeq === _fetchSessionsSeq) { + _fetchSessionsPromise = null + } } })() - return _fetchSessionsPromise + _fetchSessionsPromise = fetchPromise + return fetchPromise }, })) diff --git a/web/src/stores/useWorkspaceStore.test.ts b/web/src/stores/useWorkspaceStore.test.ts index a55103e3b..c3d21664f 100644 --- a/web/src/stores/useWorkspaceStore.test.ts +++ b/web/src/stores/useWorkspaceStore.test.ts @@ -88,6 +88,31 @@ describe('useWorkspaceStore', () => { expect(useWorkspaceStore.getState().currentWorkspaceHash).toBe('w2') }) + it('switchWorkspace ignores stale late response from an older switch request', async () => { + let resolveA!: () => void + let resolveB!: () => void + const gatewayAPI = { + switchWorkspace: vi + .fn() + .mockImplementationOnce(() => new Promise((resolve) => { resolveA = resolve })) + .mockImplementationOnce(() => new Promise((resolve) => { resolveB = resolve })), + } as any + const fetchSessions = useSessionStore.getState().fetchSessions as any + + const switchA = useWorkspaceStore.getState().switchWorkspace('wA', gatewayAPI) + const switchB = useWorkspaceStore.getState().switchWorkspace('wB', gatewayAPI) + + resolveB() + await switchB + expect(useWorkspaceStore.getState().currentWorkspaceHash).toBe('wB') + + resolveA() + await switchA + expect(useWorkspaceStore.getState().currentWorkspaceHash).toBe('wB') + expect(fetchSessions).toHaveBeenCalledTimes(1) + expect(fetchSessions).toHaveBeenCalledWith(gatewayAPI, true) + }) + it('createWorkspace failure reports toast', async () => { const showToast = vi.fn() useUIStore.setState({ showToast } as any) diff --git a/web/src/stores/useWorkspaceStore.ts b/web/src/stores/useWorkspaceStore.ts index de94e148c..ff369c980 100644 --- a/web/src/stores/useWorkspaceStore.ts +++ b/web/src/stores/useWorkspaceStore.ts @@ -42,6 +42,7 @@ function mapAPIWorkspace(w: APIWorkspace): Workspace { } let _fetchWorkspacesPromise: Promise | null = null +let _workspaceSwitchSeq = 0 export const useWorkspaceStore = create((set, get) => ({ workspaces: [], @@ -88,6 +89,7 @@ export const useWorkspaceStore = create((set, get) => ({ } set({ loading: true }) + const switchSeq = ++_workspaceSwitchSeq // 先清空所有前端状态(防止重连 handler 读到旧 sessionId 竞态) useChatStore.getState().clearMessages() @@ -105,17 +107,21 @@ export const useWorkspaceStore = create((set, get) => ({ try { await gatewayAPI.switchWorkspace(hash) + if (switchSeq !== _workspaceSwitchSeq) return set({ currentWorkspaceHash: hash }) useGatewayStore.getState().notifyProviderChanged() // 加载新工作区的会话列表 await useSessionStore.getState().fetchSessions(gatewayAPI, true) } catch (err) { + if (switchSeq !== _workspaceSwitchSeq) return console.error('switchWorkspace failed:', err) useUIStore.getState().showToast('Failed to switch workspace', 'error') } finally { - useChatStore.getState().setTransitioning(false) - set({ loading: false }) + if (switchSeq === _workspaceSwitchSeq) { + useChatStore.getState().setTransitioning(false) + set({ loading: false }) + } } }, @@ -126,6 +132,7 @@ export const useWorkspaceStore = create((set, get) => ({ } set({ loading: true }) + const switchSeq = ++_workspaceSwitchSeq // 先清空所有前端状态(与 switchWorkspace 保持一致) useChatStore.getState().clearMessages() @@ -143,6 +150,7 @@ export const useWorkspaceStore = create((set, get) => ({ try { const result = await gatewayAPI.createWorkspace(path, name) + if (switchSeq !== _workspaceSwitchSeq) return const w = mapAPIWorkspace(result.payload.workspace) set((state) => ({ workspaces: [w, ...state.workspaces.filter((x) => x.hash !== w.hash)], @@ -150,19 +158,24 @@ export const useWorkspaceStore = create((set, get) => ({ // 通知后端切换到新工作区 await gatewayAPI.switchWorkspace(w.hash) + if (switchSeq !== _workspaceSwitchSeq) return set({ currentWorkspaceHash: w.hash }) useGatewayStore.getState().notifyProviderChanged() // 加载新工作区的会话列表 await useSessionStore.getState().fetchSessions(gatewayAPI, true) + if (switchSeq !== _workspaceSwitchSeq) return useUIStore.getState().showToast('Workspace created', 'success') } catch (err) { + if (switchSeq !== _workspaceSwitchSeq) return console.error('createWorkspace failed:', err) set({ loading: false }) useUIStore.getState().showToast('Failed to create workspace', 'error') } finally { - useChatStore.getState().setTransitioning(false) - set({ loading: false }) + if (switchSeq === _workspaceSwitchSeq) { + useChatStore.getState().setTransitioning(false) + set({ loading: false }) + } } }, diff --git a/web/src/utils/eventBridge.test.ts b/web/src/utils/eventBridge.test.ts index 55df0530e..ea7ad70aa 100644 --- a/web/src/utils/eventBridge.test.ts +++ b/web/src/utils/eventBridge.test.ts @@ -102,6 +102,65 @@ describe("eventBridge", () => { expect(useChatStore.getState().messages[0].content).toBe("Hello"); }); + it("drops stale session events after session switch for tool and chunk updates", () => { + const api = createMockGatewayAPI(); + useSessionStore.setState({ currentSessionId: "sess-new" } as any); + + handleGatewayEvent( + { + type: EventType.ToolStart, + payload: { + payload: { + runtime_event_type: EventType.ToolStart, + payload: { + name: "filesystem_write_file", + id: "tc-old", + arguments: '{"path":"stale.txt"}', + }, + }, + }, + session_id: "sess-old", + run_id: "run-old", + }, + api, + ); + + handleGatewayEvent( + { + type: EventType.ToolDiff, + payload: { + payload: { + runtime_event_type: EventType.ToolDiff, + payload: { + tool_name: "filesystem_write_file", + file_path: "stale.txt", + diff: "--- a/stale.txt\n+++ b/stale.txt\n@@ -0,0 +1 @@\n+old\n", + was_new: true, + }, + }, + }, + session_id: "sess-old", + run_id: "run-old", + }, + api, + ); + + handleGatewayEvent( + { + type: EventType.AgentChunk, + payload: { + payload: { runtime_event_type: EventType.AgentChunk, payload: "stale chunk" }, + }, + session_id: "sess-old", + run_id: "run-old", + }, + api, + ); + + expect(useChatStore.getState().messages).toHaveLength(0); + expect(useUIStore.getState().fileChanges).toHaveLength(0); + }); + it("AgentDone finalizes message from parts array", () => { const api = createMockGatewayAPI(); const store = useChatStore.getState(); diff --git a/web/src/utils/eventBridge.ts b/web/src/utils/eventBridge.ts index 24ed94f17..142f9011b 100644 --- a/web/src/utils/eventBridge.ts +++ b/web/src/utils/eventBridge.ts @@ -538,6 +538,10 @@ function normalizeUserQuestionRequestedPayload( } const CRITICAL_EVENTS = new Set([EventType.Error]); +const SESSION_AGNOSTIC_EVENTS = new Set([ + EventType.Error, + EventType.InputNormalized, +]); function strField(payload: unknown, key: string): string { return ((payload as PayloadRecord)?.[key] as string) ?? ""; @@ -632,6 +636,8 @@ export function handleGatewayEvent( (innerEnvelope?.runtime_event_type as string | undefined) ?? (payload.event_type as string | undefined); if (!eventType) return; + const frameSessionId = (frame.session_id || "").trim(); + const frameRunId = frame.run_id; // Discard non-critical events during workspace transition to avoid stale data // Only Error events are allowed through during transition @@ -642,6 +648,16 @@ export function handleGatewayEvent( return; } + const currentSessionId = useSessionStore.getState().currentSessionId.trim(); + if ( + frameSessionId && + currentSessionId && + frameSessionId !== currentSessionId && + !SESSION_AGNOSTIC_EVENTS.has(eventType) + ) { + return; + } + const eventPayload = innerEnvelope?.payload; const chatStore = useChatStore.getState(); @@ -649,9 +665,6 @@ export function handleGatewayEvent( const gwStore = useGatewayStore.getState(); const insightStore = useRuntimeInsightStore.getState(); - const frameSessionId = frame.session_id; - const frameRunId = frame.run_id; - /** 更新最新 verification 消息的 data 为 insightStore 当前最后一条 record */ function syncLatestVerificationToChat() { const history = useRuntimeInsightStore.getState().verificationHistory; From ac9c52f4e40241a517b8ffd8fe5889d6a4f20e6a Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 17 May 2026 16:09:04 +0800 Subject: [PATCH 3/7] fix(web): prevent stale session/workspace state writeback --- web/src/stores/useSessionStore.test.ts | 62 ++++++++++++++++++++++++ web/src/stores/useSessionStore.ts | 17 +++++-- web/src/stores/useWorkspaceStore.test.ts | 32 ++++++++++++ web/src/stores/useWorkspaceStore.ts | 5 ++ 4 files changed, 112 insertions(+), 4 deletions(-) diff --git a/web/src/stores/useSessionStore.test.ts b/web/src/stores/useSessionStore.test.ts index ed2bdba6b..e0db1009e 100644 --- a/web/src/stores/useSessionStore.test.ts +++ b/web/src/stores/useSessionStore.test.ts @@ -187,6 +187,68 @@ describe('useSessionStore', () => { expect(useChatStore.getState().isTransitioning).toBe(false) }) + it('resetForWorkspaceSwitch aborts in-flight switchSession and blocks stale writeback', async () => { + const mockBindStream = vi.fn().mockResolvedValue({}) + let resolveLoad!: (value: any) => void + const mockLoadSession = vi.fn().mockImplementation( + () => new Promise((resolve) => { resolveLoad = resolve }), + ) + const mockAPI = { bindStream: mockBindStream, loadSession: mockLoadSession } as any + + const switchPromise = useSessionStore.getState().switchSession('sess-old', mockAPI) + await Promise.resolve() + + useSessionStore.getState().resetForWorkspaceSwitch() + + resolveLoad({ + payload: { + messages: [{ role: 'assistant', content: 'stale payload', tool_calls: [] }], + agent_mode: 'plan', + }, + }) + await switchPromise + + expect(useChatStore.getState().messages).toHaveLength(0) + expect(useChatStore.getState().agentMode).toBe('build') + }) + + it('switchSession applies only latest request when older request resolves later', async () => { + const mockBindStream = vi.fn().mockResolvedValue({}) + let resolveLoadA!: (value: any) => void + let resolveLoadB!: (value: any) => void + const mockLoadSession = vi + .fn() + .mockImplementationOnce(() => new Promise((resolve) => { resolveLoadA = resolve })) + .mockImplementationOnce(() => new Promise((resolve) => { resolveLoadB = resolve })) + const mockAPI = { bindStream: mockBindStream, loadSession: mockLoadSession } as any + + const switchA = useSessionStore.getState().switchSession('sess-a', mockAPI) + await Promise.resolve() + const switchB = useSessionStore.getState().switchSession('sess-b', mockAPI) + await Promise.resolve() + + resolveLoadB({ + payload: { + messages: [{ role: 'assistant', content: 'new payload', tool_calls: [] }], + agent_mode: 'plan', + }, + }) + await switchB + + resolveLoadA({ + payload: { + messages: [{ role: 'assistant', content: 'old payload', tool_calls: [] }], + agent_mode: 'build', + }, + }) + await switchA + + expect(useSessionStore.getState().currentSessionId).toBe('sess-b') + expect(useChatStore.getState().messages).toHaveLength(1) + expect(useChatStore.getState().messages[0].content).toBe('new payload') + expect(useChatStore.getState().agentMode).toBe('plan') + }) + it('fetchSessions auto-selects first session and binds stream', async () => { const setMessagesSpy = vi.spyOn(useChatStore.getState(), 'setMessages') const addMessageSpy = vi.spyOn(useChatStore.getState(), 'addMessage') diff --git a/web/src/stores/useSessionStore.ts b/web/src/stores/useSessionStore.ts index 5a45dd439..63ff495bc 100644 --- a/web/src/stores/useSessionStore.ts +++ b/web/src/stores/useSessionStore.ts @@ -262,6 +262,7 @@ export async function reloadSessionAfterCheckpointRestore( let _fetchSessionsPromise: Promise | null = null let _fetchSessionsSeq = 0 +let _switchSessionSeq = 0 export const useSessionStore = create((set, get) => ({ projects: [], @@ -290,6 +291,7 @@ export const useSessionStore = create((set, get) => ({ if (prevAbort) { prevAbort.abort() } + const switchSeq = ++_switchSessionSeq const abortCtrl = new AbortController() set({ _switchAbort: abortCtrl, loading: true }) @@ -308,13 +310,15 @@ export const useSessionStore = create((set, get) => ({ // 3. Bind stream (events will be discarded due to isTransitioning) await gatewayAPI.bindStream({ session_id: sessionId, channel: 'all' }) + if (abortCtrl.signal.aborted || switchSeq !== _switchSessionSeq || get()._switchAbort !== abortCtrl) return // 4. Load historical messages (concurrently fetch todos + runtime snapshot) const sessionFrame = await loadSessionWithInsights(gatewayAPI, sessionId) + if (abortCtrl.signal.aborted || switchSeq !== _switchSessionSeq || get()._switchAbort !== abortCtrl) return const sessionData = sessionFrame.payload as { messages?: BackendMessage[]; agent_mode?: string } // Check if this request was superseded - if (abortCtrl.signal.aborted) return + if (abortCtrl.signal.aborted || switchSeq !== _switchSessionSeq || get()._switchAbort !== abortCtrl) return // 5. Load messages and stop transitioning if (sessionData.messages && sessionData.messages.length > 0) { @@ -326,7 +330,7 @@ export const useSessionStore = create((set, get) => ({ useChatStore.getState().setAgentMode(restoredMode) chatStore.setTransitioning(false) } catch (err) { - if (abortCtrl.signal.aborted) return + if (abortCtrl.signal.aborted || switchSeq !== _switchSessionSeq || get()._switchAbort !== abortCtrl) return console.error('switchSession failed:', err) // Revert to previous session and re-bind its stream set({ currentSessionId: prevSessionId }) @@ -335,7 +339,7 @@ export const useSessionStore = create((set, get) => ({ } useChatStore.getState().setTransitioning(false) } finally { - if (get()._switchAbort === abortCtrl) { + if (switchSeq === _switchSessionSeq && get()._switchAbort === abortCtrl) { set({ loading: false, _switchAbort: null }) } } @@ -379,9 +383,14 @@ export const useSessionStore = create((set, get) => ({ }, resetForWorkspaceSwitch: () => { + const currentAbort = get()._switchAbort + if (currentAbort) { + currentAbort.abort() + } _fetchSessionsPromise = null _fetchSessionsSeq += 1 - set({ _initialBindDone: false, loading: false }) + _switchSessionSeq += 1 + set({ _initialBindDone: false, loading: false, _switchAbort: null }) }, removeSessionLocally: (sessionId) => { diff --git a/web/src/stores/useWorkspaceStore.test.ts b/web/src/stores/useWorkspaceStore.test.ts index c3d21664f..e00869856 100644 --- a/web/src/stores/useWorkspaceStore.test.ts +++ b/web/src/stores/useWorkspaceStore.test.ts @@ -4,6 +4,7 @@ import { useChatStore } from './useChatStore' import { useSessionStore } from './useSessionStore' import { useUIStore } from './useUIStore' import { useGatewayStore } from './useGatewayStore' +import { useRuntimeInsightStore } from './useRuntimeInsightStore' function flushPromises() { return new Promise((resolve) => setTimeout(resolve, 0)) @@ -32,6 +33,7 @@ describe('useWorkspaceStore', () => { useUIStore.setState({ showToast: vi.fn(), clearFileChanges: vi.fn(), + clearCheckpointRollbackUndo: vi.fn(), resetPreviewTabs: vi.fn(), setSearchQuery: vi.fn(), } as any) @@ -39,6 +41,9 @@ describe('useWorkspaceStore', () => { setCurrentRunId: vi.fn(), notifyProviderChanged: vi.fn(), } as any) + useRuntimeInsightStore.setState({ + reset: vi.fn(), + } as any) }) it('deduplicates concurrent fetchWorkspaces calls', async () => { @@ -80,7 +85,9 @@ describe('useWorkspaceStore', () => { expect(useChatStore.getState().clearMessages).toHaveBeenCalled() expect(useSessionStore.getState().resetForWorkspaceSwitch).toHaveBeenCalled() + expect(useRuntimeInsightStore.getState().reset).toHaveBeenCalled() expect(useUIStore.getState().clearFileChanges).toHaveBeenCalled() + expect(useUIStore.getState().clearCheckpointRollbackUndo).toHaveBeenCalled() expect(useUIStore.getState().resetPreviewTabs).toHaveBeenCalled() expect(gatewayAPI.switchWorkspace).toHaveBeenCalledWith('w2') expect(useGatewayStore.getState().notifyProviderChanged).toHaveBeenCalled() @@ -124,6 +131,31 @@ describe('useWorkspaceStore', () => { expect(showToast).toHaveBeenCalledWith('Failed to create workspace', 'error') }) + it('createWorkspace clears runtime insight and rollback undo before switching', async () => { + const gatewayAPI = { + createWorkspace: vi.fn().mockResolvedValue({ + payload: { + workspace: { + hash: 'w-new', + path: '/new', + name: 'New', + created_at: '1', + updated_at: '1', + }, + }, + }), + switchWorkspace: vi.fn().mockResolvedValue(undefined), + } as any + const fetchSessions = useSessionStore.getState().fetchSessions as any + + await useWorkspaceStore.getState().createWorkspace('/new', gatewayAPI, 'New') + + expect(useRuntimeInsightStore.getState().reset).toHaveBeenCalled() + expect(useUIStore.getState().clearCheckpointRollbackUndo).toHaveBeenCalled() + expect(gatewayAPI.switchWorkspace).toHaveBeenCalledWith('w-new') + expect(fetchSessions).toHaveBeenCalledWith(gatewayAPI, true) + }) + it('deleteWorkspace switches to remaining first workspace when current is removed', async () => { const switchWorkspace = vi.spyOn(useWorkspaceStore.getState(), 'switchWorkspace') useWorkspaceStore.setState({ diff --git a/web/src/stores/useWorkspaceStore.ts b/web/src/stores/useWorkspaceStore.ts index ff369c980..548237639 100644 --- a/web/src/stores/useWorkspaceStore.ts +++ b/web/src/stores/useWorkspaceStore.ts @@ -5,6 +5,7 @@ import { useSessionStore } from '@/stores/useSessionStore' import { useChatStore } from '@/stores/useChatStore' import { useUIStore } from '@/stores/useUIStore' import { useGatewayStore } from '@/stores/useGatewayStore' +import { useRuntimeInsightStore } from '@/stores/useRuntimeInsightStore' /** 工作区记录 */ export interface Workspace { @@ -101,7 +102,9 @@ export const useWorkspaceStore = create((set, get) => ({ }) useSessionStore.getState().resetForWorkspaceSwitch() useGatewayStore.getState().setCurrentRunId('') + useRuntimeInsightStore.getState().reset() useUIStore.getState().clearFileChanges() + useUIStore.getState().clearCheckpointRollbackUndo() useUIStore.getState().resetPreviewTabs() useUIStore.getState().setSearchQuery('') @@ -144,7 +147,9 @@ export const useWorkspaceStore = create((set, get) => ({ }) useSessionStore.getState().resetForWorkspaceSwitch() useGatewayStore.getState().setCurrentRunId('') + useRuntimeInsightStore.getState().reset() useUIStore.getState().clearFileChanges() + useUIStore.getState().clearCheckpointRollbackUndo() useUIStore.getState().resetPreviewTabs() useUIStore.getState().setSearchQuery('') From 9094580bf2b3b0f07b3d5525a2a0948191b3cea8 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 17 May 2026 05:33:04 -0400 Subject: [PATCH 4/7] fix(web): close remaining session and workspace race gaps --- web/src/components/layout/Sidebar.test.tsx | 23 +++ web/src/components/layout/Sidebar.tsx | 16 +- web/src/stores/useWorkspaceStore.test.ts | 88 +++++++++-- web/src/stores/useWorkspaceStore.ts | 163 +++++++++++---------- web/src/utils/eventBridge.test.ts | 78 ++++++++++ web/src/utils/eventBridge.ts | 1 - 6 files changed, 274 insertions(+), 95 deletions(-) diff --git a/web/src/components/layout/Sidebar.test.tsx b/web/src/components/layout/Sidebar.test.tsx index 5e1ddf32d..eddee55e0 100644 --- a/web/src/components/layout/Sidebar.test.tsx +++ b/web/src/components/layout/Sidebar.test.tsx @@ -21,6 +21,7 @@ vi.mock('@/context/RuntimeProvider', () => ({ describe('Sidebar ProviderModal', () => { beforeEach(() => { + vi.restoreAllMocks() cleanup() mockGatewayAPI = { listMCPServers: vi.fn().mockResolvedValue({ @@ -130,6 +131,7 @@ describe('Sidebar ProviderModal', () => { useWorkspaceStore.setState({ workspaces: [], currentWorkspaceHash: '', + changing: false, switchWorkspace: vi.fn(), renameWorkspace: vi.fn(), deleteWorkspace: vi.fn(), @@ -328,6 +330,27 @@ describe('Sidebar ProviderModal', () => { }) }) + it('disables workspace actions while a workspace change is in flight', () => { + useWorkspaceStore.setState({ + workspaces: [ + { hash: 'w1', path: '/workspace-one', name: 'Workspace One', createdAt: '1', updatedAt: '1' }, + { hash: 'w2', path: '/workspace-two', name: 'Workspace Two', createdAt: '1', updatedAt: '1' }, + ], + currentWorkspaceHash: 'w1', + changing: true, + switchWorkspace: vi.fn(), + } as any) + + const { container } = render() + + expect(screen.getByRole('button', { name: /Workspace One/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /Workspace Two/i })).toBeDisabled() + + const addWorkspaceButton = container.querySelector('.sidebar-section-header .btn') + expect(addWorkspaceButton).toBeInstanceOf(HTMLButtonElement) + expect(addWorkspaceButton as HTMLButtonElement).toBeDisabled() + }) + it('immediately dispatches collapsed-rail actions', async () => { const toggleSidebar = vi.fn() const prepareNewChat = vi.fn() diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index b75c5c410..2e2a21573 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -47,6 +47,7 @@ export default function Sidebar({ collapsed }: SidebarProps) { const workspaces = useWorkspaceStore((s) => s.workspaces) const currentWorkspaceHash = useWorkspaceStore((s) => s.currentWorkspaceHash) + const workspaceChanging = useWorkspaceStore((s) => s.changing) const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace) const renameWorkspace = useWorkspaceStore((s) => s.renameWorkspace) const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace) @@ -110,6 +111,7 @@ export default function Sidebar({ collapsed }: SidebarProps) { async function handleSelectWorkspace(hash: string) { if (!gatewayAPI) return + if (useWorkspaceStore.getState().changing) return if (hash !== currentWorkspaceHash) { await switchWorkspace(hash, gatewayAPI) } @@ -139,6 +141,7 @@ export default function Sidebar({ collapsed }: SidebarProps) { async function handleCreateWorkspace(path: string, name?: string) { if (!gatewayAPI || !path.trim()) return + if (useWorkspaceStore.getState().changing) return await createWorkspace(path.trim(), gatewayAPI, name?.trim() || undefined) setCreateWorkspaceOpen(false) } @@ -209,7 +212,13 @@ export default function Sidebar({ collapsed }: SidebarProps) { {/* Section header: label + add workspace */}
工作区 -
@@ -266,6 +275,7 @@ export default function Sidebar({ collapsed }: SidebarProps) { expanded={rowExpanded} isCurrent={isCurrent} isRenaming={isRenaming} + disabled={workspaceChanging} renameValue={workspaceRenameValue} onRenameValueChange={setWorkspaceRenameValue} onCommitRename={() => handleCommitWorkspaceRename(ws.hash)} @@ -438,11 +448,13 @@ function SessionItem({ function WorkspaceRow({ workspace, expanded, isCurrent, isRenaming, renameValue, + disabled, onRenameValueChange, onCommitRename, onCancelRename, onClick, onStartRename, onDelete, }: { workspace: Workspace expanded: boolean; isCurrent: boolean; isRenaming: boolean + disabled: boolean renameValue: string onRenameValueChange: (v: string) => void onCommitRename: () => void @@ -459,7 +471,7 @@ function WorkspaceRow({ onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} > -