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..117750fc1 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))
+ }
+
+ // 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 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{})
+ // 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,166 @@ 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(cfgRoot) {
+ t.Fatalf("root = %q, want %q", root, filepath.Clean(cfgRoot))
+ }
+}
+
+func TestResolveListFilesRootFallsBackWhenSessionWorkdirEmpty(t *testing.T) {
+ cfgRoot := t.TempDir()
+ loaderStore := &bridgeSessionStoreWithLoader{
+ bridgeSessionStoreStub: bridgeSessionStoreStub{},
+ session: agentsession.Session{Workdir: " \t "},
+ }
+ 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 empty session workdir should not error: %v", err)
+ }
+ if root != filepath.Clean(cfgRoot) {
+ t.Fatalf("root = %q, want %q", root, filepath.Clean(cfgRoot))
+ }
+}
+
+func TestResolveListFilesRootPropagatesUnexpectedSessionLoadError(t *testing.T) {
+ cfgRoot := t.TempDir()
+ loaderStore := &bridgeSessionStoreWithLoader{
+ bridgeSessionStoreStub: bridgeSessionStoreStub{},
+ loadErr: errors.New("load failed"),
+ }
+ cfgMgr := &configManagerStub{cfg: config.Config{Workdir: cfgRoot}}
+ 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 || err.Error() != "load failed" {
+ t.Fatalf("expected load failed error, got %v", err)
+ }
+}
+
+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 TestIsPathWithinRoot(t *testing.T) {
+ root := t.TempDir()
+ insideDir := filepath.Join(root, "inside")
+ if err := os.MkdirAll(insideDir, 0o755); err != nil {
+ t.Fatalf("mkdir inside dir: %v", err)
+ }
+
+ if !isPathWithinRoot(root, root) {
+ t.Fatal("expected workspace root to be accepted as its own boundary")
+ }
+ if !isPathWithinRoot(insideDir, root) {
+ t.Fatal("expected child dir to be accepted")
+ }
+
+ outsideRoot := t.TempDir()
+ if isPathWithinRoot(outsideRoot, root) {
+ t.Fatal("expected unrelated path to be rejected")
+ }
+
+ linkPath := filepath.Join(root, "linked-outside")
+ if err := os.Symlink(outsideRoot, linkPath); err != nil {
+ t.Fatalf("symlink outside: %v", err)
+ }
+ if isPathWithinRoot(linkPath, root) {
+ t.Fatal("expected symlink escaping workspace root to be rejected")
+ }
+}
+
+func TestResolveWorkspaceRootForFileAccess(t *testing.T) {
+ configuredRoot := t.TempDir()
+ cfgMgr := &configManagerStub{cfg: config.Config{Workdir: configuredRoot}}
+ bridge, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, testSessionStore, cfgMgr, nil)
+ defer bridge.Close()
+
+ root, err := bridge.resolveWorkspaceRootForFileAccess()
+ if err != nil {
+ t.Fatalf("resolve configured workspace root: %v", err)
+ }
+ if root != filepath.Clean(configuredRoot) {
+ t.Fatalf("root = %q, want %q", root, filepath.Clean(configuredRoot))
+ }
+
+ bridgeNoConfig, _ := newGatewayRuntimePortBridge(
+ context.Background(),
+ &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)},
+ testSessionStore,
+ &configManagerStub{cfg: config.Config{Workdir: " \t "}},
+ nil,
+ )
+ defer bridgeNoConfig.Close()
+
+ root, err = bridgeNoConfig.resolveWorkspaceRootForFileAccess()
+ if err != nil {
+ t.Fatalf("resolve cwd fallback workspace root: %v", err)
+ }
+ wd, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ absWd, err := filepath.Abs(wd)
+ if err != nil {
+ t.Fatalf("abs wd: %v", err)
+ }
if root != filepath.Clean(absWd) {
t.Fatalf("root = %q, want %q", root, filepath.Clean(absWd))
}
}
+func TestLoadStoredSessionRejectsUnavailableOrUnsupportedSessionStore(t *testing.T) {
+ bridgeNilStore, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, nil)
+ defer bridgeNilStore.Close()
+
+ _, err := bridgeNilStore.loadStoredSession(context.Background(), "s-1")
+ if err == nil || !strings.Contains(err.Error(), "session store is unavailable") {
+ t.Fatalf("expected unavailable store error, got %v", err)
+ }
+
+ bridgeUnsupported, _ := newGatewayRuntimePortBridge(context.Background(), &runtimeStub{eventsCh: make(chan agentruntime.RuntimeEvent, 1)}, &bridgeSessionStoreStub{})
+ defer bridgeUnsupported.Close()
+
+ _, err = bridgeUnsupported.loadStoredSession(context.Background(), "s-1")
+ if err == nil || !strings.Contains(err.Error(), "does not support load session") {
+ t.Fatalf("expected unsupported loader 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 +2033,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 +2061,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 +2104,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 +2124,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 +2198,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 +2220,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 +2269,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 +2290,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 +2313,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/internal/cli/web_command_test.go b/internal/cli/web_command_test.go
index 0c0d23351..dd48daab4 100644
--- a/internal/cli/web_command_test.go
+++ b/internal/cli/web_command_test.go
@@ -1,15 +1,21 @@
package cli
import (
+ "bytes"
"context"
+ "encoding/json"
"errors"
"io/fs"
"log"
+ "net"
+ "net/http"
+ "net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
+ "time"
)
// writeWebCommandTestFile 写入 web 命令测试所需的最小文件内容,避免各测试重复拼装目录。
@@ -286,3 +292,356 @@ func TestRunWebCommandSkipBuildStillUsesEmbeddedAssets(t *testing.T) {
t.Fatal("startGatewayServer staticFS = nil, want embedded assets FS")
}
}
+
+func TestValidateStaticDirAndResolveOverride(t *testing.T) {
+ tempDir := t.TempDir()
+ staticDir := filepath.Join(tempDir, "dist")
+ if _, err := validateStaticDir(staticDir); err == nil {
+ t.Fatal("validateStaticDir() error = nil, want missing index.html error")
+ }
+
+ writeWebCommandTestFile(t, filepath.Join(staticDir, "index.html"), "")
+ got, err := resolveWebStaticDir(staticDir)
+ if err != nil {
+ t.Fatalf("resolveWebStaticDir() error = %v", err)
+ }
+ if got != staticDir {
+ t.Fatalf("resolveWebStaticDir() = %q, want %q", got, staticDir)
+ }
+
+ dirIndex := filepath.Join(tempDir, "bad-dist", "index.html")
+ if err := os.MkdirAll(dirIndex, 0o755); err != nil {
+ t.Fatalf("mkdir %s: %v", dirIndex, err)
+ }
+ if _, err := validateStaticDir(filepath.Dir(dirIndex)); err == nil || !strings.Contains(err.Error(), "is a directory") {
+ t.Fatalf("validateStaticDir() error = %v, want directory error", err)
+ }
+}
+
+func TestIsStaleFrontendBuildBranches(t *testing.T) {
+ tempDir := t.TempDir()
+ webDir := filepath.Join(tempDir, "web")
+ srcDir := filepath.Join(webDir, "src")
+ distIndex := filepath.Join(webDir, "dist", "index.html")
+
+ if !isStaleFrontendBuild(webDir) {
+ t.Fatal("isStaleFrontendBuild() = false, want true when dist is missing")
+ }
+
+ writeWebCommandTestFile(t, distIndex, "")
+ writeWebCommandTestFile(t, filepath.Join(webDir, "package.json"), "{}")
+ writeWebCommandTestFile(t, filepath.Join(webDir, "vite.config.ts"), "export default {}")
+ writeWebCommandTestFile(t, filepath.Join(webDir, "tsconfig.json"), "{}")
+ writeWebCommandTestFile(t, filepath.Join(srcDir, "main.ts"), "console.log('ok')")
+
+ distTime := time.Now()
+ if err := os.Chtimes(distIndex, distTime, distTime); err != nil {
+ t.Fatalf("chtimes dist: %v", err)
+ }
+ olderTime := distTime.Add(-time.Minute)
+ for _, path := range []string{
+ filepath.Join(webDir, "package.json"),
+ filepath.Join(webDir, "vite.config.ts"),
+ filepath.Join(webDir, "tsconfig.json"),
+ filepath.Join(srcDir, "main.ts"),
+ } {
+ if err := os.Chtimes(path, olderTime, olderTime); err != nil {
+ t.Fatalf("chtimes %s: %v", path, err)
+ }
+ }
+
+ if isStaleFrontendBuild(webDir) {
+ t.Fatal("isStaleFrontendBuild() = true, want false when dist is newest")
+ }
+
+ newerTime := distTime.Add(time.Minute)
+ packageJSON := filepath.Join(webDir, "package.json")
+ if err := os.Chtimes(packageJSON, newerTime, newerTime); err != nil {
+ t.Fatalf("chtimes package.json: %v", err)
+ }
+ if !isStaleFrontendBuild(webDir) {
+ t.Fatal("isStaleFrontendBuild() = false, want true when package.json is newer")
+ }
+
+ if err := os.Chtimes(packageJSON, olderTime, olderTime); err != nil {
+ t.Fatalf("restore package.json time: %v", err)
+ }
+ srcFile := filepath.Join(srcDir, "main.ts")
+ if err := os.Chtimes(srcFile, newerTime, newerTime); err != nil {
+ t.Fatalf("chtimes src: %v", err)
+ }
+ if !isStaleFrontendBuild(webDir) {
+ t.Fatal("isStaleFrontendBuild() = false, want true when src file is newer")
+ }
+}
+
+func TestBuildFrontendAndReadGatewayToken(t *testing.T) {
+ tempDir := t.TempDir()
+ webDir := filepath.Join(tempDir, "web")
+ if err := os.MkdirAll(webDir, 0o755); err != nil {
+ t.Fatalf("mkdir webdir: %v", err)
+ }
+
+ npmPath := filepath.Join(tempDir, "npm")
+ script := strings.Join([]string{
+ "#!/bin/sh",
+ "set -eu",
+ "if [ \"$1\" = \"install\" ]; then",
+ " exit 0",
+ "fi",
+ "if [ \"$1\" = \"run\" ] && [ \"$2\" = \"build\" ]; then",
+ " mkdir -p \"$PWD/dist\"",
+ " printf '' > \"$PWD/dist/index.html\"",
+ " exit 0",
+ "fi",
+ "exit 1",
+ }, "\n")
+ if err := os.WriteFile(npmPath, []byte(script), 0o755); err != nil {
+ t.Fatalf("write npm stub: %v", err)
+ }
+ stubWebCommandHooks(t, nil, nil, func(string) (string, error) {
+ return npmPath, nil
+ })
+
+ logger := log.New(&bytes.Buffer{}, "", 0)
+ if err := buildFrontend(webDir, logger); err != nil {
+ t.Fatalf("buildFrontend() error = %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(webDir, "dist", "index.html")); err != nil {
+ t.Fatalf("built dist/index.html missing: %v", err)
+ }
+
+ homeDir := filepath.Join(tempDir, "home")
+ authDir := filepath.Join(homeDir, ".neocode")
+ if err := os.MkdirAll(authDir, 0o755); err != nil {
+ t.Fatalf("mkdir auth dir: %v", err)
+ }
+ authData, err := json.Marshal(map[string]string{"token": " secret-token "})
+ if err != nil {
+ t.Fatalf("marshal auth data: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(authDir, "auth.json"), authData, 0o644); err != nil {
+ t.Fatalf("write auth.json: %v", err)
+ }
+ originalHome := os.Getenv("HOME")
+ if err := os.Setenv("HOME", homeDir); err != nil {
+ t.Fatalf("set HOME: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = os.Setenv("HOME", originalHome)
+ })
+ if got := readGatewayToken(); got != "secret-token" {
+ t.Fatalf("readGatewayToken() = %q, want %q", got, "secret-token")
+ }
+}
+
+func TestWaitForGatewayAndOpenBrowserAndResolveListenAddress(t *testing.T) {
+ tempDir := t.TempDir()
+ homeDir := filepath.Join(tempDir, "home")
+ authDir := filepath.Join(homeDir, ".neocode")
+ if err := os.MkdirAll(authDir, 0o755); err != nil {
+ t.Fatalf("mkdir auth dir: %v", err)
+ }
+ authData, err := json.Marshal(map[string]string{"token": "token-123"})
+ if err != nil {
+ t.Fatalf("marshal auth data: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(authDir, "auth.json"), authData, 0o644); err != nil {
+ t.Fatalf("write auth.json: %v", err)
+ }
+ originalHome := os.Getenv("HOME")
+ if err := os.Setenv("HOME", homeDir); err != nil {
+ t.Fatalf("set HOME: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = os.Setenv("HOME", originalHome)
+ })
+
+ binDir := filepath.Join(tempDir, "bin")
+ if err := os.MkdirAll(binDir, 0o755); err != nil {
+ t.Fatalf("mkdir bin dir: %v", err)
+ }
+ openLog := filepath.Join(tempDir, "opened-url.txt")
+ scriptPath := filepath.Join(binDir, "xdg-open")
+ script := "#!/bin/sh\nprintf '%s' \"$1\" > \"" + openLog + "\"\n"
+ if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
+ t.Fatalf("write xdg-open stub: %v", err)
+ }
+ originalPath := os.Getenv("PATH")
+ if err := os.Setenv("PATH", binDir+string(os.PathListSeparator)+originalPath); err != nil {
+ t.Fatalf("set PATH: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = os.Setenv("PATH", originalPath)
+ })
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/healthz" {
+ http.NotFound(w, r)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ logger := log.New(&bytes.Buffer{}, "", 0)
+ waitForGatewayAndOpenBrowser(context.Background(), strings.TrimPrefix(server.URL, "http://"), logger)
+
+ var data []byte
+ var readErr error
+ for i := 0; i < 20; i++ {
+ data, readErr = os.ReadFile(openLog)
+ if readErr == nil {
+ break
+ }
+ time.Sleep(25 * time.Millisecond)
+ }
+ if readErr != nil {
+ t.Fatalf("read open log: %v", readErr)
+ }
+ if got := string(data); got != server.URL+"/?token=token-123" {
+ t.Fatalf("opened url = %q, want %q", got, server.URL+"/?token=token-123")
+ }
+
+ if got := resolveWebListenAddress("bad-address"); got != "bad-address" {
+ t.Fatalf("resolveWebListenAddress() = %q, want original invalid address", got)
+ }
+
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ defer listener.Close()
+ occupied := listener.Addr().String()
+ resolved := resolveWebListenAddress(occupied)
+ if resolved == occupied {
+ t.Fatalf("resolveWebListenAddress() = %q, want fallback port", resolved)
+ }
+}
+
+func TestResolveWebStaticDirCurrentWorkdirAndReadGatewayTokenInvalid(t *testing.T) {
+ tempDir := t.TempDir()
+ chdirForWebCommandTest(t, tempDir)
+ writeWebCommandTestFile(t, filepath.Join(tempDir, "web", "dist", "index.html"), "")
+ stubResolveExecutablePath(t, func() (string, error) {
+ return "", errors.New("skip executable lookup")
+ })
+
+ got, err := resolveWebStaticDir("")
+ if err != nil {
+ t.Fatalf("resolveWebStaticDir() error = %v", err)
+ }
+ if got != filepath.Join(tempDir, "web", "dist") {
+ t.Fatalf("resolveWebStaticDir() = %q, want cwd web/dist", got)
+ }
+
+ homeDir := filepath.Join(tempDir, "home")
+ authDir := filepath.Join(homeDir, ".neocode")
+ if err := os.MkdirAll(authDir, 0o755); err != nil {
+ t.Fatalf("mkdir auth dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(authDir, "auth.json"), []byte("{invalid"), 0o644); err != nil {
+ t.Fatalf("write invalid auth.json: %v", err)
+ }
+ originalHome := os.Getenv("HOME")
+ if err := os.Setenv("HOME", homeDir); err != nil {
+ t.Fatalf("set HOME: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = os.Setenv("HOME", originalHome)
+ })
+ if got := readGatewayToken(); got != "" {
+ t.Fatalf("readGatewayToken() = %q, want empty on invalid json", got)
+ }
+}
+
+func TestRunWebCommandFallbackAndSkipBuildErrors(t *testing.T) {
+ tempDir := t.TempDir()
+ chdirForWebCommandTest(t, tempDir)
+ stubResolveExecutablePath(t, func() (string, error) {
+ return "", errors.New("skip executable lookup")
+ })
+ stubWebCommandEmbeddedAssets(t, nil, false)
+
+ err := runWebCommand(context.Background(), webCommandOptions{
+ HTTPAddress: "127.0.0.1:8080",
+ LogLevel: "info",
+ SkipBuild: true,
+ OpenBrowser: false,
+ Workdir: tempDir,
+ })
+ if err == nil || !strings.Contains(err.Error(), "--skip-build is set") {
+ t.Fatalf("runWebCommand() error = %v, want skip-build missing assets error", err)
+ }
+
+ writeWebCommandTestFile(t, filepath.Join(tempDir, "web", "package.json"), "{}")
+ err = runWebCommand(context.Background(), webCommandOptions{
+ HTTPAddress: "127.0.0.1:8080",
+ LogLevel: "info",
+ OpenBrowser: false,
+ Workdir: tempDir,
+ })
+ if err == nil || !strings.Contains(err.Error(), "frontend build failed on this machine") {
+ t.Fatalf("runWebCommand() error = %v, want build failure error", err)
+ }
+}
+
+func TestRunWebCommandRebuildsStaleDistAndDefaultsWorkdir(t *testing.T) {
+ tempDir := t.TempDir()
+ chdirForWebCommandTest(t, tempDir)
+ webDir := filepath.Join(tempDir, "web")
+ writeWebCommandTestFile(t, filepath.Join(webDir, "package.json"), "{}")
+ writeWebCommandTestFile(t, filepath.Join(webDir, "vite.config.ts"), "export default {}")
+ writeWebCommandTestFile(t, filepath.Join(webDir, "tsconfig.json"), "{}")
+ writeWebCommandTestFile(t, filepath.Join(webDir, "src", "main.ts"), "console.log('stale')")
+ writeWebCommandTestFile(t, filepath.Join(webDir, "dist", "index.html"), "")
+
+ distIndex := filepath.Join(webDir, "dist", "index.html")
+ oldTime := time.Now().Add(-time.Hour)
+ newTime := time.Now()
+ if err := os.Chtimes(distIndex, oldTime, oldTime); err != nil {
+ t.Fatalf("chtimes dist: %v", err)
+ }
+ for _, path := range []string{
+ filepath.Join(webDir, "package.json"),
+ filepath.Join(webDir, "vite.config.ts"),
+ filepath.Join(webDir, "tsconfig.json"),
+ filepath.Join(webDir, "src", "main.ts"),
+ } {
+ if err := os.Chtimes(path, newTime, newTime); err != nil {
+ t.Fatalf("chtimes %s: %v", path, err)
+ }
+ }
+
+ buildCalled := false
+ var captured gatewayCommandOptions
+ sentinelErr := errors.New("stop after start")
+ stubWebCommandHooks(
+ t,
+ func(_ context.Context, options gatewayCommandOptions, _ string, _ fs.FS, _ func(string)) error {
+ captured = options
+ return sentinelErr
+ },
+ func(webDir string, _ *log.Logger) error {
+ buildCalled = true
+ writeWebCommandTestFile(t, filepath.Join(webDir, "dist", "index.html"), "")
+ return nil
+ },
+ nil,
+ )
+
+ err := runWebCommand(context.Background(), webCommandOptions{
+ HTTPAddress: "127.0.0.1:8080",
+ LogLevel: "info",
+ OpenBrowser: false,
+ })
+ if !errors.Is(err, sentinelErr) {
+ t.Fatalf("runWebCommand() error = %v, want sentinel error", err)
+ }
+ if !buildCalled {
+ t.Fatal("runWebCommand() did not rebuild stale frontend dist")
+ }
+ if captured.Workdir != tempDir {
+ t.Fatalf("gateway workdir = %q, want cwd %q", captured.Workdir, tempDir)
+ }
+}
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/components/layout/Sidebar.test.tsx b/web/src/components/layout/Sidebar.test.tsx
index 5e1ddf32d..1524b17b1 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,50 @@ 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(