diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000000..c25ae2a6fc --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,104 @@ +# Changes Summary: Race Condition Fixes with Optimistic Locking + +## Overview + +This implementation addresses race conditions in tab metadata updates (spec-004) by implementing optimistic locking with version checking. The changes prevent TOCTOU (Time-Of-Check-Time-Of-Use) vulnerabilities in concurrent metadata operations. + +## Files Modified + +### Backend (Go) + +#### `pkg/wstore/wstore_dbops.go` +- Added `ErrVersionMismatch` error variable for concurrent modification detection +- Added `ErrObjectLocked` error variable for lock state rejection + +#### `pkg/wstore/wstore.go` +- Added `UpdateObjectMetaWithVersion()` function: + - Performs optimistic locking update with version checking + - If `expectedVersion > 0` and doesn't match current version, returns `ErrVersionMismatch` + - If `expectedVersion == 0`, behaves like `UpdateObjectMeta` (no version check) + +- Added `UpdateObjectMetaIfNotLocked()` function: + - Atomically checks lock and updates metadata + - Lock is checked INSIDE the transaction, eliminating TOCTOU vulnerability + - Returns `ErrObjectLocked` (wrapped in `ErrVersionMismatch`) if locked + - Returns `ErrVersionMismatch` if version doesn't match + +#### `pkg/service/objectservice/objectservice.go` +- Added `UpdateObjectMetaWithVersion()` RPC service method +- Added `UpdateObjectMetaIfNotLocked()` RPC service method +- Both methods include proper metadata annotations for TypeScript binding generation + +### Frontend (TypeScript) + +#### `frontend/app/view/term/termwrap.ts` +- Added debounce map (`osc7DebounceMap`) for OSC 7 updates per tab +- Added `OSC7_DEBOUNCE_MS = 300` constant for debounce delay +- Added `clearOsc7Debounce()` helper function +- Added `cleanupOsc7DebounceForTab()` exported function for memory leak prevention +- Updated `handleOsc7Command()` to: + - Add null safety check for `tabData?.oid` + - Use debouncing to reduce race condition window + - Use atomic lock-aware update (`UpdateObjectMetaIfNotLocked`) instead of regular update + - Gracefully handle version mismatch and locked state errors + +#### `frontend/app/tab/tab.tsx` +- Added `getApi` to imports from `@/app/store/global` (fix for pre-existing missing import) + +### Generated Files + +#### `frontend/app/store/services.ts` +- Auto-generated new TypeScript methods: + - `UpdateObjectMetaWithVersion(oref, meta, expectedVersion)` + - `UpdateObjectMetaIfNotLocked(oref, meta, lockKey, expectedVersion)` + +## Key Features Implemented + +### 1. Optimistic Locking +- Uses existing `version` field in WaveObj types +- Version checked inside transaction to prevent TOCTOU +- Atomic increment of version on successful update (already implemented in `DBUpdate`) + +### 2. Error Types +- **ErrVersionMismatch**: Indicates concurrent modification detected +- **ErrObjectLocked**: Indicates update rejected due to lock state +- Both errors are wrapped appropriately for consistent error handling + +### 3. OSC 7 Debouncing +- 300ms debounce window for rapid directory changes +- Per-tab debounce timers in a Map +- Cleanup function to prevent memory leaks on tab close + +### 4. Atomic Lock Checking +- Lock state checked INSIDE database transaction +- Eliminates race condition between lock check and update +- If lock is toggled during update, the update is safely rejected + +## Acceptance Criteria Status + +- [x] `UpdateObjectMetaWithVersion` added to `wstore.go` +- [x] RPC endpoints added to `objectservice.go` +- [x] OSC 7 debounce map with cleanup function +- [x] Null safety guards in `termwrap.ts` +- [x] `ErrVersionMismatch` error type created +- [x] `ErrObjectLocked` error type created +- [x] TypeScript compilation passes (our files) +- [x] Go compilation passes +- [x] Changes committed + +## Testing Notes + +To test the implementation: + +1. **Version Mismatch Test**: Open two terminals in the same tab, rapidly change directories in both - the race condition should be handled gracefully + +2. **Lock Bypass Test**: Toggle the lock while an OSC 7 update is in flight - the update should be rejected if lock is set + +3. **Debounce Test**: Rapidly `cd` between directories - only the final directory should be set as basedir + +4. **Memory Leak Test**: Open and close multiple tabs - the debounce map should be cleaned up + +## Notes + +- The spec mentions retry logic for manual updates (handleSetBaseDir, handleToggleLock) - this was NOT implemented as the spec noted it as optional for Phase 4 and the core race condition fixes are functional without it +- Pre-existing TypeScript errors in unrelated files (streamdown.tsx, notificationpopover.tsx) remain unfixed as they are not related to this implementation diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..1471fd7eaf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,386 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Wave Terminal is an open-source, AI-native terminal built with Electron. It combines traditional terminal features with graphical capabilities like file previews, web browsing, and AI assistance. The architecture consists of four main components: + +1. **Frontend** (React + TypeScript) - UI and user interactions +2. **emain** (Electron Main Process) - Window management, native OS integration +3. **wavesrv** (Go Backend) - Core business logic, database, remote connections +4. **wsh** (Go CLI/Server) - Command-line tool and remote multiplexing server + +## Build System + +The project uses **Task** (modern Make alternative) for build orchestration. See `Taskfile.yml` for all available tasks. + +### Common Commands + +```bash +# Install dependencies (run this first after cloning) +task init + +# Development server with hot reload +task dev + +# Run standalone without hot reload +task start + +# Production build and packaging +task package + +# TypeScript type checking +task check:ts + +# Run tests +npm test + +# Run tests with coverage +npm run coverage + +# Clean build artifacts +task clean +``` + +### Quick Development Shortcuts + +```bash +# Fast development mode (macOS ARM64 only, no docsite, no wsh) +task electron:quickdev + +# Fast development mode (Windows x64 only, no docsite, no wsh) +task electron:winquickdev +``` + +### Code Generation + +The project uses code generators to maintain type safety between Go and TypeScript: + +```bash +# Generate TypeScript bindings from Go types +task generate + +# This runs: +# - cmd/generatets/main-generatets.go -> frontend/types/gotypes.d.ts +# - cmd/generatets/main-generatets.go -> frontend/app/store/services.ts +# - cmd/generatets/main-generatets.go -> frontend/app/store/wshclientapi.ts +# - cmd/generatego/main-generatego.go -> various Go files +``` + +**Always run `task generate` after modifying:** +- Go RPC types in `pkg/wshrpc/` +- Service definitions in `pkg/service/` +- Wave object types in `pkg/waveobj/` + +## Architecture Overview + +### Frontend Architecture + +**Entry Point:** `frontend/wave.ts` +- Initializes the application with either Wave Terminal mode or Tsunami Builder mode +- Sets up Jotai store, WPS (WebSocket Pub/Sub), and Monaco editor +- Root React component: `frontend/app/app.tsx` + +**State Management:** Jotai (atom-based state) +- Global atoms defined in `frontend/app/store/global.ts` +- Store instance: `globalStore` (exported from `frontend/app/store/jotaiStore.ts`) +- Key models: `GlobalModel`, `TabModel`, `ConnectionsModel` + +**Key Frontend Directories:** +- `frontend/app/block/` - Terminal blocks and renderers +- `frontend/app/view/` - Different view types (terminal, preview, web, etc.) +- `frontend/app/workspace/` - Workspace and tab layout management +- `frontend/layout/` - Layout system using `react-resizable-panels` +- `frontend/app/store/` - State management, RPC clients, WOS (Wave Object Store) +- `frontend/app/element/` - Reusable UI components +- `frontend/app/monaco/` - Monaco editor integration + +**Hot Module Reloading:** +- Vite enables HMR for most changes +- State changes (Jotai atoms, layout) may require hard reload: `Cmd+Shift+R` / `Ctrl+Shift+R` + +### Electron Main Process (emain) + +**Entry Point:** `emain/emain.ts` +- Manages Electron app lifecycle and window creation +- Spawns and manages the `wavesrv` backend process +- Handles native menus, context menus, and OS integration + +**IPC Communication:** +- Functions exposed from emain to frontend are defined in two places: + 1. `emain/preload.ts` - Electron preload script + 2. `frontend/types/custom.d.ts` - TypeScript declarations +- Frontend calls: `getApi().()` + +**Key emain Files:** +- `emain/emain.ts` - Main entry point +- `emain/emain-window.ts` - Window management +- `emain/emain-menu.ts` - Menu bar and context menus +- `emain/emain-wavesrv.ts` - wavesrv process management +- `emain/emain-tabview.ts` - Tab view management +- `emain/preload.ts` - Preload script for renderer + +### Go Backend (wavesrv) + +**Entry Point:** `cmd/server/main-server.go` + +**Core Packages:** +- `pkg/wstore/` - Database operations and Wave object persistence +- `pkg/waveobj/` - Wave object type definitions (Client, Window, Tab, Block, etc.) +- `pkg/service/` - HTTP service endpoints +- `pkg/wshrpc/` - WebSocket RPC system (communication with frontend and wsh) +- `pkg/blockcontroller/` - Terminal block lifecycle management +- `pkg/remote/` - SSH and remote connection handling +- `pkg/wcloud/` - Cloud sync and authentication +- `pkg/waveai/` - AI integration (OpenAI, Claude, etc.) +- `pkg/filestore/` - File storage and management + +**Database:** +- SQLite databases in `db/migrations-wstore/` and `db/migrations-filestore/` +- Wave objects: `Client`, `Window`, `Workspace`, `Tab`, `Block`, `LayoutState` +- All Wave object types registered in `pkg/waveobj/waveobj.go` + +**RPC Communication:** +- Uses custom `wshrpc` protocol over WebSocket +- RPC types defined in `pkg/wshrpc/wshrpctypes.go` +- Commands implemented in `pkg/wshrpc/wshserver/` and `pkg/wshrpc/wshremote/` + +### wsh (Wave Shell) + +**Entry Point:** `cmd/wsh/main-wsh.go` + +**Dual Purpose:** +1. CLI tool for controlling Wave from the terminal +2. Remote server for multiplexing connections and file streaming + +**Communication:** +- Uses `wshrpc` protocol over domain socket or WebSocket +- Enables single-connection multiplexing for remote terminals + +## Development Guidelines + +### Frontend Development + +1. **Use existing patterns:** Before adding new components, search for similar features: + ```bash + # Find similar views + grep -r "registerView" frontend/app/view/ + + # Find block implementations + ls frontend/app/block/ + ``` + +2. **State management:** Use Jotai atoms for reactive state + - Global atoms in `frontend/app/store/global.ts` + - Component-local atoms using `atom()` from `jotai` + +3. **RPC calls:** Use the generated `RpcApi` from `frontend/app/store/wshclientapi.ts`: + ```typescript + import { RpcApi } from "@/app/store/wshclientapi"; + import { TabRpcClient } from "@/app/store/wshrpcutil"; + + const result = await RpcApi.SomeCommand(TabRpcClient, { param: "value" }); + ``` + +4. **Wave Objects:** Access via WOS (Wave Object Store): + ```typescript + import * as WOS from "@/store/wos"; + + const tab = WOS.getObjectValue(WOS.makeORef("tab", tabId)); + ``` + +### Backend Development + +1. **Database changes:** Add migrations to `db/migrations-wstore/` or `db/migrations-filestore/` + +2. **New RPC commands:** + - Define in `pkg/wshrpc/wshrpctypes.go` + - Implement handler in `pkg/wshrpc/wshserver/` + - Run `task generate` to update TypeScript bindings + +3. **New Wave object types:** + - Add to `pkg/waveobj/wtype.go` + - Register in `init()` function + - Run `task generate` + +4. **Testing:** Write tests in `*_test.go` files: + ```bash + # Run Go tests + go test ./pkg/... + + # Run specific package + go test ./pkg/wstore/ + ``` + +### Code Style + +- **TypeScript:** Prettier + ESLint (configured in `eslint.config.js`, `prettier.config.cjs`) +- **Go:** Standard `go fmt` + `staticcheck` (see `staticcheck.conf`) +- **Text files:** Must end with a newline (`.editorconfig`) + +## Tsunami Framework + +Tsunami is Wave's internal UI framework for building reactive Go-based UIs that render in the terminal. + +**Location:** `tsunami/` directory +- `tsunami/engine/` - Core rendering engine +- `tsunami/frontend/` - React components for Tsunami UIs +- `tsunami/vdom/` - Virtual DOM implementation + +**Usage:** +- Powers the WaveApp Builder (`/builder/`) +- Scaffold for new Tsunami apps in `dist/tsunamiscaffold/` + +**Build Tsunami:** +```bash +task tsunami:frontend:build +task build:tsunamiscaffold +``` + +## Testing & Debugging + +### Frontend Debugging + +- **DevTools:** `Cmd+Option+I` (macOS) or `Ctrl+Option+I` (Windows/Linux) +- **Console access to global state:** + ```javascript + globalStore + globalAtoms + WOS + RpcApi + ``` + +### Backend Debugging + +- **Logs:** `~/.waveterm-dev/waveapp.log` (development mode) +- Contains both NodeJS (emain) and Go (wavesrv) logs + +### Running Tests + +```bash +# TypeScript/React tests +npm test + +# With coverage +npm run coverage + +# Go tests +go test ./pkg/... +``` + +## File Organization Conventions + +- **Go files:** `packagename_descriptor.go` (e.g., `waveobj_wtype.go`) +- **TypeScript files:** `component-name.tsx`, `util-name.ts` +- **SCSS files:** `component-name.scss` +- **Test files:** `*_test.go`, `*.test.ts`, `*.test.tsx` + +## Platform-Specific Notes + +### Windows + +- Use Zig for CGO static linking +- Use `task electron:winquickdev` for fast iteration +- Backslashes in file paths for Edit/MultiEdit tools + +### Linux + +- Requires Zig for CGO static linking +- Platform-specific dependencies in `BUILD.md` +- Use `USE_SYSTEM_FPM=1 task package` on ARM64 + +### macOS + +- No special dependencies +- `task electron:quickdev` works on ARM64 only + +## Important Paths + +- **Frontend entry:** `frontend/wave.ts` +- **Main React app:** `frontend/app/app.tsx` +- **Electron main:** `emain/emain.ts` +- **Go backend entry:** `cmd/server/main-server.go` +- **wsh entry:** `cmd/wsh/main-wsh.go` +- **Generated types:** `frontend/types/gotypes.d.ts` +- **RPC API:** `frontend/app/store/wshclientapi.ts` +- **Dev logs:** `~/.waveterm-dev/waveapp.log` + +## Common Gotchas + +1. **After changing Go types, always run `task generate`** - TypeScript bindings won't update automatically +2. **emain and wavesrv don't hot-reload** - Must restart `task dev` to see changes +3. **Jotai atom changes may break HMR** - Use hard reload (`Cmd+Shift+R`) +4. **Database schema changes require migrations** - Never modify schema directly +5. **Wave objects must be registered** - Add to `init()` in `pkg/waveobj/waveobj.go` + +## Tab Base Directory Feature + +Wave Terminal supports per-tab base directories that provide a project-centric workflow where all terminals and widgets within a tab share the same working directory context. + +### Metadata Keys + +| Key | Type | Description | +|-----|------|-------------| +| `tab:basedir` | `string` | Absolute path to base directory | +| `tab:basedirlock` | `boolean` | When true, disables smart auto-detection | + +### Behavior Model + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TAB │ +│ tab:basedir = "/home/user/project" │ +│ tab:basedirlock = false │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │ +│ │ Terminal 1 │ │ Terminal 2 │ │ File View │ │ +│ │ cmd:cwd = ... │ │ cmd:cwd = ... │ │ file = ... │ │ +│ │ (inherits tab) │ │ (inherits tab) │ │ (inherits) │ │ +│ └─────────────────┘ └─────────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Smart Auto-Detection (OSC 7) + +When a terminal reports its working directory via OSC 7: + +1. **Always:** Updates block's `cmd:cwd` metadata +2. **Conditionally:** Updates tab's `tab:basedir` if: + - `tab:basedirlock` is false + - `tab:basedir` is empty OR equals "~" + +This allows the first terminal to "teach" the tab its project directory. + +### Lock Semantics + +| State | Behavior | +|-------|----------| +| Unlocked (default) | OSC 7 can update `tab:basedir` (under conditions) | +| Locked | Only manual setting changes `tab:basedir` | + +### File Locations + +| Purpose | File | +|---------|------| +| Tab context menu UI | `frontend/app/tab/tab.tsx` | +| OSC 7 handling | `frontend/app/view/term/termwrap.ts` | +| Terminal inheritance | `frontend/app/store/keymodel.ts` | +| Widget inheritance | `frontend/app/workspace/widgets.tsx` | +| Go type definitions | `pkg/waveobj/wtypemeta.go` | +| Metadata constants | `pkg/waveobj/metaconsts.go` | + +### Related Presets + +Tab variable presets can include base directory configuration: + +```json +// File: pkg/wconfig/defaultconfig/presets/tabvars.json +{ + "tabvar@my-project": { + "display:name": "My Project", + "tab:basedir": "/home/user/my-project", + "tab:basedirlock": true + } +} +``` diff --git a/Taskfile.yml b/Taskfile.yml index 1e6947de6c..00c57895d9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -8,8 +8,10 @@ vars: BIN_DIR: "bin" VERSION: sh: node version.cjs - RMRF: '{{if eq OS "windows"}}powershell Remove-Item -Force -Recurse -ErrorAction SilentlyContinue{{else}}rm -rf{{end}}' - DATE: '{{if eq OS "windows"}}powershell Get-Date -UFormat{{else}}date{{end}}' + RMRF: '{{if eq OS "windows"}}pwsh -NoProfile -Command Remove-Item -Force -Recurse -ErrorAction SilentlyContinue{{else}}rm -rf{{end}}' + DATE: '{{if eq OS "windows"}}pwsh -NoProfile -Command "Get-Date -UFormat"{{else}}date{{end}}' + BUILD_TIME: + sh: '{{if eq OS "windows"}}pwsh -NoProfile -Command "Get-Date -UFormat +%Y%m%d%H%M"{{else}}date +%Y%m%d%H%M{{end}}' ARTIFACTS_BUCKET: waveterm-github-artifacts/staging-w2 RELEASES_BUCKET: dl.waveterm.dev/releases-w2 WINGET_PACKAGE: CommandLine.Wave @@ -223,7 +225,7 @@ tasks: desc: Build the wavesrv component for Windows platforms (only generates artifacts for the current architecture). platforms: [windows] cmds: - - cmd: powershell -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wavesrv*" + - cmd: pwsh -NoProfile -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wavesrv*" ignore_error: true - task: build:server:internal vars: @@ -250,7 +252,7 @@ tasks: vars: - ARCHS cmd: - cmd: CGO_ENABLED=1 GOARCH={{.GOARCH}} {{.GO_ENV_VARS}} go build -tags "osusergo,sqlite_omit_load_extension" -ldflags "{{.GO_LDFLAGS}} -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wavesrv.{{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}}{{exeExt}} cmd/server/main-server.go + cmd: CGO_ENABLED=1 GOARCH={{.GOARCH}} {{.GO_ENV_VARS}} go build -tags "osusergo,sqlite_omit_load_extension" -ldflags "{{.GO_LDFLAGS}} -X main.BuildTime={{.BUILD_TIME}} -X main.WaveVersion={{.VERSION}}" -o dist/bin/wavesrv.{{if eq .GOARCH "amd64"}}x64{{else}}{{.GOARCH}}{{end}}{{exeExt}} cmd/server/main-server.go for: var: ARCHS split: "," @@ -263,7 +265,7 @@ tasks: - cmd: rm -f dist/bin/wsh* platforms: [darwin, linux] ignore_error: true - - cmd: powershell -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wsh*" + - cmd: pwsh -NoProfile -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wsh*" platforms: [windows] ignore_error: true - task: build:wsh:internal @@ -318,7 +320,7 @@ tasks: - GOOS - GOARCH - VERSION - cmd: (CGO_ENABLED=0 GOOS={{.GOOS}} GOARCH={{.GOARCH}} go build -ldflags="-s -w -X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.WaveVersion={{.VERSION}}" -o dist/bin/wsh-{{.VERSION}}-{{.GOOS}}.{{.NORMALIZEDARCH}}{{.EXT}} cmd/wsh/main-wsh.go) + cmd: (CGO_ENABLED=0 GOOS={{.GOOS}} GOARCH={{.GOARCH}} go build -ldflags="-s -w -X main.BuildTime={{.BUILD_TIME}} -X main.WaveVersion={{.VERSION}}" -o dist/bin/wsh-{{.VERSION}}-{{.GOOS}}.{{.NORMALIZEDARCH}}{{.EXT}} cmd/wsh/main-wsh.go) internal: true build:tsunamiscaffold: @@ -327,7 +329,7 @@ tasks: - cmd: "{{.RMRF}} dist/tsunamiscaffold" ignore_error: true - task: copyfiles:'tsunami/frontend/scaffold':'dist/tsunamiscaffold' - - cmd: '{{if eq OS "windows"}}powershell Copy-Item -Path tsunami/templates/empty-gomod.tmpl -Destination dist/tsunamiscaffold/go.mod{{else}}cp tsunami/templates/empty-gomod.tmpl dist/tsunamiscaffold/go.mod{{end}}' + - cmd: '{{if eq OS "windows"}}pwsh -NoProfile -Command Copy-Item -Path tsunami/templates/empty-gomod.tmpl -Destination dist/tsunamiscaffold/go.mod{{else}}cp tsunami/templates/empty-gomod.tmpl dist/tsunamiscaffold/go.mod{{end}}' deps: - tsunami:scaffold sources: @@ -486,7 +488,7 @@ tasks: copyfiles:*:*: desc: Recursively copy directory and its contents. internal: true - cmd: '{{if eq OS "windows"}}powershell Copy-Item -Recurse -Force -Path {{index .MATCH 0}} -Destination {{index .MATCH 1}}{{else}}mkdir -p "$(dirname {{index .MATCH 1}})" && cp -r {{index .MATCH 0}} {{index .MATCH 1}}{{end}}' + cmd: '{{if eq OS "windows"}}pwsh -NoProfile -Command Copy-Item -Recurse -Force -Path {{index .MATCH 0}} -Destination {{index .MATCH 1}}{{else}}mkdir -p "$(dirname {{index .MATCH 1}})" && cp -r {{index .MATCH 0}} {{index .MATCH 1}}{{end}}' clean: desc: clean make/dist directories @@ -539,7 +541,7 @@ tasks: - cmd: rm -f package.json platforms: [darwin, linux] ignore_error: true - - cmd: powershell -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path package.json" + - cmd: pwsh -NoProfile -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path package.json" platforms: [windows] ignore_error: true - npm --no-workspaces init -y --init-license Apache-2.0 @@ -585,18 +587,18 @@ tasks: cmds: - cmd: "{{.RMRF}} scaffold" ignore_error: true - - powershell New-Item -ItemType Directory -Force -Path scaffold - - powershell Copy-Item -Path ../templates/package.json.tmpl -Destination scaffold/package.json - - powershell -Command "Set-Location scaffold; npm install" - - powershell Move-Item -Path scaffold/node_modules -Destination scaffold/nm - - powershell Copy-Item -Recurse -Force -Path dist -Destination scaffold/ - - powershell New-Item -ItemType Directory -Force -Path scaffold/dist/tw - - powershell Copy-Item -Path '../templates/*.go.tmpl' -Destination scaffold/ - - powershell Copy-Item -Path ../templates/tailwind.css -Destination scaffold/ - - powershell Copy-Item -Path ../templates/gitignore.tmpl -Destination scaffold/.gitignore - - powershell Copy-Item -Path 'src/element/*.tsx' -Destination scaffold/dist/tw/ - - powershell Copy-Item -Path '../ui/*.go' -Destination scaffold/dist/tw/ - - powershell Copy-Item -Path ../engine/errcomponent.go -Destination scaffold/dist/tw/ + - pwsh -NoProfile -Command New-Item -ItemType Directory -Force -Path scaffold + - pwsh -NoProfile -Command Copy-Item -Path ../templates/package.json.tmpl -Destination scaffold/package.json + - pwsh -NoProfile -Command "Set-Location scaffold; npm install" + - pwsh -NoProfile -Command Move-Item -Path scaffold/node_modules -Destination scaffold/nm + - pwsh -NoProfile -Command Copy-Item -Recurse -Force -Path dist -Destination scaffold/ + - pwsh -NoProfile -Command New-Item -ItemType Directory -Force -Path scaffold/dist/tw + - pwsh -NoProfile -Command Copy-Item -Path '../templates/*.go.tmpl' -Destination scaffold/ + - pwsh -NoProfile -Command Copy-Item -Path ../templates/tailwind.css -Destination scaffold/ + - pwsh -NoProfile -Command Copy-Item -Path ../templates/gitignore.tmpl -Destination scaffold/.gitignore + - pwsh -NoProfile -Command Copy-Item -Path 'src/element/*.tsx' -Destination scaffold/dist/tw/ + - pwsh -NoProfile -Command Copy-Item -Path '../ui/*.go' -Destination scaffold/dist/tw/ + - pwsh -NoProfile -Command Copy-Item -Path ../engine/errcomponent.go -Destination scaffold/dist/tw/ tsunami:build: desc: Build the tsunami binary. @@ -604,11 +606,11 @@ tasks: - cmd: rm -f bin/tsunami* platforms: [darwin, linux] ignore_error: true - - cmd: powershell -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path bin/tsunami*" + - cmd: pwsh -NoProfile -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path bin/tsunami*" platforms: [windows] ignore_error: true - mkdir -p bin - - cd tsunami && go build -ldflags "-X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.TsunamiVersion={{.VERSION}}" -o ../bin/tsunami{{exeExt}} cmd/main-tsunami.go + - cd tsunami && go build -ldflags "-X main.BuildTime={{.BUILD_TIME}} -X main.TsunamiVersion={{.VERSION}}" -o ../bin/tsunami{{exeExt}} cmd/main-tsunami.go sources: - "tsunami/**/*.go" - "tsunami/go.mod" diff --git a/docs/docs/tabs.mdx b/docs/docs/tabs.mdx index 354089be4c..11fe415952 100644 --- a/docs/docs/tabs.mdx +++ b/docs/docs/tabs.mdx @@ -45,6 +45,71 @@ You can switch to an existing tab by clicking on it in the tab bar. You can also Pinning a tab makes it harder to close accidentally. You can pin a tab by right-clicking on it and selecting "Pin Tab" from the context menu that appears. You can also pin a tab by dragging it to a lesser index than an existing pinned tab. When a tab is pinned, the button for the tab will be replaced with a button. Clicking this button will unpin the tab. You can also unpin a tab by dragging it to an index higher than an existing unpinned tab. +### Tab Base Directory + +You can set a base directory for each tab to create project-centric workflows. All terminals and file previews launched within the tab will use this directory as their starting point. + +#### Setting the Base Directory + +1. Right-click on a tab in the tab bar +2. Select "Base Directory" from the context menu +3. Click "Set Base Directory..." +4. Choose a directory using the file picker + +Once set, the base directory will be displayed in the tab header with a folder icon. All new terminals opened in that tab will start in this directory. + +#### Smart Auto-Detection + +Wave Terminal can automatically detect and set the base directory from your terminal's working directory. When you `cd` into a project directory, Wave uses OSC 7 escape sequences to learn this location. + +Auto-detection occurs when: +- The tab has no base directory set yet +- The base directory is not locked + +This means the first directory you navigate to in a new tab becomes the tab's base directory automatically. + +#### Locking the Base Directory + +To prevent auto-detection from changing your base directory: + +1. Right-click on the tab +2. Select "Base Directory" +3. Click "Lock (Disable Smart Detection)" + +A lock icon will appear next to the base directory indicator in the tab header. When locked, you can still manually change the base directory, but terminal navigation will not affect it. Click the lock icon directly to toggle the lock state. + +#### Clearing the Base Directory + +To remove the base directory and return to default behavior: + +1. Right-click on the tab +2. Select "Base Directory" +3. Click "Clear Base Directory" + +#### Use Cases + +- **Project-centric workflows:** Set tab base directory to project root; all terminals start there +- **Multi-project organization:** Different tabs for different projects, each with its own base directory +- **Stable context:** Lock the base directory when navigating to multiple subdirectories + +#### Configuration via Presets + +You can create presets that include base directory configuration. Add entries to your settings: + +```json +{ + "presets": { + "tabvar@my-project": { + "display:name": "My Project", + "tab:basedir": "/path/to/my-project", + "tab:basedirlock": true + } + } +} +``` + +Apply presets via the tab's right-click menu: "Tab Variables" > Select preset + ## Tab Layout System The tabs are comprised of tiled blocks. The contents of each block is a single widget. You can move blocks around and arrange them into layouts that best-suit your workflow. You can also magnify blocks to focus on a specific widget. diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 09ba69d1eb..996cb3b2dd 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -453,4 +453,90 @@ export function initIpcHandlers() { electron.ipcMain.on("do-refresh", (event) => { event.sender.reloadIgnoringCache(); }); + + electron.ipcMain.handle( + "show-open-dialog", + async ( + event: electron.IpcMainInvokeEvent, + options: { + title?: string; + defaultPath?: string; + properties?: Array<"openFile" | "openDirectory" | "multiSelections" | "showHiddenFiles">; + filters?: Array<{ name: string; extensions: string[] }>; + } + ): Promise => { + // SECURITY: Restrict to directory selection only for this feature + const allowedProperties = + options.properties?.filter((p) => ["openDirectory", "showHiddenFiles"].includes(p)) || + ["openDirectory"]; + + // SECURITY: Sanitize defaultPath + let sanitizedDefaultPath = options.defaultPath; + if (sanitizedDefaultPath) { + // CRITICAL SECURITY: Block UNC paths on Windows to prevent network attacks + // UNC paths like \\attacker.com\share can leak credentials or data + if (process.platform === "win32" && /^[\\/]{2}[^\\/]/.test(sanitizedDefaultPath)) { + console.warn("show-open-dialog: blocked UNC path in defaultPath:", sanitizedDefaultPath); + sanitizedDefaultPath = electronApp.getPath("home"); + } else { + // Expand home directory shorthand + if (sanitizedDefaultPath.startsWith("~")) { + sanitizedDefaultPath = sanitizedDefaultPath.replace(/^~/, electronApp.getPath("home")); + } + // Normalize path to resolve any .. components + sanitizedDefaultPath = path.normalize(sanitizedDefaultPath); + + // Validate the path exists and is accessible + try { + await fs.promises.access(sanitizedDefaultPath, fs.constants.R_OK); + } catch { + // Fall back to home directory if path doesn't exist or isn't readable + sanitizedDefaultPath = electronApp.getPath("home"); + } + } + } + + // Get the appropriate parent window + const ww = getWaveWindowByWebContentsId(event.sender.id); + const parentWindow = ww ?? electron.BrowserWindow.getFocusedWindow(); + + const result = await electron.dialog.showOpenDialog(parentWindow, { + title: options.title ?? "Select Directory", + defaultPath: sanitizedDefaultPath, + properties: allowedProperties as electron.OpenDialogOptions["properties"], + filters: options.filters, + }); + + // Return empty array if canceled + if (result.canceled || !result.filePaths) { + return []; + } + + // SECURITY: Validate returned paths + const validPaths: string[] = []; + for (const filePath of result.filePaths) { + // CRITICAL SECURITY: Block UNC paths in returned values on Windows + if (process.platform === "win32" && /^[\\/]{2}[^\\/]/.test(filePath)) { + console.warn("show-open-dialog: blocked UNC path in result:", filePath); + continue; + } + + try { + const stats = await fs.promises.stat(filePath); + if (allowedProperties.includes("openDirectory")) { + if (stats.isDirectory()) { + validPaths.push(filePath); + } + } else { + validPaths.push(filePath); + } + } catch { + // Skip paths that can't be accessed + console.warn("show-open-dialog: skipping inaccessible path:", filePath); + } + } + + return validPaths; + } + ); } diff --git a/emain/preload.ts b/emain/preload.ts index c6bdf14988..59eec51e37 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -68,6 +68,12 @@ contextBridge.exposeInMainWorld("api", { openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId), setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), doRefresh: () => ipcRenderer.send("do-refresh"), + showOpenDialog: (options: { + title?: string; + defaultPath?: string; + properties?: Array<"openFile" | "openDirectory" | "multiSelections" | "showHiddenFiles">; + filters?: Array<{ name: string; extensions: string[] }>; + }) => ipcRenderer.invoke("show-open-dialog", options), }); // Custom event for "new-window" diff --git a/frontend/app/monaco/schemaendpoints.ts b/frontend/app/monaco/schemaendpoints.ts index 2b3134e215..839bb61498 100644 --- a/frontend/app/monaco/schemaendpoints.ts +++ b/frontend/app/monaco/schemaendpoints.ts @@ -5,6 +5,7 @@ import settingsSchema from "../../../schema/settings.json"; import connectionsSchema from "../../../schema/connections.json"; import aipresetsSchema from "../../../schema/aipresets.json"; import bgpresetsSchema from "../../../schema/bgpresets.json"; +import tabvarspresetsSchema from "../../../schema/tabvarspresets.json"; import waveaiSchema from "../../../schema/waveai.json"; import widgetsSchema from "../../../schema/widgets.json"; @@ -35,6 +36,11 @@ const MonacoSchemas: SchemaInfo[] = [ fileMatch: ["*/WAVECONFIGPATH/presets/bg.json"], schema: bgpresetsSchema, }, + { + uri: "wave://schema/tabvarspresets.json", + fileMatch: ["*/WAVECONFIGPATH/presets/tabvars.json", "*/WAVECONFIGPATH/presets/tabvars/*.json"], + schema: tabvarspresetsSchema, + }, { uri: "wave://schema/waveai.json", fileMatch: ["*/WAVECONFIGPATH/waveai.json"], diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index fe6e5b1685..696d177e2b 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -116,6 +116,14 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { }) as Atom; // this is *the* tab that this tabview represents. it should never change. const staticTabIdAtom: Atom = atom(initOpts.tabId); + const activeTabAtom: Atom = atom((get) => { + const staticTabId = get(staticTabIdAtom); + // Guard against both null/undefined AND empty string + if (staticTabId == null || staticTabId === "") { + return null; + } + return WOS.getObjectValue(WOS.makeORef("tab", staticTabId), get); + }); const controlShiftDelayAtom = atom(false); const updaterStatusAtom = atom("up-to-date") as PrimitiveAtom; try { @@ -169,6 +177,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { settingsAtom, hasCustomAIPresetsAtom, staticTabId: staticTabIdAtom, + activeTab: activeTabAtom, isFullScreen: isFullScreenAtom, zoomFactorAtom, controlShiftDelayAtom, @@ -189,7 +198,6 @@ function initGlobalWaveEventSubs(initOpts: WaveInitOpts) { { eventType: "waveobj:update", handler: (event) => { - // console.log("waveobj:update wave event handler", event); const update: WaveObjUpdate = event.data; WOS.updateWaveObject(update); }, diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 318d1d775a..7a1c5635b0 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -21,6 +21,7 @@ import { WOS, } from "@/app/store/global"; import { getActiveTabModel } from "@/app/store/tab-model"; +import { cleanupOsc7DebounceForTab } from "@/app/view/term/termwrap"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { deleteLayoutModelForTab, getLayoutModelForStaticTab, NavigateDirection } from "@/layout/index"; import * as keyutil from "@/util/keyutil"; @@ -127,6 +128,8 @@ function getStaticTabBlockCount(): number { function simpleCloseStaticTab() { const ws = globalStore.get(atoms.workspace); const tabId = globalStore.get(atoms.staticTabId); + // Clean up OSC 7 debounce timers for this tab to prevent memory leaks + cleanupOsc7DebounceForTab(tabId); getApi().closeTab(ws.oid, tabId); deleteLayoutModelForTab(tabId); } @@ -314,7 +317,7 @@ function globalRefocus() { refocusNode(blockId); } -function getDefaultNewBlockDef(): BlockDef { +async function getDefaultNewBlockDef(): Promise { const adnbAtom = getSettingsKeyAtom("app:defaultnewblock"); const adnb = globalStore.get(adnbAtom) ?? "term"; if (adnb == "launcher") { @@ -331,6 +334,36 @@ function getDefaultNewBlockDef(): BlockDef { controller: "shell", }, }; + + // ===== Tab Base Directory Inheritance ===== + // When creating new terminals via keyboard shortcuts (e.g., Cmd+N, Cmd+D), + // inherit the tab's base directory as the terminal's initial working directory. + // This ensures new terminals in the same tab start in the same project context. + // + // Inheritance priority: + // 1. Focused block's cmd:cwd (copy directory from existing terminal) + // 2. Tab's tab:basedir (use tab-level project directory) + // 3. Default (typically home directory ~) + const tabData = globalStore.get(atoms.activeTab); + let tabBaseDir = tabData?.meta?.["tab:basedir"]; + + // Pre-use validation: quickly validate tab basedir before using it + if (tabBaseDir && tabBaseDir.trim() !== "") { + try { + const { validateTabBasedir } = await import("@/store/tab-basedir-validator"); + const validationResult = await validateTabBasedir(tabData.oid, tabBaseDir); + if (!validationResult.valid) { + console.warn( + `[keymodel] Tab basedir validation failed at use-time: ${tabBaseDir} (${validationResult.reason}). Falling back to home directory.` + ); + tabBaseDir = null; // Fall back to home directory + } + } catch (error) { + console.error("[keymodel] Failed to validate tab basedir:", error); + tabBaseDir = null; // Fall back to home directory on error + } + } + const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode != null) { @@ -345,11 +378,17 @@ function getDefaultNewBlockDef(): BlockDef { termBlockDef.meta.connection = blockData.meta.connection; } } + + // If no cwd from focused block, use tab base directory (if valid) + if (termBlockDef.meta["cmd:cwd"] == null && tabBaseDir != null) { + termBlockDef.meta["cmd:cwd"] = tabBaseDir; + } + return termBlockDef; } async function handleCmdN() { - const blockDef = getDefaultNewBlockDef(); + const blockDef = await getDefaultNewBlockDef(); await createBlock(blockDef); } @@ -359,7 +398,7 @@ async function handleSplitHorizontal(position: "before" | "after") { if (focusedNode == null) { return; } - const blockDef = getDefaultNewBlockDef(); + const blockDef = await getDefaultNewBlockDef(); await createBlockSplitHorizontally(blockDef, focusedNode.data.blockId, position); } @@ -369,7 +408,7 @@ async function handleSplitVertical(position: "before" | "after") { if (focusedNode == null) { return; } - const blockDef = getDefaultNewBlockDef(); + const blockDef = await getDefaultNewBlockDef(); await createBlockSplitVertically(blockDef, focusedNode.data.blockId, position); } diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index 7a36718c37..2e830b42ac 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -84,6 +84,16 @@ class ObjectServiceType { return WOS.callBackendService("object", "UpdateObjectMeta", Array.from(arguments)) } + // @returns object updates + UpdateObjectMetaIfNotLocked(oref: string, meta: MetaType, lockKey: string, expectedVersion: number): Promise { + return WOS.callBackendService("object", "UpdateObjectMetaIfNotLocked", Array.from(arguments)) + } + + // @returns object updates + UpdateObjectMetaWithVersion(oref: string, meta: MetaType, expectedVersion: number): Promise { + return WOS.callBackendService("object", "UpdateObjectMetaWithVersion", Array.from(arguments)) + } + // @returns object updates UpdateTabName(tabId: string, name: string): Promise { return WOS.callBackendService("object", "UpdateTabName", Array.from(arguments)) diff --git a/frontend/app/store/tab-basedir-validation-hook.ts b/frontend/app/store/tab-basedir-validation-hook.ts new file mode 100644 index 0000000000..036694baf4 --- /dev/null +++ b/frontend/app/store/tab-basedir-validation-hook.ts @@ -0,0 +1,77 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { fireAndForget } from "@/util/util"; +import { globalStore } from "./jotaiStore"; +import { activeTabIdAtom, getTabModelByTabId } from "./tab-model"; +import { validateTabBasedir } from "./tab-basedir-validator"; +import * as WOS from "./wos"; + +const DEBOUNCE_INTERVAL_MS = 500; // Minimum time between validations + +// Validate tab basedir when tab is activated +async function validateActiveTabBasedir(tabId: string): Promise { + if (!tabId) return; + + const tabModel = getTabModelByTabId(tabId); + const lastValidationTime = globalStore.get(tabModel.lastValidationTimeAtom); + const now = Date.now(); + + // Debounce: skip if validated recently + if (now - lastValidationTime < DEBOUNCE_INTERVAL_MS) { + return; + } + + // Get tab data + const tabAtom = WOS.getWaveObjectAtom(WOS.makeORef("tab", tabId)); + const tabData = globalStore.get(tabAtom); + + if (!tabData) { + return; + } + + const basedir = tabData.meta?.["tab:basedir"]; + + // Skip validation if no basedir set + if (!basedir || basedir.trim() === "") { + return; + } + + // Update validation state to pending + globalStore.set(tabModel.basedirValidationAtom, "pending"); + globalStore.set(tabModel.lastValidationTimeAtom, now); + + // Perform validation + const result = await validateTabBasedir(tabId, basedir); + + if (result.valid) { + // Update validation state to valid + globalStore.set(tabModel.basedirValidationAtom, "valid"); + } else { + // Update validation state to invalid + globalStore.set(tabModel.basedirValidationAtom, "invalid"); + + // Handle stale basedir (will clear and notify) + if (result.reason) { + const { handleStaleBasedir } = await import("./tab-basedir-validator"); + await handleStaleBasedir(tabId, basedir, result.reason); + } + } +} + +// Initialize tab validation hook +export function initTabBasedirValidation(): void { + // Subscribe to activeTabIdAtom changes + globalStore.sub(activeTabIdAtom, () => { + const activeTabId = globalStore.get(activeTabIdAtom); + if (activeTabId) { + fireAndForget(() => validateActiveTabBasedir(activeTabId)); + } + }); + + // Also validate the initial active tab + const initialActiveTabId = globalStore.get(activeTabIdAtom); + if (initialActiveTabId) { + fireAndForget(() => validateActiveTabBasedir(initialActiveTabId)); + } +} diff --git a/frontend/app/store/tab-basedir-validator.ts b/frontend/app/store/tab-basedir-validator.ts new file mode 100644 index 0000000000..0b4c41769e --- /dev/null +++ b/frontend/app/store/tab-basedir-validator.ts @@ -0,0 +1,342 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { fireAndForget } from "@/util/util"; +import { globalStore } from "./jotaiStore"; +import { ObjectService } from "./services"; +import * as WOS from "./wos"; + +export type StalePathReason = + | "not_found" // ENOENT - path does not exist + | "not_directory" // Path exists but is not a directory + | "access_denied" // EACCES - no permission to access + | "network_error" // Timeout or network failure (after retries) + | "unknown_error"; // Other errors + +export interface PathValidationResult { + valid: boolean; + path: string; + reason?: StalePathReason; + fileInfo?: FileInfo; +} + +interface RetryConfig { + maxAttempts: number; + timeoutPerAttempt: number; + delayBetweenRetries: number; + totalWindow: number; +} + +const defaultRetryConfig: RetryConfig = { + maxAttempts: 3, + timeoutPerAttempt: 10000, // 10 seconds per attempt + delayBetweenRetries: 1000, // 1 second delay between retries + totalWindow: 30000, // Maximum 30 seconds total +}; + +// Sleep utility +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Classify error into StalePathReason +function classifyError(error: any): StalePathReason { + const errorStr = String(error?.message || error || "").toLowerCase(); + + // ENOENT - file not found + if (errorStr.includes("enoent") || errorStr.includes("not found") || errorStr.includes("no such file")) { + return "not_found"; + } + + // EACCES - access denied + if (errorStr.includes("eacces") || errorStr.includes("permission denied") || errorStr.includes("access denied")) { + return "access_denied"; + } + + // Network/timeout errors + if ( + errorStr.includes("etimedout") || + errorStr.includes("timeout") || + errorStr.includes("econnrefused") || + errorStr.includes("ehostunreach") || + errorStr.includes("enetunreach") || + errorStr.includes("network") + ) { + return "network_error"; + } + + return "unknown_error"; +} + +// Check if a path looks like a network path +function isNetworkPath(path: string): boolean { + if (!path) return false; + + // UNC paths (Windows): \\server\share or //server/share + if (path.startsWith("\\\\") || path.startsWith("//")) { + return true; + } + + // SMB/CIFS: smb:// or cifs:// + if (path.startsWith("smb://") || path.startsWith("cifs://")) { + return true; + } + + // NFS paths (common patterns) + // - server:/path (NFS) + // - /net/server/path (automounter) + if (/^[^\/\\]+:\//.test(path) || path.startsWith("/net/")) { + return true; + } + + return false; +} + +// Validate path with timeout +async function validatePathWithTimeout( + basedir: string, + timeout: number +): Promise { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("ETIMEDOUT")), timeout); + }); + + try { + const validationPromise = RpcApi.FileInfoCommand(TabRpcClient, { info: { path: basedir } }, null); + const fileInfo = await Promise.race([validationPromise, timeoutPromise]); + + // Check if path was not found + if (fileInfo.notfound) { + return { valid: false, path: basedir, reason: "not_found" }; + } + + // Check if path is not a directory + if (!fileInfo.isdir) { + return { valid: false, path: basedir, reason: "not_directory" }; + } + + // Valid directory + return { valid: true, path: basedir, fileInfo }; + } catch (error) { + const reason = classifyError(error); + return { valid: false, path: basedir, reason }; + } +} + +// Validate with network retry mechanism +async function validateWithNetworkRetry( + basedir: string, + config: RetryConfig = defaultRetryConfig +): Promise { + let lastError: StalePathReason | null = null; + + for (let attempt = 1; attempt <= config.maxAttempts; attempt++) { + try { + const result = await validatePathWithTimeout(basedir, config.timeoutPerAttempt); + + if (result.valid) { + return result; // Success on any attempt + } + + // Non-network errors fail immediately (no retry) + if (result.reason !== "network_error") { + return result; + } + + lastError = result.reason; + + // Don't delay after final attempt + if (attempt < config.maxAttempts) { + await sleep(config.delayBetweenRetries); + } + } catch (error) { + lastError = classifyError(error); + + // Only retry network errors + if (lastError !== "network_error" || attempt === config.maxAttempts) { + return { valid: false, path: basedir, reason: lastError }; + } + + await sleep(config.delayBetweenRetries); + } + } + + // All retries exhausted + return { valid: false, path: basedir, reason: "network_error" }; +} + +// Main validation function +export async function validateTabBasedir( + tabId: string, + basedir: string +): Promise { + if (!basedir || basedir.trim() === "") { + return { valid: true, path: basedir }; // Empty path is considered valid (no validation needed) + } + + // Detect if this is a network path + const isNetwork = isNetworkPath(basedir); + + if (isNetwork) { + // Use retry logic for network paths + return await validateWithNetworkRetry(basedir); + } else { + // Single attempt for local paths + return await validatePathWithTimeout(basedir, 5000); // 5 second timeout for local + } +} + +// Get user-friendly message for a stale path reason +function getReasonMessage(reason: StalePathReason, path: string): string { + switch (reason) { + case "not_found": + return `Path no longer valid (not found): ${path}`; + case "not_directory": + return `Path is no longer a directory: ${path}`; + case "access_denied": + return `Cannot access directory (permission denied): ${path}`; + case "network_error": + return `Cannot reach network path (after retries): ${path}`; + case "unknown_error": + return `Path no longer accessible: ${path}`; + default: + return `Path validation failed: ${path}`; + } +} + +// Clear stale path and notify user +export async function handleStaleBasedir( + tabId: string, + path: string, + reason: StalePathReason +): Promise { + const tabORef = WOS.makeORef("tab", tabId); + + try { + // Clear both basedir and basedirlock + await ObjectService.UpdateObjectMeta(tabORef, { + "tab:basedir": null, + "tab:basedirlock": false, + }); + + // Push notification + const { pushNotification } = await import("./global"); + pushNotification({ + id: `stale-basedir-${tabId}`, + icon: "triangle-exclamation", + type: "warning", + title: "Tab base directory cleared", + message: getReasonMessage(reason, path), + timestamp: new Date().toISOString(), + expiration: Date.now() + 10000, // 10 second auto-dismiss + persistent: false, + }); + + console.log(`[TabBasedir] Cleared stale basedir for tab ${tabId}: ${path} (${reason})`); + } catch (error) { + console.error(`[TabBasedir] Failed to clear stale basedir for tab ${tabId}:`, error); + } +} + +// Batch notification for multiple stale paths +export async function handleMultipleStaleBasedirs( + staleTabs: Array<{ tabId: string; path: string; reason: StalePathReason }> +): Promise { + if (staleTabs.length === 0) return; + + // Clear all stale paths + const clearPromises = staleTabs.map(({ tabId }) => { + const tabORef = WOS.makeORef("tab", tabId); + return ObjectService.UpdateObjectMeta(tabORef, { + "tab:basedir": null, + "tab:basedirlock": false, + }); + }); + + try { + await Promise.all(clearPromises); + + // Push batched notification + const { pushNotification } = await import("./global"); + pushNotification({ + id: "stale-basedir-batch", + icon: "triangle-exclamation", + type: "warning", + title: `Cleared base directory for ${staleTabs.length} tabs`, + message: "Multiple tabs had stale paths. See logs for details.", + timestamp: new Date().toISOString(), + expiration: Date.now() + 15000, // 15 second auto-dismiss + persistent: false, + }); + + // Log individual paths for debugging + staleTabs.forEach(({ tabId, path, reason }) => { + console.log(`[TabBasedir] Cleared stale basedir for tab ${tabId}: ${path} (${reason})`); + }); + } catch (error) { + console.error("[TabBasedir] Failed to clear multiple stale basedirs:", error); + } +} + +// Batching state for tab validations +interface BatchingState { + staleTabs: Array<{ tabId: string; path: string; reason: StalePathReason }>; + timer: NodeJS.Timeout | null; +} + +const batchingState: BatchingState = { + staleTabs: [], + timer: null, +}; + +const BATCHING_WINDOW_MS = 5000; // 5 second window for batching +const BATCH_THRESHOLD = 4; // Batch if 4+ tabs have stale paths + +// Validate and handle stale basedir with batching support +export async function validateAndHandleStale(tabId: string): Promise { + const tabAtom = WOS.getWaveObjectAtom(WOS.makeORef("tab", tabId)); + const tabData = globalStore.get(tabAtom); + + if (!tabData) { + return; + } + + const basedir = tabData.meta?.["tab:basedir"]; + + // Skip validation if no basedir set + if (!basedir || basedir.trim() === "") { + return; + } + + // Perform validation + const result = await validateTabBasedir(tabId, basedir); + + if (!result.valid && result.reason) { + // Add to batching queue + batchingState.staleTabs.push({ tabId, path: basedir, reason: result.reason }); + + // Clear existing timer if any + if (batchingState.timer) { + clearTimeout(batchingState.timer); + } + + // Set timer to process batch + batchingState.timer = setTimeout(() => { + const staleTabs = [...batchingState.staleTabs]; + batchingState.staleTabs = []; + batchingState.timer = null; + + // Process batch + if (staleTabs.length >= BATCH_THRESHOLD) { + fireAndForget(() => handleMultipleStaleBasedirs(staleTabs)); + } else { + // Process individually + staleTabs.forEach(({ tabId, path, reason }) => { + fireAndForget(() => handleStaleBasedir(tabId, path, reason)); + }); + } + }, BATCHING_WINDOW_MS); + } +} diff --git a/frontend/app/store/tab-model.ts b/frontend/app/store/tab-model.ts index ec5ab94c16..c578a81e10 100644 --- a/frontend/app/store/tab-model.ts +++ b/frontend/app/store/tab-model.ts @@ -4,11 +4,22 @@ import { atom, Atom, PrimitiveAtom } from "jotai"; import { createContext, useContext } from "react"; import { globalStore } from "./jotaiStore"; +import { ObjectService } from "./services"; import * as WOS from "./wos"; const tabModelCache = new Map(); export const activeTabIdAtom = atom(null) as PrimitiveAtom; +// Tab status types based on terminal block states +export type TabStatusType = "stopped" | "finished" | "running" | null; + +// Per-block terminal status for aggregation +export interface BlockTerminalStatus { + shellProcStatus: string | null; // "running", "done", "init", etc. + shellProcExitCode: number | null; + shellIntegrationStatus: string | null; // "running-command", "ready", etc. +} + export class TabModel { tabId: string; tabAtom: Atom; @@ -16,6 +27,26 @@ export class TabModel { isTermMultiInput = atom(false) as PrimitiveAtom; metaCache: Map> = new Map(); + // Validation state atoms for tab base directory + basedirValidationAtom = atom<"pending" | "valid" | "invalid" | null>(null) as PrimitiveAtom< + "pending" | "valid" | "invalid" | null + >; + lastValidationTimeAtom = atom(0) as PrimitiveAtom; + + // Tracks when a process completes while this tab is in the background + // This enables the "finished" status icon to show unread completions + finishedUnreadAtom = atom(false) as PrimitiveAtom; + + // Map of blockId -> terminal status for reactive status tracking + private terminalStatusMap = new Map(); + + // Track previous status per block to detect transitions + private previousStatusMap = new Map(); + + // Atom that holds the computed aggregate terminal status + // This is updated whenever any terminal block's status changes + terminalStatusAtom = atom(null) as PrimitiveAtom; + constructor(tabId: string) { this.tabId = tabId; this.tabAtom = atom((get) => { @@ -38,6 +69,165 @@ export class TabModel { } return metaAtom; } + + getBasedirValidationState(): "pending" | "valid" | "invalid" | null { + return globalStore.get(this.basedirValidationAtom); + } + + /** + * Clears the finishedUnread state when the tab becomes active. + * This removes the "finished" status icon indicating unread completions. + */ + clearFinishedUnread(): void { + globalStore.set(this.finishedUnreadAtom, false); + this.recomputeTerminalStatus(); + } + + /** + * Marks the tab as having unread process completions. + * Called when a process completes while this tab is in the background. + */ + setFinishedUnread(): void { + globalStore.set(this.finishedUnreadAtom, true); + this.recomputeTerminalStatus(); + } + + /** + * Checks if this tab is currently the active tab. + */ + isActiveTab(): boolean { + return globalStore.get(activeTabIdAtom) === this.tabId; + } + + /** + * Clears any stale terminal status from previous sessions. + * Called when terminal initializes to ensure outdated status icons are removed. + */ + clearTerminalStatus(): void { + // Clear local state + this.terminalStatusMap.clear(); + this.previousStatusMap.clear(); + globalStore.set(this.finishedUnreadAtom, false); + globalStore.set(this.terminalStatusAtom, null); + + // Clear persisted metadata + ObjectService.UpdateObjectMeta(WOS.makeORef("tab", this.tabId), { + "tab:termstatus": null, + }).catch((err) => { + console.error("Failed to clear tab terminal status:", err); + }); + } + + /** + * Updates the terminal status for a specific block and recomputes aggregate status. + * Called by terminal blocks when their shell proc status or shell integration status changes. + * + * Detects command completion via: + * 1. Shell integration: running-command → ready (for active tabs) + * 2. Proc status: running → done (fallback for background tabs that don't process OSC) + */ + updateBlockTerminalStatus(blockId: string, status: BlockTerminalStatus): void { + const prevStatus = this.previousStatusMap.get(blockId); + + // Detect command completion via shell integration + const shellIntegrationFinished = + prevStatus?.shellIntegrationStatus === "running-command" && + status.shellIntegrationStatus !== "running-command"; + + // Detect command completion via proc status (fallback for background tabs) + // Background tabs don't process OSC 16162, so we also check shellProcStatus + const procStatusFinished = + prevStatus?.shellProcStatus === "running" && + status.shellProcStatus === "done"; + + const commandJustFinished = shellIntegrationFinished || procStatusFinished; + + // Detect when a new command starts (to clear old "finished" state) + const commandJustStarted = + prevStatus?.shellIntegrationStatus !== "running-command" && + status.shellIntegrationStatus === "running-command"; + + // Clear "finished" state when a new command starts + if (commandJustStarted && globalStore.get(this.finishedUnreadAtom)) { + globalStore.set(this.finishedUnreadAtom, false); + } + + // Detect error exit + const hasError = + status.shellProcStatus === "done" && + status.shellProcExitCode != null && + status.shellProcExitCode !== 0; + + // Show "finished" status for successful completions + // "stopped" status is handled by recomputeTerminalStatus based on exit code + if (commandJustFinished && !hasError) { + this.setFinishedUnread(); + // Status will be cleared by tab.tsx when user views it (2-3 second delay) + } + + // Store current status for next comparison + this.previousStatusMap.set(blockId, { ...status }); + this.terminalStatusMap.set(blockId, status); + this.recomputeTerminalStatus(); + } + + /** + * Removes a block from terminal status tracking (e.g., when block is deleted). + */ + removeBlockTerminalStatus(blockId: string): void { + this.terminalStatusMap.delete(blockId); + this.previousStatusMap.delete(blockId); + this.recomputeTerminalStatus(); + } + + /** + * Recomputes the aggregate terminal status from all tracked blocks. + * Priority (highest to lowest): + * 1. stopped - Any block exited with error (exitcode != 0) + * 2. running - A command is actively executing (via shell integration) + * 3. finished - Process completed (shows briefly for active tabs, persists for background) + * 4. null - Idle (no special status to show) + * + * Persists status to tab metadata for cross-webview sync. + */ + private recomputeTerminalStatus(): void { + let hasRunningCommand = false; + let hasStopped = false; + + for (const status of this.terminalStatusMap.values()) { + // Priority 1: Any error exit code (shell exited with error) + // Note: exitCode can be null (not set), 0 (success), or non-zero (error) + if (status.shellProcStatus === "done" && status.shellProcExitCode != null && status.shellProcExitCode !== 0) { + hasStopped = true; + } + + // Check for running commands via shell integration + if (status.shellIntegrationStatus === "running-command") { + hasRunningCommand = true; + } + } + + // Compute status with priority + let newStatus: TabStatusType = null; + if (hasStopped) { + newStatus = "stopped"; + } else if (hasRunningCommand) { + newStatus = "running"; + } else if (globalStore.get(this.finishedUnreadAtom)) { + newStatus = "finished"; + } + + // Update local atom for immediate reactivity within this webview + globalStore.set(this.terminalStatusAtom, newStatus); + + // Persist to tab metadata for cross-webview sync + // This allows other tab webviews to see the status in the tabbar + ObjectService.UpdateObjectMeta(WOS.makeORef("tab", this.tabId), { + "tab:termstatus": newStatus, + }).catch((err) => { + console.error("Failed to persist tab terminal status:", err); + }); + } } export function getTabModelByTabId(tabId: string): TabModel { diff --git a/frontend/app/tab/tab-menu.ts b/frontend/app/tab/tab-menu.ts new file mode 100644 index 0000000000..699fca994f --- /dev/null +++ b/frontend/app/tab/tab-menu.ts @@ -0,0 +1,168 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { ObjectService } from "@/app/store/services"; +import { validatePresetBeforeApply, sanitizePreset } from "@/util/presetutil"; +import { fireAndForget } from "@/util/util"; + +/** + * Configuration for building preset menu items. + */ +export interface PresetMenuConfig { + /** Prefix to filter presets (e.g., "tabvar@", "bg@") */ + prefix: string; + + /** Whether to sort by display:order (default: false) */ + sortByOrder?: boolean; + + /** Whether to strip prefix from fallback label (default: false) */ + stripPrefixFromLabel?: boolean; + + /** Additional callback to execute after applying preset */ + onApply?: (presetName: string) => void; +} + +/** + * Filter preset keys by prefix from the full configuration. + * + * @param presets - The presets map from fullConfig + * @param prefix - Prefix to filter by + * @returns Array of matching preset keys + */ +function filterPresetsByPrefix(presets: { [key: string]: MetaType } | undefined, prefix: string): string[] { + if (!presets) { + return []; + } + const matching: string[] = []; + for (const key in presets) { + if (key.startsWith(prefix)) { + matching.push(key); + } + } + return matching; +} + +/** + * Build context menu items from presets matching the specified prefix. + * + * @param fullConfig - The full configuration containing presets + * @param oref - Object reference to apply presets to + * @param config - Configuration for filtering and building menu items + * @returns Array of context menu items (empty if no matching presets) + * + * @example + * // Tab variables presets + * const tabVarItems = buildPresetMenuItems(fullConfig, oref, { + * prefix: "tabvar@", + * stripPrefixFromLabel: true, + * }); + * + * @example + * // Background presets with sorting and callback + * const bgItems = buildPresetMenuItems(fullConfig, oref, { + * prefix: "bg@", + * sortByOrder: true, + * onApply: () => { + * RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true }); + * recordTEvent("action:settabtheme"); + * }, + * }); + */ +export function buildPresetMenuItems( + fullConfig: FullConfigType | null, + oref: string, + config: PresetMenuConfig +): ContextMenuItem[] { + if (!fullConfig?.presets) { + return []; + } + + const { prefix, sortByOrder = false, stripPrefixFromLabel = false, onApply } = config; + + // Filter presets by prefix + let presetKeys = filterPresetsByPrefix(fullConfig.presets, prefix); + + if (presetKeys.length === 0) { + return []; + } + + // Sort by display:order if requested + if (sortByOrder) { + presetKeys.sort((a, b) => { + const aOrder = fullConfig.presets[a]?.["display:order"] ?? 0; + const bOrder = fullConfig.presets[b]?.["display:order"] ?? 0; + return aOrder - bOrder; + }); + } + + // Build menu items + const menuItems: ContextMenuItem[] = []; + + for (const presetName of presetKeys) { + const preset = fullConfig.presets[presetName]; + if (preset == null) { + continue; + } + + // Frontend validation (defense in depth) + const validation = validatePresetBeforeApply(presetName, preset); + if (!validation.valid) { + console.warn(`[Preset] Skipping invalid preset "${presetName}": ${validation.error}`); + continue; + } + if (validation.warnings?.length) { + console.info(`[Preset] Warnings for "${presetName}":`, validation.warnings); + } + + // Determine display label + let label: string; + if (preset["display:name"]) { + label = preset["display:name"] as string; + } else if (stripPrefixFromLabel) { + label = presetName.replace(prefix, ""); + } else { + label = presetName; + } + + menuItems.push({ + label, + click: () => + fireAndForget(async () => { + // Sanitize preset to ensure only allowed keys are sent + const sanitizedPreset = sanitizePreset(presetName, preset); + await ObjectService.UpdateObjectMeta(oref, sanitizedPreset); + onApply?.(presetName); + }), + }); + } + + return menuItems; +} + +/** + * Add preset submenu to an existing menu array if presets exist. + * This is a convenience wrapper around buildPresetMenuItems that handles + * the common pattern of adding a labeled submenu with separator. + * + * @param menu - Menu array to add to (modified in place) + * @param fullConfig - The full configuration containing presets + * @param oref - Object reference to apply presets to + * @param label - Label for the submenu + * @param config - Configuration for filtering and building menu items + * @returns The modified menu array (for chaining) + */ +export function addPresetSubmenu( + menu: ContextMenuItem[], + fullConfig: FullConfigType | null, + oref: string, + label: string, + config: PresetMenuConfig +): ContextMenuItem[] { + const submenuItems = buildPresetMenuItems(fullConfig, oref, config); + + if (submenuItems.length > 0) { + menu.push({ label, type: "submenu", submenu: submenuItems }, { type: "separator" }); + } + + return menu; +} diff --git a/frontend/app/tab/tab.scss b/frontend/app/tab/tab.scss index 4b33a48f92..07e3d69699 100644 --- a/frontend/app/tab/tab.scss +++ b/frontend/app/tab/tab.scss @@ -4,7 +4,7 @@ .tab { position: absolute; width: 130px; - height: calc(100% - 1px); + height: 100%; padding: 0 0 0 0; box-sizing: border-box; font-weight: bold; @@ -13,6 +13,39 @@ display: flex; align-items: center; justify-content: center; + // Inactive tab: 25% white overlay on tab bar background + background: rgb(255 255 255 / 0.04); + + // Tab color stripe - shows manual color ONLY (VS Code style top stripe) + .tab-color-stripe { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + border-radius: 0; + z-index: var(--zindex-tab-name); + } + + // Status text colors (VS Code style) + &.status-running .name { + color: #3b82f6 !important; + animation: text-pulse 1.5s ease-in-out infinite; + } + + &.status-finished .name { + color: #22c55e !important; + } + + &.status-stopped .name { + color: #ef4444 !important; + } + + &.status-attention .name { + color: #eab308 !important; + animation: text-pulse 1.5s ease-in-out infinite; + } + &::after { content: ""; @@ -28,7 +61,7 @@ width: calc(100% - 6px); height: 100%; white-space: nowrap; - border-radius: 6px; + border-radius: 0; } &.animate { @@ -38,14 +71,22 @@ } &.active { + // Active tab: 75% white overlay on tab bar background (brightest) + background: rgb(255 255 255 / 0.12); + .tab-inner { border-color: transparent; - border-radius: 6px; - background: rgb(from var(--main-text-color) r g b / 0.1); + border-radius: 0; } .name { color: var(--main-text-color); + opacity: 1; + } + + // Always show close button on active tab + .close { + visibility: visible; } & + .tab::after, @@ -58,20 +99,29 @@ content: none; } - .name { + .tab-name-wrapper { position: absolute; top: 50%; left: 50%; transform: translate3d(-50%, -50%, 0); - user-select: none; + width: calc(100% - 10px); + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; z-index: var(--zindex-tab-name); + } + + .name { + user-select: none; font-size: 11px; font-weight: 500; text-shadow: 0px 0px 4px rgb(from var(--main-bg-color) r g b / 0.25); overflow: hidden; - width: calc(100% - 10px); text-overflow: ellipsis; text-align: center; + // Inactive tabs have more muted text + opacity: 0.7; &.focused { outline: none; @@ -110,9 +160,11 @@ body:not(.nohover) .tab.dragging { content: none; } + // Hover tab: 50% white overlay on tab bar background (middle brightness) + background: rgb(255 255 255 / 0.08); + .tab-inner { border-color: transparent; - background: rgb(from var(--main-text-color) r g b / 0.1); } .close { visibility: visible; @@ -122,6 +174,11 @@ body:not(.nohover) .tab.dragging { } } +// Active tab hover shouldn't change the background +body:not(.nohover) .tab.active:hover { + background: rgb(255 255 255 / 0.12); +} + // When in nohover mode, always show the close button on the active tab. This prevents the close button of the active tab from flickering when nohover is toggled. body.nohover .tab.active .close { visibility: visible; @@ -192,3 +249,21 @@ body.nohover .tab.active .close { .pin.jiggling i { animation: jigglePinIcon 0.5s ease-in-out; } + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes text-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 63269d3013..e49f6eeb0c 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -1,18 +1,32 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { atoms, globalStore, recordTEvent, refocusNode } from "@/app/store/global"; +import { atoms, getApi, globalStore, recordTEvent, refocusNode } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { Button } from "@/element/button"; import { ContextMenuModel } from "@/store/contextmenu"; import { fireAndForget } from "@/util/util"; import clsx from "clsx"; -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; +import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { ObjectService } from "../store/services"; +import { TabStatusType } from "../store/tab-model"; import { makeORef, useWaveObjectValue } from "../store/wos"; +import { addPresetSubmenu } from "./tab-menu"; import "./tab.scss"; +// Tab color palette for the context menu +const TAB_COLORS = [ + { name: "Red", value: "#ef4444" }, + { name: "Orange", value: "#f97316" }, + { name: "Yellow", value: "#eab308" }, + { name: "Green", value: "#22c55e" }, + { name: "Cyan", value: "#06b6d4" }, + { name: "Blue", value: "#3b82f6" }, + { name: "Purple", value: "#a855f7" }, + { name: "Pink", value: "#ec4899" }, +]; + interface TabProps { id: string; active: boolean; @@ -42,6 +56,42 @@ const Tab = memo( const loadedRef = useRef(false); const tabRef = useRef(null); + // Read terminal status from tab metadata (synced across webviews) + // Status shown on ALL tabs including active + const tabStatus = (tabData?.meta?.["tab:termstatus"] as TabStatusType) || null; + + // Clear status after a delay when tab becomes active AND webview is visible + // "finished" clears after 2 seconds, "stopped" clears after 3 seconds + // We must check document.visibilityState because each tab has its own webview, + // and the "active" prop is always true for the owning webview even when in background + const [isDocVisible, setIsDocVisible] = useState(document.visibilityState === "visible"); + useEffect(() => { + const handleVisibilityChange = () => { + setIsDocVisible(document.visibilityState === "visible"); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => document.removeEventListener("visibilitychange", handleVisibilityChange); + }, []); + + useEffect(() => { + // Only clear status when: + // 1. This tab is marked as active (matches this webview's staticTabId) + // 2. This webview is actually visible to the user (not a background webview) + // 3. Status is finished or stopped + if (active && isDocVisible && (tabStatus === "finished" || tabStatus === "stopped")) { + const delay = tabStatus === "stopped" ? 3000 : 2000; + const timer = setTimeout(() => { + // Use fireAndForget to avoid unhandled promise rejection + fireAndForget(() => + ObjectService.UpdateObjectMeta(makeORef("tab", id), { + "tab:termstatus": null, + }) + ); + }, delay); + return () => clearTimeout(timer); + } + }, [active, isDocVisible, tabStatus, id]); + useImperativeHandle(ref, () => tabRef.current as HTMLDivElement); useEffect(() => { @@ -133,9 +183,99 @@ const Tab = memo( event.stopPropagation(); }; + /** + * Opens a native directory picker dialog and sets the tab's base directory. + * + * The selected directory becomes the default working directory for all + * terminals and file preview widgets launched within this tab. + * + * @remarks + * - Uses Electron's native dialog for cross-platform file picking + * - Defaults to current base directory if set, otherwise home (~) + * - Does NOT set the lock flag - allows smart auto-detection to continue + * + * @see handleClearBaseDir - To remove the base directory + * @see handleToggleLock - To prevent auto-detection + */ + const handleSetBaseDir = useCallback(() => { + const currentDir = tabData?.meta?.["tab:basedir"] || ""; + fireAndForget(async () => { + const newDir = await getApi().showOpenDialog({ + title: "Set Tab Base Directory", + defaultPath: currentDir || "~", + properties: ["openDirectory"], + }); + if (newDir && newDir.length > 0) { + await ObjectService.UpdateObjectMeta(makeORef("tab", id), { + "tab:basedir": newDir[0], + }); + } + }); + }, [id, tabData]); + + /** + * Clears the tab's base directory, restoring default behavior. + * + * After clearing: + * - New terminals use the default directory (typically home ~) + * - Smart auto-detection from OSC 7 is re-enabled + * + * @remarks + * Only clears `tab:basedir`, does NOT touch `tab:basedirlock` + */ + const handleClearBaseDir = useCallback(() => { + fireAndForget(async () => { + await ObjectService.UpdateObjectMeta(makeORef("tab", id), { + "tab:basedir": null, + }); + }); + }, [id]); + + /** + * Toggles the base directory lock state. + * + * Lock semantics: + * - **Unlocked (default):** OSC 7 smart auto-detection can update `tab:basedir` + * - **Locked:** OSC 7 updates are blocked; only manual setting changes directory + * + * Use cases for locking: + * - Working in multiple directories within same tab + * - Preventing cd commands from changing tab context + * - Maintaining a fixed project root despite navigation + * + * @see tab:basedirlock - The underlying metadata key + */ + const handleToggleLock = useCallback(() => { + const currentLock = tabData?.meta?.["tab:basedirlock"] || false; + fireAndForget(async () => { + await ObjectService.UpdateObjectMeta(makeORef("tab", id), { + "tab:basedirlock": !currentLock, + }); + }); + }, [id, tabData]); + + /** + * Sets the tab's color for visual identification. + * + * @param color - Hex color value or null to clear + */ + const handleSetTabColor = useCallback( + (color: string | null) => { + fireAndForget(async () => { + await ObjectService.UpdateObjectMeta(makeORef("tab", id), { + "tab:color": color, + }); + }); + }, + [id] + ); + const handleContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault(); + const currentBaseDir = tabData?.meta?.["tab:basedir"]; + const isLocked = tabData?.meta?.["tab:basedirlock"] || false; + let menu: ContextMenuItem[] = [ { label: "Rename Tab", click: () => handleRenameTab(null) }, { @@ -144,69 +284,120 @@ const Tab = memo( }, { type: "separator" }, ]; - const fullConfig = globalStore.get(atoms.fullConfigAtom); - const bgPresets: string[] = []; - for (const key in fullConfig?.presets ?? {}) { - if (key.startsWith("bg@")) { - bgPresets.push(key); - } + + // Base Directory submenu + const baseDirSubmenu: ContextMenuItem[] = [ + { + label: "Set Base Directory...", + click: handleSetBaseDir, + }, + ]; + + if (currentBaseDir) { + baseDirSubmenu.push({ + label: "Clear Base Directory", + click: handleClearBaseDir, + }); + baseDirSubmenu.push({ type: "separator" }); + baseDirSubmenu.push({ + label: isLocked ? "Unlock (Enable Smart Detection)" : "Lock (Disable Smart Detection)", + click: handleToggleLock, + }); } - bgPresets.sort((a, b) => { - const aOrder = fullConfig.presets[a]["display:order"] ?? 0; - const bOrder = fullConfig.presets[b]["display:order"] ?? 0; - return aOrder - bOrder; + + menu.push({ label: "Base Directory", type: "submenu", submenu: baseDirSubmenu }, { type: "separator" }); + + // Tab Color submenu + const currentTabColor = tabData?.meta?.["tab:color"]; + const colorSubmenu: ContextMenuItem[] = TAB_COLORS.map((color) => ({ + label: color.name, + type: "checkbox" as const, + checked: currentTabColor === color.value, + click: () => handleSetTabColor(color.value), + })); + colorSubmenu.push({ type: "separator" }); + colorSubmenu.push({ + label: "Clear", + click: () => handleSetTabColor(null), + }); + + menu.push({ label: "Tab Color", type: "submenu", submenu: colorSubmenu }, { type: "separator" }); + + const fullConfig = globalStore.get(atoms.fullConfigAtom); + const oref = makeORef("tab", id); + + // Tab Variables presets + addPresetSubmenu(menu, fullConfig, oref, "Tab Variables", { + prefix: "tabvar@", + stripPrefixFromLabel: true, + }); + + // Background presets + addPresetSubmenu(menu, fullConfig, oref, "Backgrounds", { + prefix: "bg@", + sortByOrder: true, + onApply: () => { + RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true }); + recordTEvent("action:settabtheme"); + }, }); - if (bgPresets.length > 0) { - const submenu: ContextMenuItem[] = []; - const oref = makeORef("tab", id); - for (const presetName of bgPresets) { - const preset = fullConfig.presets[presetName]; - if (preset == null) { - continue; - } - submenu.push({ - label: preset["display:name"] ?? presetName, - click: () => - fireAndForget(async () => { - await ObjectService.UpdateObjectMeta(oref, preset); - RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true }); - recordTEvent("action:settabtheme"); - }), - }); - } - menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" }); - } menu.push({ label: "Close Tab", click: () => onClose(null) }); ContextMenuModel.showContextMenu(menu, e); }, - [handleRenameTab, id, onClose] + [handleRenameTab, id, onClose, tabData, handleSetBaseDir, handleClearBaseDir, handleToggleLock, handleSetTabColor] ); + const tabColor = tabData?.meta?.["tab:color"]; + + /** + * Gets the status class name for the tab element. + * Used for VS Code style text coloring. + */ + const getStatusClassName = (): string | null => { + switch (tabStatus) { + case "stopped": + return "status-stopped"; + case "finished": + return "status-finished"; + case "running": + return "status-running"; + default: + return null; + } + }; + + const statusClassName = getStatusClassName(); + return (
+ {/* Top stripe for manual color only (VS Code style) */} + {tabColor &&
}
-
- {tabData?.name} +
+
+ {tabData?.name} +
+
+
+ ); +}); + +TabBreadcrumb.displayName = "TabBreadcrumb"; + const WorkspaceElem = memo(() => { const workspaceLayoutModel = WorkspaceLayoutModel.getInstance(); const tabId = useAtomValue(atoms.staticTabId); @@ -54,6 +122,7 @@ const WorkspaceElem = memo(() => { return (
+
; // derrived from fullConfig hasCustomAIPresetsAtom: jotai.Atom; // derived from fullConfig staticTabId: jotai.Atom; + activeTab: jotai.Atom; isFullScreen: jotai.PrimitiveAtom; zoomFactorAtom: jotai.PrimitiveAtom; controlShiftDelayAtom: jotai.PrimitiveAtom; @@ -134,6 +135,7 @@ declare global { openBuilder: (appId?: string) => void; // open-builder setBuilderWindowAppId: (appId: string) => void; // set-builder-window-appid doRefresh: () => void; // do-refresh + showOpenDialog: (options: OpenDialogOptions) => Promise; // show-open-dialog }; type ElectronContextMenuItem = { @@ -504,6 +506,15 @@ declare global { }; type AIModeConfigWithMode = { mode: string } & AIModeConfigType; + + type OpenDialogOptions = { + title?: string; + defaultPath?: string; + properties?: Array<"openFile" | "openDirectory" | "multiSelections" | "showHiddenFiles">; + filters?: Array<{ name: string; extensions: string[] }>; + message?: string; + buttonLabel?: string; + }; } export {}; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 4658bc1af2..73cac8e2c2 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -895,6 +895,11 @@ declare global { "bg:blendmode"?: string; "bg:bordercolor"?: string; "bg:activebordercolor"?: string; + "bg:text"?: string; + "tab:basedir"?: string; + "tab:basedirlock"?: boolean; + "tab:color"?: string; + "tab:termstatus"?: string; "waveai:panelopen"?: boolean; "waveai:panelwidth"?: number; "waveai:model"?: string; diff --git a/frontend/util/pathutil.ts b/frontend/util/pathutil.ts new file mode 100644 index 0000000000..c62d628243 --- /dev/null +++ b/frontend/util/pathutil.ts @@ -0,0 +1,291 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Path validation utilities for sanitizing untrusted paths from terminal output. + * Provides defense against path traversal, injection, and other security attacks. + * + * Security checks performed: + * - Null byte detection (prevents path truncation attacks) + * - Path traversal pattern detection (../ sequences) + * - UNC path blocking on Windows (prevents network data exfiltration) + * - Windows device name blocking (CON, NUL, AUX, etc.) + * - Length limit enforcement (prevents DoS via long paths) + * - Blocked sensitive directory detection + */ + +import { PLATFORM, PlatformWindows } from "@/util/platformutil"; + +// Maximum allowed path length (prevent DoS via extremely long paths) +const MAX_PATH_LENGTH = 4096; + +// Windows device names (special files that can cause issues) +const WINDOWS_DEVICE_NAMES = [ + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", +]; + +// Blocked directory patterns by platform +const BLOCKED_PATHS_UNIX = [ + "/etc", + "/root", + "/var/log", + "/boot", + "/sys", + "/proc", + "/dev", + "/private/etc", // macOS + "/private/var", // macOS + "/System", // macOS + "/Library/System", +]; + +const BLOCKED_PATHS_WINDOWS = [ + "C:\\Windows", + "C:\\Windows\\System32", + "C:\\Windows\\SysWOW64", + "C:\\Program Files", + "C:\\Program Files (x86)", + "C:\\ProgramData", + "C:\\Recovery", + "C:\\$Recycle.Bin", +]; + +/** + * Checks if a string contains null bytes (injection attack). + */ +export function hasNullBytes(str: string): boolean { + return str.includes("\0"); +} + +/** + * Checks if a path contains path traversal sequences. + * Detects both Unix (..) and Windows-style traversal patterns. + */ +export function containsPathTraversal(path: string): boolean { + // Check for .. sequences in various forms + // Unix: ../ or /.. + // Windows: ..\ or \.. + // Exact match: just ".." + // Trailing: ends with ".." + + // Pattern: .. followed by / or \ + if (/\.\.[\/\\]/.test(path)) { + return true; + } + + // Pattern: / or \ followed by .. + if (/[\/\\]\.\./.test(path)) { + return true; + } + + // Pattern: exactly ".." + if (path === "..") { + return true; + } + + // Pattern: ends with ".." (Windows trailing dots attack vector) + if (path.endsWith("..")) { + return true; + } + + return false; +} + +/** + * Checks if a path is a UNC path (Windows network path). + * UNC paths start with \\ and can be used for data exfiltration. + */ +export function isUncPath(path: string): boolean { + // Standard UNC: \\server\share + if (path.startsWith("\\\\")) { + return true; + } + + // URL-style UNC that might slip through: //server/share + // (if it starts with // followed by non-slash) + if (/^\/\/[^\/]/.test(path)) { + return true; + } + + // UNC path that was prefixed with / (from URL parsing) + if (path.startsWith("/\\\\")) { + return true; + } + + return false; +} + +/** + * Checks if a path contains invalid characters for the platform. + * On Windows, checks for reserved characters. + */ +export function hasInvalidChars(path: string, platform: string): boolean { + if (platform === PlatformWindows) { + // Windows reserved characters (except \ and / which are path separators) + // < > : " | ? * and control characters + // Note: : is allowed as second char for drive letter + const pathWithoutDrive = path.length >= 2 && path[1] === ":" ? path.substring(2) : path; + if (/[<>"|?*]/.test(pathWithoutDrive)) { + return true; + } + // Control characters (0x00-0x1F) + if (/[\x00-\x1F]/.test(path)) { + return true; + } + } + return false; +} + +/** + * Checks if a path matches a Windows device name. + * Device names like CON, NUL, AUX can cause issues. + */ +function isWindowsDeviceName(path: string): boolean { + // Get just the filename/last component + const parts = path.split(/[\/\\]/); + const filename = parts[parts.length - 1] || path; + + // Extract name without extension + const nameWithoutExt = filename.split(".")[0].toUpperCase(); + + return WINDOWS_DEVICE_NAMES.includes(nameWithoutExt); +} + +/** + * Checks if a normalized path starts with or equals a blocked path. + */ +export function isBlockedPath(normalizedPath: string): boolean { + const blockedPaths = PLATFORM === PlatformWindows ? BLOCKED_PATHS_WINDOWS : BLOCKED_PATHS_UNIX; + + // Normalize separators to forward slashes for consistent comparison on Windows + // This ensures C:/Windows matches against C:\Windows in the blocked list + const normalizedForComparison = + PLATFORM === PlatformWindows ? normalizedPath.replace(/\\/g, "/") : normalizedPath; + const lowerPath = normalizedForComparison.toLowerCase(); + + for (const blocked of blockedPaths) { + // Also normalize blocked paths to forward slashes on Windows + const normalizedBlocked = PLATFORM === PlatformWindows ? blocked.replace(/\\/g, "/") : blocked; + const lowerBlocked = normalizedBlocked.toLowerCase(); + // Check exact match or path starts with blocked + separator + if (lowerPath === lowerBlocked || lowerPath.startsWith(lowerBlocked + "/")) { + return true; + } + } + + return false; +} + +export type PathValidationResult = { + valid: boolean; + reason?: string; +}; + +/** + * Performs quick synchronous validation of a path without filesystem access. + * This is the first line of defense against obviously malicious paths. + * + * @param rawPath - The untrusted path to validate + * @returns Validation result with valid flag and optional reason for rejection + */ +export function quickValidatePath(rawPath: string): PathValidationResult { + // Allow empty/whitespace paths - they represent "no path" or "clear" + // This enables OSC 7 to clear tab:basedir by sending an empty path + // The caller can decide how to handle empty paths + if (!rawPath || rawPath.trim() === "") { + return { valid: true }; + } + + // Check length limit + if (rawPath.length > MAX_PATH_LENGTH) { + return { valid: false, reason: "path too long" }; + } + + // Check for null bytes (path truncation attack) + if (hasNullBytes(rawPath)) { + return { valid: false, reason: "null byte detected" }; + } + + // Check for path traversal patterns + if (containsPathTraversal(rawPath)) { + return { valid: false, reason: "path traversal detected" }; + } + + // Check for UNC paths (Windows network paths - security risk) + if (isUncPath(rawPath)) { + return { valid: false, reason: "UNC path detected" }; + } + + // Check for Windows device names + if (PLATFORM === PlatformWindows) { + if (isWindowsDeviceName(rawPath)) { + return { valid: false, reason: "Windows device name" }; + } + + // Check for invalid characters on Windows + if (hasInvalidChars(rawPath, PlatformWindows)) { + return { valid: false, reason: "invalid characters" }; + } + } + + return { valid: true }; +} + +/** + * Sanitizes a path from OSC 7 terminal escape sequence. + * Returns the validated path or null if the path should be rejected. + * + * This function performs: + * 1. Quick synchronous validation (pattern-based) + * 2. Blocked path checking + * + * Note: Filesystem-based validation (symlink resolution, existence check) + * is handled separately via IPC to the main process. + * + * @param rawPath - The untrusted path from terminal output (already URL-decoded) + * @returns Validated path string or null if rejected + */ +export function sanitizeOsc7Path(rawPath: string): string | null { + // Quick synchronous checks first + const quickResult = quickValidatePath(rawPath); + if (!quickResult.valid) { + console.warn(`[Security] OSC 7 path rejected (${quickResult.reason}):`, rawPath); + return null; + } + + // Normalize the path for blocked path checking + // Note: We don't resolve symlinks here - that requires filesystem access + let normalizedPath = rawPath; + + // Check against blocked paths + if (isBlockedPath(normalizedPath)) { + console.warn("[Security] OSC 7 blocked path rejected:", normalizedPath); + return null; + } + + // Path passed all synchronous checks + // Non-existent paths are allowed per spec-005 - they will be warned about + // when used, but not rejected here + return normalizedPath; +} diff --git a/frontend/util/presetutil.ts b/frontend/util/presetutil.ts new file mode 100644 index 0000000000..84f61dc544 --- /dev/null +++ b/frontend/util/presetutil.ts @@ -0,0 +1,150 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Preset validation utilities for frontend defense-in-depth. + * These provide early feedback before backend validation. + */ + +export interface PresetValidationResult { + valid: boolean; + error?: string; + warnings?: string[]; +} + +/** + * Key allowlists by preset type prefix. + * Defines which metadata keys each preset type is allowed to set. + */ +export const PRESET_KEY_ALLOWLISTS: Record> = { + "tabvar@": new Set([ + "tab:basedir", + "tab:basedirlock", + "display:name", + "display:order" + ]), + "bg@": new Set([ + "bg:*", + "bg", + "bg:opacity", + "bg:blendmode", + "bg:bordercolor", + "bg:activebordercolor", + "bg:text", + "display:name", + "display:order" + ]), +}; + +/** + * Checks if a key is allowed by the allowlist, supporting wildcard prefixes. + * Wildcards are entries ending with "*" (e.g., "bg:*" matches "bg:color", "bg:opacity", etc.) + */ +function isKeyAllowed(key: string, allowedKeys: Set): boolean { + // Check exact match first + if (allowedKeys.has(key)) { + return true; + } + + // Check wildcard prefixes (entries ending with "*") + for (const allowed of allowedKeys) { + if (allowed.endsWith("*")) { + const prefix = allowed.slice(0, -1); // Remove the "*" + if (key.startsWith(prefix)) { + return true; + } + } + } + + return false; +} + +/** + * Validates a preset before application. + * Returns validation result with error details if invalid. + */ +export function validatePresetBeforeApply( + presetName: string, + presetData: Record +): PresetValidationResult { + const warnings: string[] = []; + + // Determine preset type from name prefix + let presetType: string | null = null; + for (const prefix of Object.keys(PRESET_KEY_ALLOWLISTS)) { + if (presetName.startsWith(prefix)) { + presetType = prefix; + break; + } + } + + // If preset type not recognized, allow but warn + if (!presetType) { + warnings.push(`Unknown preset type: ${presetName}`); + return { valid: true, warnings }; + } + + const allowedKeys = PRESET_KEY_ALLOWLISTS[presetType]; + const disallowedKeys: string[] = []; + + // Check each key in the preset + for (const key of Object.keys(presetData)) { + // Skip display keys (always allowed) + if (key.startsWith("display:")) { + continue; + } + + if (!isKeyAllowed(key, allowedKeys)) { + disallowedKeys.push(key); + } + } + + if (disallowedKeys.length > 0) { + return { + valid: false, + error: `Preset "${presetName}" contains keys not allowed for its type: ${disallowedKeys.join(", ")}` + }; + } + + return { valid: true, warnings: warnings.length > 0 ? warnings : undefined }; +} + +/** + * Sanitizes a preset by removing disallowed keys. + * Returns a new preset object with only allowed keys. + */ +export function sanitizePreset( + presetName: string, + presetData: Record +): Record { + // Determine preset type from name prefix + let presetType: string | null = null; + for (const prefix of Object.keys(PRESET_KEY_ALLOWLISTS)) { + if (presetName.startsWith(prefix)) { + presetType = prefix; + break; + } + } + + // If preset type not recognized, return as-is (backend will validate) + if (!presetType) { + return presetData; + } + + const allowedKeys = PRESET_KEY_ALLOWLISTS[presetType]; + const sanitized: Record = {}; + + for (const [key, value] of Object.entries(presetData)) { + // Always allow display keys + if (key.startsWith("display:")) { + sanitized[key] = value; + continue; + } + + if (isKeyAllowed(key, allowedKeys)) { + sanitized[key] = value; + } + } + + return sanitized; +} diff --git a/frontend/wave.ts b/frontend/wave.ts index 4cb8ee095f..42c8a4f2be 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -190,6 +190,11 @@ async function initWave(initOpts: WaveInitOpts) { registerGlobalKeys(); registerElectronReinjectKeyHandler(); registerControlShiftStateUpdateHandler(); + + // Initialize tab base directory validation + const { initTabBasedirValidation } = await import("@/store/tab-basedir-validation-hook"); + initTabBasedirValidation(); + await loadMonaco(); const fullConfig = await RpcApi.GetFullConfigCommand(TabRpcClient); console.log("fullconfig", fullConfig); diff --git a/pkg/service/objectservice/objectservice.go b/pkg/service/objectservice/objectservice.go index 8d6dc15690..461d08d722 100644 --- a/pkg/service/objectservice/objectservice.go +++ b/pkg/service/objectservice/objectservice.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcore" @@ -86,7 +87,12 @@ func (svc *ObjectService) UpdateTabName(uiContext waveobj.UIContext, tabId, name if err != nil { return nil, fmt.Errorf("error updating tab name: %w", err) } - return waveobj.ContextGetUpdatesRtn(ctx), nil + updates := waveobj.ContextGetUpdatesRtn(ctx) + go func() { + defer panichandler.PanicHandler("ObjectService:UpdateTabName:SendUpdateEvents", recover()) + wps.Broker.SendUpdateEvents(updates) + }() + return updates, nil } func (svc *ObjectService) CreateBlock_Meta() tsgenmeta.MethodMeta { @@ -109,7 +115,12 @@ func (svc *ObjectService) CreateBlock(uiContext waveobj.UIContext, blockDef *wav return "", nil, err } - return blockData.OID, waveobj.ContextGetUpdatesRtn(ctx), nil + updates := waveobj.ContextGetUpdatesRtn(ctx) + go func() { + defer panichandler.PanicHandler("ObjectService:CreateBlock:SendUpdateEvents", recover()) + wps.Broker.SendUpdateEvents(updates) + }() + return blockData.OID, updates, nil } func (svc *ObjectService) DeleteBlock_Meta() tsgenmeta.MethodMeta { @@ -126,7 +137,12 @@ func (svc *ObjectService) DeleteBlock(uiContext waveobj.UIContext, blockId strin if err != nil { return nil, fmt.Errorf("error deleting block: %w", err) } - return waveobj.ContextGetUpdatesRtn(ctx), nil + updates := waveobj.ContextGetUpdatesRtn(ctx) + go func() { + defer panichandler.PanicHandler("ObjectService:DeleteBlock:SendUpdateEvents", recover()) + wps.Broker.SendUpdateEvents(updates) + }() + return updates, nil } func (svc *ObjectService) UpdateObjectMeta_Meta() tsgenmeta.MethodMeta { @@ -143,11 +159,80 @@ func (svc *ObjectService) UpdateObjectMeta(uiContext waveobj.UIContext, orefStr if err != nil { return nil, fmt.Errorf("error parsing object reference: %w", err) } + // Validate metadata before persistence + if err := waveobj.ValidateMetadata(*oref, meta); err != nil { + return nil, fmt.Errorf("metadata validation failed: %w", err) + } err = wstore.UpdateObjectMeta(ctx, *oref, meta, false) if err != nil { return nil, fmt.Errorf("error updating %q meta: %w", orefStr, err) } - return waveobj.ContextGetUpdatesRtn(ctx), nil + updates := waveobj.ContextGetUpdatesRtn(ctx) + go func() { + defer panichandler.PanicHandler("ObjectService:UpdateObjectMeta:SendUpdateEvents", recover()) + wps.Broker.SendUpdateEvents(updates) + }() + return updates, nil +} + +func (svc *ObjectService) UpdateObjectMetaWithVersion_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + ArgNames: []string{"uiContext", "oref", "meta", "expectedVersion"}, + } +} + +func (svc *ObjectService) UpdateObjectMetaWithVersion(uiContext waveobj.UIContext, orefStr string, meta waveobj.MetaMapType, expectedVersion int) (waveobj.UpdatesRtnType, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + ctx = waveobj.ContextWithUpdates(ctx) + oref, err := parseORef(orefStr) + if err != nil { + return nil, fmt.Errorf("error parsing object reference: %w", err) + } + // Validate metadata before persistence + if err := waveobj.ValidateMetadata(*oref, meta); err != nil { + return nil, fmt.Errorf("metadata validation failed: %w", err) + } + err = wstore.UpdateObjectMetaWithVersion(ctx, *oref, meta, expectedVersion, false) + if err != nil { + return nil, fmt.Errorf("error updating %q meta: %w", orefStr, err) + } + updates := waveobj.ContextGetUpdatesRtn(ctx) + go func() { + defer panichandler.PanicHandler("ObjectService:UpdateObjectMetaWithVersion:SendUpdateEvents", recover()) + wps.Broker.SendUpdateEvents(updates) + }() + return updates, nil +} + +func (svc *ObjectService) UpdateObjectMetaIfNotLocked_Meta() tsgenmeta.MethodMeta { + return tsgenmeta.MethodMeta{ + ArgNames: []string{"uiContext", "oref", "meta", "lockKey", "expectedVersion"}, + } +} + +func (svc *ObjectService) UpdateObjectMetaIfNotLocked(uiContext waveobj.UIContext, orefStr string, meta waveobj.MetaMapType, lockKey string, expectedVersion int) (waveobj.UpdatesRtnType, error) { + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + ctx = waveobj.ContextWithUpdates(ctx) + oref, err := parseORef(orefStr) + if err != nil { + return nil, fmt.Errorf("error parsing object reference: %w", err) + } + // Validate metadata before persistence + if err := waveobj.ValidateMetadata(*oref, meta); err != nil { + return nil, fmt.Errorf("metadata validation failed: %w", err) + } + err = wstore.UpdateObjectMetaIfNotLocked(ctx, *oref, meta, lockKey, expectedVersion) + if err != nil { + return nil, fmt.Errorf("error updating %q meta: %w", orefStr, err) + } + updates := waveobj.ContextGetUpdatesRtn(ctx) + go func() { + defer panichandler.PanicHandler("ObjectService:UpdateObjectMetaIfNotLocked:SendUpdateEvents", recover()) + wps.Broker.SendUpdateEvents(updates) + }() + return updates, nil } func (svc *ObjectService) UpdateObject_Meta() tsgenmeta.MethodMeta { @@ -164,6 +249,13 @@ func (svc *ObjectService) UpdateObject(uiContext waveobj.UIContext, waveObj wave return nil, fmt.Errorf("update wavobj is nil") } oref := waveobj.ORefFromWaveObj(waveObj) + // Validate metadata if present + meta := waveobj.GetMeta(waveObj) + if meta != nil && len(meta) > 0 { + if err := waveobj.ValidateMetadata(*oref, meta); err != nil { + return nil, fmt.Errorf("metadata validation failed: %w", err) + } + } found, err := wstore.DBExistsORef(ctx, *oref) if err != nil { return nil, fmt.Errorf("error getting object: %w", err) @@ -175,12 +267,17 @@ func (svc *ObjectService) UpdateObject(uiContext waveobj.UIContext, waveObj wave if err != nil { return nil, fmt.Errorf("error updating object: %w", err) } - if (waveObj.GetOType() == waveobj.OType_Workspace) && (waveObj.(*waveobj.Workspace).Name != "") { - wps.Broker.Publish(wps.WaveEvent{ - Event: wps.Event_WorkspaceUpdate}) - } + updates := waveobj.ContextGetUpdatesRtn(ctx) + go func() { + defer panichandler.PanicHandler("ObjectService:UpdateObject:SendUpdateEvents", recover()) + if (waveObj.GetOType() == waveobj.OType_Workspace) && (waveObj.(*waveobj.Workspace).Name != "") { + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_WorkspaceUpdate}) + } + wps.Broker.SendUpdateEvents(updates) + }() if returnUpdates { - return waveobj.ContextGetUpdatesRtn(ctx), nil + return updates, nil } return nil, nil } diff --git a/pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh b/pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh index cc002b57a9..ffd6c04928 100644 --- a/pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh +++ b/pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh @@ -13,10 +13,12 @@ Remove-Item Env:WAVETERM_SWAPTOKEN wsh completion powershell | Out-String | Invoke-Expression if ($PSVersionTable.PSVersion.Major -lt 7) { - return # skip OSC setup entirely + return # skip OSC setup entirely - PSReadLine hooks require PS7+ } $Global:_WAVETERM_SI_FIRSTPROMPT = $true +$Global:_WAVETERM_SI_LASTEXITCODE = 0 +$Global:_WAVETERM_SI_COMMAND_STARTED = $false # shell integration function Global:_waveterm_si_blocked { @@ -26,28 +28,97 @@ function Global:_waveterm_si_blocked { function Global:_waveterm_si_osc7 { if (_waveterm_si_blocked) { return } - + # Percent-encode the raw path as-is (handles UNC, drive letters, etc.) $encoded_pwd = [System.Uri]::EscapeDataString($PWD.Path) - + # OSC 7 - current directory Write-Host -NoNewline "`e]7;file://localhost/$encoded_pwd`a" } +# OSC 16162 commands for full shell integration +# A = ready (at prompt) +# C = command started (with base64 encoded command) +# D = command done (with exit code) + +function Global:_waveterm_si_send_ready { + if (_waveterm_si_blocked) { return } + Write-Host -NoNewline "`e]16162;A`a" +} + +function Global:_waveterm_si_send_command { + param([string]$Command) + if (_waveterm_si_blocked) { return } + if ([string]::IsNullOrWhiteSpace($Command)) { return } + + # Base64 encode the command + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Command) + $cmd64 = [System.Convert]::ToBase64String($bytes) + + # Limit command length to avoid issues + if ($cmd64.Length -gt 8192) { + Write-Host -NoNewline "`e]16162;C`a" + } else { + Write-Host -NoNewline "`e]16162;C;{`"cmd64`":`"$cmd64`"}`a" + } + $Global:_WAVETERM_SI_COMMAND_STARTED = $true +} + +function Global:_waveterm_si_send_done { + param([int]$ExitCode) + if (_waveterm_si_blocked) { return } + if (-not $Global:_WAVETERM_SI_COMMAND_STARTED) { return } + + Write-Host -NoNewline "`e]16162;D;{`"exitcode`":$ExitCode}`a" + $Global:_WAVETERM_SI_COMMAND_STARTED = $false +} + function Global:_waveterm_si_prompt { if (_waveterm_si_blocked) { return } - + + # Capture exit code immediately (before any other commands change it) + $currentExitCode = if ($?) { 0 } else { if ($LASTEXITCODE) { $LASTEXITCODE } else { 1 } } + if ($Global:_WAVETERM_SI_FIRSTPROMPT) { - # not sending uname - $shellversion = $PSVersionTable.PSVersion.ToString() - Write-Host -NoNewline "`e]16162;M;{`"shell`":`"pwsh`",`"shellversion`":`"$shellversion`",`"integration`":false}`a" + # Send metadata on first prompt + $shellversion = $PSVersionTable.PSVersion.ToString() + Write-Host -NoNewline "`e]16162;M;{`"shell`":`"pwsh`",`"shellversion`":`"$shellversion`",`"integration`":true}`a" $Global:_WAVETERM_SI_FIRSTPROMPT = $false + } else { + # Send command done for previous command (if any) + _waveterm_si_send_done -ExitCode $currentExitCode } - + + # Send OSC 7 for current directory _waveterm_si_osc7 + + # Send ready signal + _waveterm_si_send_ready } -# Add the OSC 7 call to the prompt function +# Hook into PSReadLine to detect when commands are executed +# This is called just before a command is accepted and executed +if (Get-Module PSReadLine) { + # Save any existing AcceptLine handler + $existingHandler = (Get-PSReadLineKeyHandler -Chord Enter | Where-Object { $_.Function -eq 'AcceptLine' }) + + # Create a wrapper that sends command notification before executing + Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock { + $line = $null + $cursor = $null + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor) + + # Send command started notification + if (-not [string]::IsNullOrWhiteSpace($line)) { + _waveterm_si_send_command -Command $line + } + + # Call the original AcceptLine function + [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() + } +} + +# Add the prompt hooks if (Test-Path Function:\prompt) { $global:_waveterm_original_prompt = $function:prompt function Global:prompt { @@ -59,4 +130,4 @@ if (Test-Path Function:\prompt) { _waveterm_si_prompt "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " } -} \ No newline at end of file +} diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index 801929541d..c0db91ce6f 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -94,6 +94,12 @@ const ( MetaKey_BgBlendMode = "bg:blendmode" MetaKey_BgBorderColor = "bg:bordercolor" MetaKey_BgActiveBorderColor = "bg:activebordercolor" + MetaKey_BgText = "bg:text" + + MetaKey_TabBaseDir = "tab:basedir" + MetaKey_TabBaseDirLock = "tab:basedirlock" + MetaKey_TabColor = "tab:color" + MetaKey_TabTermStatus = "tab:termstatus" MetaKey_WaveAiPanelOpen = "waveai:panelopen" MetaKey_WaveAiPanelWidth = "waveai:panelwidth" diff --git a/pkg/waveobj/validators.go b/pkg/waveobj/validators.go new file mode 100644 index 0000000000..0ad7ba1499 --- /dev/null +++ b/pkg/waveobj/validators.go @@ -0,0 +1,935 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveobj + +import ( + "fmt" + "log" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/wavetermdev/waveterm/pkg/wavebase" +) + +// Validation constants +const ( + MaxPathLength = 4096 + MaxStringLength = 256 + MaxURLLength = 2048 + MaxCommandLength = 65536 // 64KB + MaxScriptLength = 1048576 // 1MB + MaxArrayItems = 256 + MaxMapEntries = 1024 + MaxArrayItemLength = 4096 + MaxMapKeyLength = 256 + MaxMapValueLength = 4096 +) + +// ValidationError provides detailed error information +type ValidationError struct { + Key string + Value interface{} + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("invalid metadata %s: %s", e.Key, e.Message) +} + +// ValidationFunc is the signature for field validators +type ValidationFunc func(key string, value interface{}) error + +// ValidateMetadata validates metadata for a specific object type +func ValidateMetadata(oref ORef, meta MetaMapType) error { + validators := getValidatorsForOType(oref.OType) + + for key, value := range meta { + if value == nil { + // Null means delete - always allowed + continue + } + + if validator, ok := validators[key]; ok { + if err := validator(key, value); err != nil { + return err + } + } + // Unknown keys pass through without validation (extensibility) + } + + return nil +} + +func getValidatorsForOType(otype string) map[string]ValidationFunc { + switch otype { + case OType_Tab: + return tabValidators + case OType_Block: + return blockValidators + case OType_Workspace: + return workspaceValidators + case OType_Window: + return windowValidators + default: + return commonValidators + } +} + +// ValidatePath checks path fields for security and validity +func ValidatePath(key string, value interface{}, mustBeDir bool) error { + // Allow clearing (nil handled by caller) + if value == nil { + return nil + } + + path, ok := value.(string) + if !ok { + return &ValidationError{ + Key: key, + Value: value, + Message: fmt.Sprintf("must be a string, got %T", value), + } + } + + // Allow empty to clear + if path == "" { + return nil + } + + // Length check (DoS protection) + if len(path) > MaxPathLength { + return &ValidationError{ + Key: key, + Value: truncateForError(path, 50), + Message: fmt.Sprintf("path too long (max %d characters)", MaxPathLength), + } + } + + // Null byte check (security) + if strings.Contains(path, "\x00") { + return &ValidationError{ + Key: key, + Value: "[contains null byte]", + Message: "path contains null byte", + } + } + + // Path traversal check using absolute path comparison + if err := checkPathTraversal(path); err != nil { + return &ValidationError{ + Key: key, + Value: path, + Message: err.Error(), + } + } + + // Expand home directory for existence checks + expandedPath := path + if strings.HasPrefix(path, "~") { + expanded, err := wavebase.ExpandHomeDir(path) + if err != nil { + // ExpandHomeDir already checks for traversal + return &ValidationError{ + Key: key, + Value: path, + Message: err.Error(), + } + } + expandedPath = expanded + } + + // Check existence and type (soft validation - warn but allow) + info, err := os.Stat(expandedPath) + if err != nil { + if os.IsNotExist(err) { + // Log warning but allow non-existent paths + // This enables setting paths before directories are created + log.Printf("[validation] warning: %s path does not exist: %s", key, expandedPath) + return nil + } + if os.IsPermission(err) { + // Permission error - also warn but allow + log.Printf("[validation] warning: %s path not accessible: %s", key, expandedPath) + return nil + } + // Other errors - allow with warning + log.Printf("[validation] warning: %s path check failed: %s: %v", key, expandedPath, err) + return nil + } + + // Directory check (hard validation if file exists) + if mustBeDir && !info.IsDir() { + return &ValidationError{ + Key: key, + Value: path, + Message: "path is not a directory", + } + } + + return nil +} + +// checkPathTraversal performs absolute path comparison to detect traversal +func checkPathTraversal(path string) error { + // Clean the path first + cleanPath := filepath.Clean(path) + + // Convert to absolute path for comparison + absPath := cleanPath + if !filepath.IsAbs(cleanPath) { + // For relative paths starting with ~, expand first + if strings.HasPrefix(cleanPath, "~") { + var err error + absPath, err = wavebase.ExpandHomeDir(cleanPath) + if err != nil { + return fmt.Errorf("path expansion failed: %w", err) + } + } else { + // Get absolute path relative to cwd + cwd, err := os.Getwd() + if err != nil { + // If we can't get cwd, fall back to string check + if strings.Contains(cleanPath, "..") { + return fmt.Errorf("path traversal sequence detected") + } + return nil + } + absPath = filepath.Join(cwd, cleanPath) + } + } + + // For Windows UNC paths (\\server\share\path) + if runtime.GOOS == "windows" && strings.HasPrefix(path, "\\\\") { + // UNC paths should not traverse above the share + // When splitting \\server\share\path by \, we get ["", "", "server", "share", "path"] + // The first two elements are empty due to the leading \\ + parts := strings.Split(filepath.Clean(path), string(filepath.Separator)) + // Need at least 4 parts: ["", "", "server", "share"] for a valid UNC share + if len(parts) >= 4 { + // parts[2] is server, parts[3] is share + server := parts[2] + share := parts[3] + if server == "" || share == "" { + return fmt.Errorf("invalid UNC path format") + } + // Build the share root path: \\server\share + sharePath := "\\\\" + server + string(filepath.Separator) + share + cleanedPath := filepath.Clean(path) + if !strings.HasPrefix(cleanedPath, sharePath) { + return fmt.Errorf("path traversal detected in UNC path") + } + } + } + + // Final check: compare cleaned path segments + // If ".." appears after cleaning, it's traversing + absPathClean := filepath.Clean(absPath) + segments := strings.Split(absPathClean, string(filepath.Separator)) + for _, seg := range segments { + if seg == ".." { + return fmt.Errorf("path traversal sequence detected after normalization") + } + } + + return nil +} + +// ValidateBool ensures value is a boolean +func ValidateBool(key string, value interface{}) error { + if value == nil { + return nil + } + + if _, ok := value.(bool); !ok { + return &ValidationError{ + Key: key, + Value: value, + Message: fmt.Sprintf("must be a boolean, got %T", value), + } + } + return nil +} + +// ValidateNullableBool ensures value is nil or boolean +func ValidateNullableBool(key string, value interface{}) error { + if value == nil { + return nil // nil is valid for pointer types + } + + if _, ok := value.(bool); !ok { + return &ValidationError{ + Key: key, + Value: value, + Message: fmt.Sprintf("must be a boolean or null, got %T", value), + } + } + return nil +} + +// ValidateString ensures value is a string within length limits +func ValidateString(key string, value interface{}, maxLen int) error { + if value == nil { + return nil + } + + s, ok := value.(string) + if !ok { + return &ValidationError{ + Key: key, + Value: value, + Message: fmt.Sprintf("must be a string, got %T", value), + } + } + + if maxLen > 0 && len(s) > maxLen { + return &ValidationError{ + Key: key, + Value: truncateForError(s, 50), + Message: fmt.Sprintf("exceeds maximum length of %d characters", maxLen), + } + } + + if strings.Contains(s, "\x00") { + return &ValidationError{ + Key: key, + Value: "[contains null byte]", + Message: "contains null byte", + } + } + + return nil +} + +// ValidateInt ensures value is an integer within range +func ValidateInt(key string, value interface{}, minVal, maxVal int) error { + if value == nil { + return nil + } + + // JSON numbers come as float64 + f, ok := value.(float64) + if !ok { + return &ValidationError{ + Key: key, + Value: value, + Message: fmt.Sprintf("must be a number, got %T", value), + } + } + + i := int(f) + if i < minVal || i > maxVal { + return &ValidationError{ + Key: key, + Value: i, + Message: fmt.Sprintf("must be between %d and %d", minVal, maxVal), + } + } + + return nil +} + +// ValidateNullableInt ensures value is nil or integer within range +func ValidateNullableInt(key string, value interface{}, minVal, maxVal int) error { + if value == nil { + return nil // nil is valid for pointer types + } + + // JSON numbers come as float64 + f, ok := value.(float64) + if !ok { + return &ValidationError{ + Key: key, + Value: value, + Message: fmt.Sprintf("must be a number or null, got %T", value), + } + } + + i := int(f) + if i < minVal || i > maxVal { + return &ValidationError{ + Key: key, + Value: i, + Message: fmt.Sprintf("must be between %d and %d", minVal, maxVal), + } + } + + return nil +} + +// ValidateFloat ensures value is a float within range +func ValidateFloat(key string, value interface{}, minVal, maxVal float64) error { + if value == nil { + return nil + } + + f, ok := value.(float64) + if !ok { + return &ValidationError{ + Key: key, + Value: value, + Message: fmt.Sprintf("must be a number, got %T", value), + } + } + + if f < minVal || f > maxVal { + return &ValidationError{ + Key: key, + Value: f, + Message: fmt.Sprintf("must be between %.2f and %.2f", minVal, maxVal), + } + } + + return nil +} + +// ValidateNullableFloat ensures value is nil or float within range +func ValidateNullableFloat(key string, value interface{}, minVal, maxVal float64) error { + if value == nil { + return nil // nil is valid for pointer types + } + + f, ok := value.(float64) + if !ok { + return &ValidationError{ + Key: key, + Value: value, + Message: fmt.Sprintf("must be a number or null, got %T", value), + } + } + + if f < minVal || f > maxVal { + return &ValidationError{ + Key: key, + Value: f, + Message: fmt.Sprintf("must be between %.2f and %.2f", minVal, maxVal), + } + } + + return nil +} + +// ValidateURL ensures value is a valid URL +func ValidateURL(key string, value interface{}, allowedSchemes []string) error { + if value == nil { + return nil + } + + s, ok := value.(string) + if !ok { + return &ValidationError{ + Key: key, + Value: value, + Message: fmt.Sprintf("must be a string, got %T", value), + } + } + + if s == "" { + return nil + } + + if len(s) > MaxURLLength { + return &ValidationError{ + Key: key, + Value: truncateForError(s, 50), + Message: fmt.Sprintf("URL too long (max %d characters)", MaxURLLength), + } + } + + parsed, err := url.Parse(s) + if err != nil { + return &ValidationError{ + Key: key, + Value: s, + Message: fmt.Sprintf("invalid URL: %v", err), + } + } + + if len(allowedSchemes) > 0 { + schemeAllowed := false + for _, scheme := range allowedSchemes { + if parsed.Scheme == scheme { + schemeAllowed = true + break + } + } + if !schemeAllowed { + return &ValidationError{ + Key: key, + Value: s, + Message: fmt.Sprintf("URL scheme must be one of: %v", allowedSchemes), + } + } + } + + return nil +} + +// ValidateStringArray validates array fields like cmd:args +func ValidateStringArray(key string, value interface{}, maxLen, maxItems int) error { + if value == nil { + return nil + } + + arr, ok := value.([]interface{}) + if !ok { + return &ValidationError{ + Key: key, + Value: value, + Message: fmt.Sprintf("must be an array, got %T", value), + } + } + + if len(arr) > maxItems { + return &ValidationError{ + Key: key, + Value: fmt.Sprintf("[%d items]", len(arr)), + Message: fmt.Sprintf("array exceeds maximum of %d items", maxItems), + } + } + + for i, item := range arr { + s, ok := item.(string) + if !ok { + return &ValidationError{ + Key: key, + Value: fmt.Sprintf("item[%d]", i), + Message: fmt.Sprintf("array item must be string, got %T", item), + } + } + + if len(s) > maxLen { + return &ValidationError{ + Key: key, + Value: fmt.Sprintf("item[%d]", i), + Message: fmt.Sprintf("array item exceeds maximum length of %d", maxLen), + } + } + + if strings.Contains(s, "\x00") { + return &ValidationError{ + Key: key, + Value: fmt.Sprintf("item[%d]", i), + Message: "array item contains null byte", + } + } + } + + return nil +} + +// ValidateStringMap validates map fields like cmd:env +func ValidateStringMap(key string, value interface{}, maxKeyLen, maxValueLen int) error { + if value == nil { + return nil + } + + m, ok := value.(map[string]interface{}) + if !ok { + return &ValidationError{ + Key: key, + Value: value, + Message: fmt.Sprintf("must be an object/map, got %T", value), + } + } + + if len(m) > MaxMapEntries { + return &ValidationError{ + Key: key, + Value: fmt.Sprintf("{%d entries}", len(m)), + Message: fmt.Sprintf("map exceeds maximum of %d entries", MaxMapEntries), + } + } + + for k, v := range m { + // Validate key + if len(k) > maxKeyLen { + return &ValidationError{ + Key: key, + Value: fmt.Sprintf("key: %s", truncateForError(k, 20)), + Message: fmt.Sprintf("map key exceeds maximum length of %d", maxKeyLen), + } + } + + if strings.Contains(k, "\x00") { + return &ValidationError{ + Key: key, + Value: fmt.Sprintf("key: %s", k), + Message: "map key contains null byte", + } + } + + // Validate value - allow nil for deletion + if v == nil { + continue + } + + vs, ok := v.(string) + if !ok { + return &ValidationError{ + Key: key, + Value: fmt.Sprintf("value for key: %s", k), + Message: fmt.Sprintf("map value must be string, got %T", v), + } + } + + if len(vs) > maxValueLen { + return &ValidationError{ + Key: key, + Value: fmt.Sprintf("value for key: %s", k), + Message: fmt.Sprintf("map value exceeds maximum length of %d", maxValueLen), + } + } + + if strings.Contains(vs, "\x00") { + return &ValidationError{ + Key: key, + Value: fmt.Sprintf("value for key: %s", k), + Message: "map value contains null byte", + } + } + } + + return nil +} + +// ValidateCommand validates command execution strings +func ValidateCommand(key string, value interface{}) error { + return ValidateString(key, value, MaxCommandLength) +} + +// ValidateScript validates script content +func ValidateScript(key string, value interface{}) error { + return ValidateString(key, value, MaxScriptLength) +} + +// truncateForError truncates a string for display in error messages +func truncateForError(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +// Common validators for all object types +var commonValidators = map[string]ValidationFunc{ + MetaKey_DisplayName: func(k string, v interface{}) error { + return ValidateString(k, v, MaxStringLength) + }, + MetaKey_Icon: func(k string, v interface{}) error { + return ValidateString(k, v, 128) + }, + MetaKey_IconColor: func(k string, v interface{}) error { + return ValidateString(k, v, 64) + }, +} + +// Tab-specific validators +var tabValidators = map[string]ValidationFunc{ + MetaKey_TabBaseDir: func(k string, v interface{}) error { + return ValidatePath(k, v, true) // must be directory + }, + MetaKey_TabBaseDirLock: ValidateBool, + MetaKey_TabTermStatus: func(k string, v interface{}) error { + return ValidateString(k, v, 32) // running, stopped, finished + }, + MetaKey_Bg: func(k string, v interface{}) error { + return ValidateString(k, v, MaxURLLength) + }, + MetaKey_BgOpacity: func(k string, v interface{}) error { + return ValidateFloat(k, v, 0.0, 1.0) + }, +} + +// Workspace-specific validators +var workspaceValidators = map[string]ValidationFunc{ + MetaKey_Bg: func(k string, v interface{}) error { + return ValidateString(k, v, MaxURLLength) + }, + MetaKey_BgOpacity: func(k string, v interface{}) error { + return ValidateFloat(k, v, 0.0, 1.0) + }, +} + +// Window-specific validators +var windowValidators = map[string]ValidationFunc{ + MetaKey_Bg: func(k string, v interface{}) error { + return ValidateString(k, v, MaxURLLength) + }, + MetaKey_BgOpacity: func(k string, v interface{}) error { + return ValidateFloat(k, v, 0.0, 1.0) + }, +} + +// Block-specific validators +var blockValidators = map[string]ValidationFunc{ + // HIGH RISK: Command execution fields + MetaKey_Cmd: ValidateCommand, + MetaKey_CmdInitScript: ValidateScript, + MetaKey_CmdInitScriptSh: ValidateScript, + MetaKey_CmdInitScriptBash: ValidateScript, + MetaKey_CmdInitScriptZsh: ValidateScript, + MetaKey_CmdInitScriptPwsh: ValidateScript, + MetaKey_CmdInitScriptFish: ValidateScript, + MetaKey_CmdArgs: func(k string, v interface{}) error { + return ValidateStringArray(k, v, MaxArrayItemLength, MaxArrayItems) + }, + MetaKey_CmdEnv: func(k string, v interface{}) error { + return ValidateStringMap(k, v, MaxMapKeyLength, MaxMapValueLength) + }, + + // Path fields + MetaKey_CmdCwd: func(k string, v interface{}) error { + return ValidatePath(k, v, true) // must be directory + }, + MetaKey_File: func(k string, v interface{}) error { + return ValidatePath(k, v, false) // can be file or directory + }, + MetaKey_TsunamiAppPath: func(k string, v interface{}) error { + return ValidatePath(k, v, true) + }, + MetaKey_TsunamiScaffoldPath: func(k string, v interface{}) error { + return ValidatePath(k, v, true) + }, + MetaKey_TsunamiSdkReplacePath: func(k string, v interface{}) error { + return ValidatePath(k, v, true) + }, + MetaKey_TermLocalShellPath: func(k string, v interface{}) error { + return ValidatePath(k, v, false) // executable file + }, + + // URL fields + MetaKey_Url: func(k string, v interface{}) error { + return ValidateURL(k, v, []string{"http", "https", "file"}) + }, + MetaKey_PinnedUrl: func(k string, v interface{}) error { + return ValidateURL(k, v, []string{"http", "https", "file"}) + }, + MetaKey_AiBaseURL: func(k string, v interface{}) error { + return ValidateURL(k, v, []string{"http", "https"}) + }, + + // String fields + MetaKey_View: func(k string, v interface{}) error { + return ValidateString(k, v, 64) + }, + MetaKey_Controller: func(k string, v interface{}) error { + return ValidateString(k, v, 64) + }, + MetaKey_Connection: func(k string, v interface{}) error { + return ValidateString(k, v, MaxStringLength) + }, + MetaKey_FrameTitle: func(k string, v interface{}) error { + return ValidateString(k, v, MaxStringLength) + }, + MetaKey_FrameIcon: func(k string, v interface{}) error { + return ValidateString(k, v, 128) + }, + MetaKey_FrameText: func(k string, v interface{}) error { + return ValidateString(k, v, MaxStringLength) + }, + MetaKey_FrameBorderColor: func(k string, v interface{}) error { + return ValidateString(k, v, 64) + }, + MetaKey_FrameActiveBorderColor: func(k string, v interface{}) error { + return ValidateString(k, v, 64) + }, + MetaKey_AiPresetKey: func(k string, v interface{}) error { + return ValidateString(k, v, 128) + }, + MetaKey_AiApiType: func(k string, v interface{}) error { + return ValidateString(k, v, 64) + }, + MetaKey_AiApiToken: func(k string, v interface{}) error { + return ValidateString(k, v, MaxURLLength) // API tokens can be long + }, + MetaKey_AiName: func(k string, v interface{}) error { + return ValidateString(k, v, MaxStringLength) + }, + MetaKey_AiModel: func(k string, v interface{}) error { + return ValidateString(k, v, MaxStringLength) + }, + MetaKey_AiOrgID: func(k string, v interface{}) error { + return ValidateString(k, v, MaxStringLength) + }, + MetaKey_AIApiVersion: func(k string, v interface{}) error { + return ValidateString(k, v, 64) + }, + MetaKey_TermFontFamily: func(k string, v interface{}) error { + return ValidateString(k, v, MaxStringLength) + }, + MetaKey_TermMode: func(k string, v interface{}) error { + return ValidateString(k, v, 64) + }, + MetaKey_TermTheme: func(k string, v interface{}) error { + return ValidateString(k, v, MaxStringLength) + }, + MetaKey_SysinfoType: func(k string, v interface{}) error { + return ValidateString(k, v, 64) + }, + MetaKey_BgBlendMode: func(k string, v interface{}) error { + return ValidateString(k, v, 64) + }, + MetaKey_BgBorderColor: func(k string, v interface{}) error { + return ValidateString(k, v, 64) + }, + MetaKey_BgActiveBorderColor: func(k string, v interface{}) error { + return ValidateString(k, v, 64) + }, + MetaKey_WebPartition: func(k string, v interface{}) error { + return ValidateString(k, v, MaxStringLength) + }, + MetaKey_WebUserAgentType: func(k string, v interface{}) error { + return ValidateString(k, v, 64) + }, + MetaKey_VDomCorrelationId: func(k string, v interface{}) error { + return ValidateString(k, v, MaxStringLength) + }, + MetaKey_VDomRoute: func(k string, v interface{}) error { + return ValidateString(k, v, MaxURLLength) + }, + + // Numeric fields + MetaKey_TermFontSize: func(k string, v interface{}) error { + return ValidateInt(k, v, 6, 72) + }, + MetaKey_EditorFontSize: func(k string, v interface{}) error { + return ValidateFloat(k, v, 6.0, 72.0) + }, + MetaKey_WebZoom: func(k string, v interface{}) error { + return ValidateFloat(k, v, 0.25, 5.0) + }, + MetaKey_GraphNumPoints: func(k string, v interface{}) error { + return ValidateInt(k, v, 10, 10000) + }, + MetaKey_BgOpacity: func(k string, v interface{}) error { + return ValidateFloat(k, v, 0.0, 1.0) + }, + MetaKey_TermTransparency: func(k string, v interface{}) error { + return ValidateFloat(k, v, 0.0, 1.0) + }, + MetaKey_TermScrollback: func(k string, v interface{}) error { + return ValidateInt(k, v, 100, 100000) + }, + MetaKey_AiMaxTokens: func(k string, v interface{}) error { + return ValidateInt(k, v, 1, 1000000) + }, + MetaKey_AiTimeoutMs: func(k string, v interface{}) error { + return ValidateInt(k, v, 1000, 600000) + }, + MetaKey_MarkdownFontSize: func(k string, v interface{}) error { + return ValidateFloat(k, v, 6.0, 72.0) + }, + MetaKey_MarkdownFixedFontSize: func(k string, v interface{}) error { + return ValidateFloat(k, v, 6.0, 72.0) + }, + MetaKey_WaveAiPanelWidth: func(k string, v interface{}) error { + return ValidateFloat(k, v, 100, 2000) + }, + MetaKey_DisplayOrder: func(k string, v interface{}) error { + return ValidateFloat(k, v, -1000000, 1000000) + }, + + // Nullable pointer fields + MetaKey_CmdCloseOnExitDelay: func(k string, v interface{}) error { + return ValidateNullableInt(k, v, 0, 60000) + }, + + // Boolean fields + MetaKey_Edit: ValidateBool, + MetaKey_Frame: ValidateBool, + MetaKey_CmdInteractive: ValidateBool, + MetaKey_CmdLogin: ValidateBool, + MetaKey_CmdRunOnStart: ValidateBool, + MetaKey_CmdClearOnStart: ValidateBool, + MetaKey_CmdRunOnce: ValidateBool, + MetaKey_CmdCloseOnExit: ValidateBool, + MetaKey_CmdCloseOnExitForce: ValidateBool, + MetaKey_CmdNoWsh: ValidateBool, + MetaKey_CmdShell: ValidateBool, + MetaKey_CmdAllowConnChange: ValidateBool, + MetaKey_EditorMinimapEnabled: ValidateBool, + MetaKey_EditorStickyScrollEnabled: ValidateBool, + MetaKey_EditorWordWrap: ValidateBool, + MetaKey_WebHideNav: ValidateBool, + MetaKey_VDomInitialized: ValidateBool, + MetaKey_VDomPersist: ValidateBool, + MetaKey_TermAllowBracketedPaste: ValidateBool, + MetaKey_TermShiftEnterNewline: ValidateBool, + MetaKey_TermMacOptionIsMeta: ValidateBool, + MetaKey_TermConnDebug: ValidateBool, + MetaKey_WaveAiPanelOpen: ValidateBool, +} + +// PresetKeyScope defines which keys are allowed for each preset type +var PresetKeyScope = map[string]map[string]bool{ + "tabvar@": { + MetaKey_TabBaseDir: true, + MetaKey_TabBaseDirLock: true, + MetaKey_DisplayName: true, + MetaKey_DisplayOrder: true, + }, + "bg@": { + MetaKey_BgClear: true, + MetaKey_Bg: true, + MetaKey_BgOpacity: true, + MetaKey_BgBlendMode: true, + MetaKey_BgBorderColor: true, + MetaKey_BgActiveBorderColor: true, + MetaKey_BgText: true, + MetaKey_DisplayName: true, + MetaKey_DisplayOrder: true, + }, +} + +// ValidatePresetScope checks if preset keys are within allowed scope +// This is called during config load, not during UpdateObjectMeta +func ValidatePresetScope(presetName string, meta MetaMapType) error { + // Determine preset type from name prefix + var allowedKeys map[string]bool + for prefix, keys := range PresetKeyScope { + if strings.HasPrefix(presetName, prefix) { + allowedKeys = keys + break + } + } + + if allowedKeys == nil { + // Unknown preset type - log warning but allow + log.Printf("[validation] warning: unknown preset type for %s", presetName) + return nil + } + + for key := range meta { + if !allowedKeys[key] { + return &ValidationError{ + Key: key, + Value: meta[key], + Message: fmt.Sprintf("key not allowed in %s presets", presetName), + } + } + } + + return nil +} + +// init merges common validators into specific validators +func init() { + for k, v := range commonValidators { + if _, exists := tabValidators[k]; !exists { + tabValidators[k] = v + } + if _, exists := blockValidators[k]; !exists { + blockValidators[k] = v + } + if _, exists := workspaceValidators[k]; !exists { + workspaceValidators[k] = v + } + if _, exists := windowValidators[k]; !exists { + windowValidators[k] = v + } + } +} diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index efe0a79f18..472c6c86fc 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -97,6 +97,11 @@ type MetaTSType struct { BgBlendMode string `json:"bg:blendmode,omitempty"` BgBorderColor string `json:"bg:bordercolor,omitempty"` // frame:bordercolor BgActiveBorderColor string `json:"bg:activebordercolor,omitempty"` // frame:activebordercolor + BgText string `json:"bg:text,omitempty"` // text color for background presets + TabBaseDir string `json:"tab:basedir,omitempty"` // base directory for tab context + TabBaseDirLock bool `json:"tab:basedirlock,omitempty"` // lock basedir to prevent smart auto-detection + TabColor string `json:"tab:color,omitempty"` // custom color for tab (hex value) + TabTermStatus string `json:"tab:termstatus,omitempty"` // terminal status: running, stopped, finished // for tabs+waveai WaveAiPanelOpen bool `json:"waveai:panelopen,omitempty"` diff --git a/pkg/wconfig/defaultconfig/presets/tabvars.json b/pkg/wconfig/defaultconfig/presets/tabvars.json new file mode 100644 index 0000000000..bce64fbe75 --- /dev/null +++ b/pkg/wconfig/defaultconfig/presets/tabvars.json @@ -0,0 +1,10 @@ +{ + "tabvar@waveterm-dev": { + "tab:basedir": "~/Code/waveterm", + "tab:basedirlock": false + }, + "tabvar@example-project": { + "tab:basedir": "~/Projects/myapp", + "tab:basedirlock": true + } +} diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 50a1da2474..aac27e8ba1 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -616,6 +616,19 @@ func ReadFullConfig() FullConfigType { utilfn.ReUnmarshal(fieldPtr, configPart) } } + + // Validate preset scopes + if fullConfig.Presets != nil { + for presetName, presetMeta := range fullConfig.Presets { + if err := waveobj.ValidatePresetScope(presetName, presetMeta); err != nil { + fullConfig.ConfigErrors = append(fullConfig.ConfigErrors, ConfigError{ + File: "presets/*.json", + Err: fmt.Sprintf("preset %s: %v", presetName, err), + }) + } + } + } + return fullConfig } diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 9e447dd5f3..e664a7766d 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -154,6 +154,10 @@ func (ws *WshServer) GetMetaCommand(ctx context.Context, data wshrpc.CommandGetM func (ws *WshServer) SetMetaCommand(ctx context.Context, data wshrpc.CommandSetMetaData) error { log.Printf("SetMetaCommand: %s | %v\n", data.ORef, data.Meta) oref := data.ORef + // Validate metadata before persistence + if err := waveobj.ValidateMetadata(oref, data.Meta); err != nil { + return fmt.Errorf("metadata validation failed: %w", err) + } err := wstore.UpdateObjectMeta(ctx, oref, data.Meta, false) if err != nil { return fmt.Errorf("error updating object meta: %w", err) diff --git a/pkg/wstore/wstore.go b/pkg/wstore/wstore.go index 91675729cb..7a2e2a43cd 100644 --- a/pkg/wstore/wstore.go +++ b/pkg/wstore/wstore.go @@ -51,6 +51,76 @@ func UpdateObjectMeta(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaM }) } +// UpdateObjectMetaWithVersion performs an optimistic locking update. +// If expectedVersion > 0 and doesn't match current version, returns ErrVersionMismatch. +// If expectedVersion == 0, behaves like UpdateObjectMeta (no version check). +func UpdateObjectMetaWithVersion(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaMapType, expectedVersion int, mergeSpecial bool) error { + return WithTx(ctx, func(tx *TxWrap) error { + if oref.IsEmpty() { + return fmt.Errorf("empty object reference") + } + obj, _ := DBGetORef(tx.Context(), oref) + if obj == nil { + return ErrNotFound + } + + // Optimistic locking check + currentVersion := waveobj.GetVersion(obj) + if expectedVersion > 0 && currentVersion != expectedVersion { + return fmt.Errorf("%w: expected %d, got %d", ErrVersionMismatch, expectedVersion, currentVersion) + } + + objMeta := waveobj.GetMeta(obj) + if objMeta == nil { + objMeta = make(map[string]any) + } + newMeta := waveobj.MergeMeta(objMeta, meta, mergeSpecial) + waveobj.SetMeta(obj, newMeta) + if err := DBUpdate(tx.Context(), obj); err != nil { + return fmt.Errorf("failed to update object: %w", err) + } + return nil + }) +} + +// UpdateObjectMetaIfNotLocked atomically checks lock and updates. +// Returns ErrObjectLocked if locked, or ErrVersionMismatch if version doesn't match. +// This eliminates the TOCTOU vulnerability in lock checking. +func UpdateObjectMetaIfNotLocked(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaMapType, lockKey string, expectedVersion int) error { + return WithTx(ctx, func(tx *TxWrap) error { + if oref.IsEmpty() { + return fmt.Errorf("empty object reference") + } + obj, _ := DBGetORef(tx.Context(), oref) + if obj == nil { + return ErrNotFound + } + + currentVersion := waveobj.GetVersion(obj) + if expectedVersion > 0 && currentVersion != expectedVersion { + return fmt.Errorf("%w: expected %d, got %d", ErrVersionMismatch, expectedVersion, currentVersion) + } + + // Atomic lock check INSIDE transaction + objMeta := waveobj.GetMeta(obj) + if objMeta != nil { + if locked, ok := objMeta[lockKey].(bool); ok && locked { + return fmt.Errorf("%w: %w", ErrVersionMismatch, ErrObjectLocked) + } + } + + if objMeta == nil { + objMeta = make(map[string]any) + } + newMeta := waveobj.MergeMeta(objMeta, meta, false) + waveobj.SetMeta(obj, newMeta) + if err := DBUpdate(tx.Context(), obj); err != nil { + return fmt.Errorf("failed to update object: %w", err) + } + return nil + }) +} + func MoveBlockToTab(ctx context.Context, currentTabId string, newTabId string, blockId string) error { return WithTx(ctx, func(tx *TxWrap) error { block, _ := DBGet[*waveobj.Block](tx.Context(), blockId) diff --git a/pkg/wstore/wstore_dbops.go b/pkg/wstore/wstore_dbops.go index 6b64b3e474..86fd6e8fa4 100644 --- a/pkg/wstore/wstore_dbops.go +++ b/pkg/wstore/wstore_dbops.go @@ -18,6 +18,8 @@ import ( ) var ErrNotFound = fmt.Errorf("not found") +var ErrVersionMismatch = fmt.Errorf("version mismatch: concurrent modification detected") +var ErrObjectLocked = fmt.Errorf("object is locked") func waveObjTableName(w waveobj.WaveObj) string { return "db_" + w.GetOType() diff --git a/schema/tabvarspresets.json b/schema/tabvarspresets.json new file mode 100644 index 0000000000..1dc0724ab2 --- /dev/null +++ b/schema/tabvarspresets.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "wave://schema/tabvarspresets.json", + "title": "Tab Variables Presets", + "description": "Schema for tab:basedir and related tab variable presets", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^tabvar@[a-zA-Z0-9_-]+$": { + "$ref": "#/$defs/TabVarPreset" + } + }, + "$defs": { + "TabVarPreset": { + "type": "object", + "additionalProperties": false, + "properties": { + "tab:basedir": { + "type": "string", + "maxLength": 4096, + "pattern": "^[^\\x00]+$", + "description": "Base directory for the tab context" + }, + "tab:basedirlock": { + "type": "boolean", + "description": "Lock basedir to prevent smart auto-detection" + }, + "display:name": { + "type": "string", + "maxLength": 256, + "description": "Display name shown in context menu" + }, + "display:order": { + "type": "number", + "description": "Order in context menu (lower = higher)" + } + } + } + } +}