diff --git a/CLAUDE.md b/CLAUDE.md index 2edfb88f8..9d897d32f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,12 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir - **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process. - **NEVER assume a timeout means the service needs restarting** — timeouts can be transient. - **NEVER add auth checks to tests** — agent providers handle their own auth. +- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules: + - Run only the specific test file you changed: `npx vitest run --bail=1` + - Never run `npm run test` for an entire workspace unless explicitly asked. + - If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run --bail=1 > /tmp/test-output.txt 2>&1` then read the file. + - Never re-run a test suite that another agent already ran and reported green — trust the result. + - For full suite verification, push to CI and check GitHub Actions instead. - **Always run typecheck after every change.** - **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it. - **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons: diff --git a/SECURITY.md b/SECURITY.md index 6ad5fdeb2..d582f9cfd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -55,7 +55,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding). -Paseo uses a host allowlist to validate the `Host` header on incoming requests. Requests with unrecognized hosts are rejected. +Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected. ## Agent authentication diff --git a/docs/AD-HOC-DAEMON-TESTING.md b/docs/AD-HOC-DAEMON-TESTING.md index 30c0d2e65..6918bb727 100644 --- a/docs/AD-HOC-DAEMON-TESTING.md +++ b/docs/AD-HOC-DAEMON-TESTING.md @@ -23,7 +23,7 @@ const daemon = await createPaseoDaemon( listen: "127.0.0.1:0", // OS picks a free port paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: false, staticDir, mcpDebug: false, diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 86fb7306a..f839afb51 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -130,7 +130,7 @@ Single file, validated with `PersistedConfigSchema`. version: 1, daemon: { listen: "127.0.0.1:6767", - allowedHosts: true | string[], + hostnames: true | string[], mcp: { enabled: boolean }, cors: { allowedOrigins: string[] }, relay: { enabled: boolean, endpoint: string, publicEndpoint: string } diff --git a/nix/module.nix b/nix/module.nix index 72a387614..022fc9eb8 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -9,6 +9,10 @@ let cfg = config.services.paseo; in { + imports = [ + (lib.mkRenamedOptionModule [ "services" "paseo" "allowedHosts" ] [ "services" "paseo" "hostnames" ]) + ]; + options.services.paseo = { enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents"; @@ -58,12 +62,12 @@ in description = "Whether to open the firewall for the Paseo daemon port."; }; - allowedHosts = lib.mkOption { + hostnames = lib.mkOption { type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str); default = [ ]; example = [ ".example.com" "myhost.local" ]; description = '' - Hosts allowed to connect to the Paseo daemon (DNS rebinding protection). + Hostnames the Paseo daemon accepts in the Host header (DNS rebinding protection). Localhost and IP addresses are always allowed by default. Use a leading dot to match a domain and all its subdomains @@ -141,10 +145,10 @@ in "/run/wrappers/bin" "/nix/var/nix/profiles/default/bin" ]); - } // lib.optionalAttrs (cfg.allowedHosts == true) { - PASEO_ALLOWED_HOSTS = "true"; - } // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) { - PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts; + } // lib.optionalAttrs (cfg.hostnames == true) { + PASEO_HOSTNAMES = "true"; + } // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) { + PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames; } // cfg.environment; serviceConfig = { diff --git a/nix/package.nix b/nix/package.nix index fdc7e37e4..dd0909a3f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-7AL13lVZq/+K8G7SFHVCtWjK5cBL7XN2kLTULONv/Xs="; + npmDepsHash = "sha256-NBB8+DNyodnREhVEiNMLADd+4MMK4lssRuzlwvvPjk8="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). diff --git a/package-lock.json b/package-lock.json index a8b72ec83..ebba06cc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.55-rc.2", + "version": "0.1.56", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.55-rc.2", + "version": "0.1.56", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34854,16 +34854,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.55-rc.2", + "version": "0.1.56", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.55-rc.2", - "@getpaseo/highlight": "0.1.55-rc.2", - "@getpaseo/server": "0.1.55-rc.2", + "@getpaseo/expo-two-way-audio": "0.1.56", + "@getpaseo/highlight": "0.1.56", + "@getpaseo/server": "0.1.56", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -35004,11 +35004,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.55-rc.2", + "version": "0.1.56", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.55-rc.2", - "@getpaseo/server": "0.1.55-rc.2", + "@getpaseo/relay": "0.1.56", + "@getpaseo/server": "0.1.56", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35049,11 +35049,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.55-rc.2", + "version": "0.1.56", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.55-rc.2", - "@getpaseo/server": "0.1.55-rc.2", + "@getpaseo/cli": "0.1.56", + "@getpaseo/server": "0.1.56", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35087,7 +35087,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.55-rc.2", + "version": "0.1.56", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35288,7 +35288,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.55-rc.2", + "version": "0.1.56", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35314,7 +35314,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.55-rc.2", + "version": "0.1.56", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35330,14 +35330,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.55-rc.2", + "version": "0.1.56", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.55-rc.2", - "@getpaseo/relay": "0.1.55-rc.2", + "@getpaseo/highlight": "0.1.56", + "@getpaseo/relay": "0.1.56", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35765,7 +35765,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.55-rc.2", + "version": "0.1.56", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index c84601039..20ec2b057 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.55-rc.2", + "version": "0.1.56", "private": true, "workspaces": [ "packages/expo-two-way-audio", @@ -14,6 +14,7 @@ ], "scripts": { "dev": "./scripts/dev.sh", + "dev:win": "powershell ./scripts/dev.ps1", "dev:server": "npm run dev --workspace=@getpaseo/server", "dev:app": "npm run start --workspace=@getpaseo/app", "dev:website": "npm run dev --workspace=@getpaseo/website", @@ -36,6 +37,7 @@ "ios": "npm run ios --workspace=@getpaseo/app", "web": "npm run web --workspace=@getpaseo/app", "dev:desktop": "npm run dev --workspace=@getpaseo/desktop", + "dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop", "build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop", "db:query": "npm run db:query --workspace=@getpaseo/server --", "cli": "npx tsx packages/cli/src/index.js", diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index 3ac61ee2e..9e319e851 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -1,4 +1,4 @@ -import { spawn, type ChildProcess, execSync } from "node:child_process"; +import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -224,6 +224,51 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload { return offer as OfferPayload; } +function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload { + const stdout = execFileSync( + process.execPath, + ["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"], + { + cwd: repoRoot, + env: { + ...process.env, + PASEO_HOME: paseoHomePath, + }, + encoding: "utf8", + }, + ); + const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null }; + if (payload.relayEnabled !== true || typeof payload.url !== "string") { + throw new Error(`Unexpected daemon pair response: ${stdout}`); + } + return decodeOfferFromFragmentUrl(payload.url); +} + +async function waitForPairingOfferFromCli(args: { + repoRoot: string; + paseoHome: string; + timeoutMs?: number; +}): Promise { + const timeoutMs = args.timeoutMs ?? 15000; + const start = Date.now(); + let lastError: unknown = null; + + while (Date.now() - start < timeoutMs) { + try { + return loadPairingOfferFromCli(args.repoRoot, args.paseoHome); + } catch (error) { + lastError = error; + await sleep(100); + } + } + + throw new Error( + `Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }`, + ); +} + export default async function globalSetup() { const repoRoot = path.resolve(__dirname, "../../.."); ensureRelayBuildArtifact(repoRoot); @@ -433,12 +478,6 @@ export default async function globalSetup() { const serverDir = path.resolve(__dirname, "../../..", "packages/server"); const tsxBin = execSync("which tsx").toString().trim(); - let offerPayload: OfferPayload | null = null; - let offerResolve: (() => void) | null = null; - const offerPromise = new Promise((resolve) => { - offerResolve = resolve; - }); - daemonProcess = spawn(tsxBin, ["src/server/index.ts"], { cwd: serverDir, env: { @@ -473,26 +512,6 @@ export default async function globalSetup() { const trimmed = line.trim(); if (!trimmed) continue; daemonLineBuffer.add(`[stdout] ${trimmed}`); - if (!offerPayload) { - const clean = stripAnsi(trimmed); - try { - const obj = JSON.parse(clean) as { msg?: string; url?: string }; - if (obj.msg === "pairing_offer" && typeof obj.url === "string") { - offerPayload = decodeOfferFromFragmentUrl(obj.url); - offerResolve?.(); - } - } catch { - const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/); - if (match && clean.includes("pairing_offer")) { - try { - offerPayload = decodeOfferFromFragmentUrl(match[0]); - offerResolve?.(); - } catch { - // ignore parsing failures - } - } - } - } console.log(`[daemon] ${trimmed}`); } }); @@ -523,17 +542,10 @@ export default async function globalSetup() { }), ]); - // Wait for daemon to emit a pairing offer (includes relay session ID). - await Promise.race([ - offerPromise, - new Promise((_, reject) => - setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000), - ), - ]); - if (!offerPayload) { - throw new Error("pairing_offer was not parsed from daemon logs"); - } - const offer = offerPayload as OfferPayload; + const offer = await waitForPairingOfferFromCli({ + repoRoot, + paseoHome, + }); process.env.E2E_DAEMON_PORT = String(port); process.env.E2E_RELAY_PORT = String(relayPort); diff --git a/packages/app/package.json b/packages/app/package.json index aeaae5b55..c840d37f4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.55-rc.2", + "version": "0.1.56", "private": true, "scripts": { "start": "expo start", @@ -31,9 +31,9 @@ "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.55-rc.2", - "@getpaseo/highlight": "0.1.55-rc.2", - "@getpaseo/server": "0.1.55-rc.2", + "@getpaseo/expo-two-way-audio": "0.1.56", + "@getpaseo/highlight": "0.1.56", + "@getpaseo/server": "0.1.56", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx index f9ce26bf9..aeef7b346 100644 --- a/packages/app/src/components/branch-switcher.tsx +++ b/packages/app/src/components/branch-switcher.tsx @@ -43,12 +43,17 @@ export function BranchSwitcher({ queryClient, }); - if (!currentBranchName) { - return ( + const titleContent = ( + <> + {title} - ); + + ); + + if (!currentBranchName) { + return {titleContent}; } return ( @@ -63,10 +68,7 @@ export function BranchSwitcher({ accessibilityRole="button" accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`} > - - - {title} - + {titleContent} {!isCompact ? : null} ({ xs: -theme.spacing[2], md: 0, }, - paddingVertical: theme.spacing[1], + paddingVertical: { + xs: 0, + md: theme.spacing[1], + }, paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.md, flexShrink: 1, diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index c2522bbe6..5b1664c1c 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -63,7 +63,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA interface ComposerProps { agentId: string; serverId: string; - isInputActive: boolean; + isPaneFocused: boolean; onSubmitMessage?: (payload: MessagePayload) => Promise; /** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */ hasExternalContent?: boolean; @@ -106,7 +106,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands"; export function Composer({ agentId, serverId, - isInputActive, + isPaneFocused, onSubmitMessage, hasExternalContent = false, allowEmptySubmit = false, @@ -435,7 +435,7 @@ export function Composer({ const handleKeyboardAction = useCallback( (action: KeyboardActionDefinition): boolean => { - if (!isInputActive) { + if (!isPaneFocused) { return false; } @@ -479,7 +479,7 @@ export function Composer({ return false; } }, - [isInputActive], + [isPaneFocused], ); useKeyboardActionHandler({ @@ -493,9 +493,9 @@ export function Composer({ "message-input.voice-toggle", "message-input.voice-mute-toggle", ], - enabled: isInputActive, + enabled: isPaneFocused, priority: isMessageInputFocused ? 200 : 100, - isActive: () => isInputActive, + isActive: () => isPaneFocused, handle: handleKeyboardAction, }); @@ -766,7 +766,7 @@ export function Composer({ autoFocus={autoFocus && isDesktopWebBreakpoint} autoFocusKey={`${serverId}:${agentId}`} disabled={isSubmitLoading} - isInputActive={isInputActive} + isPaneFocused={isPaneFocused} leftContent={leftContent} beforeVoiceContent={beforeVoiceContent} rightContent={rightContent} diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index d65a34cbe..e11419e57 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -264,6 +264,7 @@ export function FilePane({ return { file: payload.file ?? null, error: payload.error ?? null }; }, staleTime: 5_000, + refetchOnMount: true, }); return ( diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 15a0c03ab..b2e9fa8ca 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -42,6 +42,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { Shortcut } from "@/components/ui/shortcut"; import { useWebElementScrollbar } from "@/components/use-web-scrollbar"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { formatShortcut } from "@/utils/format-shortcut"; +import { getShortcutOs } from "@/utils/shortcut-platform"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { markScrollInvestigationEvent, @@ -81,8 +83,8 @@ export interface MessageInputProps { autoFocus?: boolean; autoFocusKey?: string; disabled?: boolean; - /** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */ - isInputActive?: boolean; + /** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */ + isPaneFocused?: boolean; /** Content to render on the left side of the button row (e.g., AgentStatusBar) */ leftContent?: React.ReactNode; /** Content to render on the right side before the voice button (e.g., context window meter) */ @@ -219,7 +221,7 @@ export const MessageInput = forwardRef(funct autoFocus = false, autoFocusKey, disabled = false, - isInputActive = true, + isPaneFocused = true, leftContent, beforeVoiceContent, rightContent, @@ -247,7 +249,9 @@ export const MessageInput = forwardRef(funct const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle"); const dictationToggleKeys = useShortcutKeys("dictation-toggle"); const queueKeys = useShortcutKeys("message-input-queue"); + const focusInputKeys = useShortcutKeys("focus-message-input"); const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); + const [isInputFocused, setIsInputFocused] = useState(false); const rootRef = useRef(null); const inputWrapperRef = useRef(null); const textInputRef = useRef unknown }) | null>( @@ -441,7 +445,7 @@ export const MessageInput = forwardRef(funct onError: handleDictationError, canStart: canStartDictation, canConfirm: canConfirmDictation, - autoStopWhenHidden: { isVisible: isInputActive }, + autoStopWhenHidden: { isVisible: isPaneFocused }, enableDuration: true, }); @@ -1025,10 +1029,12 @@ export const MessageInput = forwardRef(funct accessibilityLabel="Message agent..." onFocus={() => { isInputFocusedRef.current = true; + setIsInputFocused(true); onFocusChange?.(true); }} onBlur={() => { isInputFocusedRef.current = false; + setIsInputFocused(false); onFocusChange?.(false); }} style={[ @@ -1053,6 +1059,11 @@ export const MessageInput = forwardRef(funct autoFocus={isWeb && autoFocus} /> {inputScrollbar} + {isWeb && isPaneFocused && !isInputFocused && !value && focusInputKeys ? ( + + {formatShortcut(focusInputKeys[0], getShortcutOs())} to focus + + ) : null} {/* Button row */} @@ -1313,6 +1324,14 @@ const styles = StyleSheet.create(((theme: any) => ({ textInputScrollWrapper: { position: "relative", }, + focusHintText: { + position: "absolute", + top: 0, + right: 0, + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + opacity: 0.5, + }, textInput: { width: "100%", color: theme.colors.foreground, diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index b509ee4bb..b00e91ebe 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -279,7 +279,7 @@ export function WorkspaceSetupDialog() { {}); }, [agentId, attentionReason, client, isConnected, requiresAttention], ); diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index f07d8d687..9bf1ed285 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -9,10 +9,6 @@ import { import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; -import { - summarizeSidebarProjects, - summarizeWorkspaceCollection, -} from "@/utils/workspace-fetch-debug"; const EMPTY_ORDER: string[] = []; const EMPTY_PROJECTS: SidebarProjectEntry[] = []; @@ -302,14 +298,6 @@ export function useSidebarWorkspacesList(options?: { if (!serverId) { return; } - - console.log("[WorkspaceFetch][sidebar] model", { - serverId, - connectionStatus, - hasHydratedWorkspaces, - sessionWorkspaces: summarizeWorkspaceCollection(sessionWorkspaces?.values()), - projects: summarizeSidebarProjects(projects), - }); }, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]); useEffect(() => { @@ -355,21 +343,10 @@ export function useSidebarWorkspacesList(options?: { let cursor: string | null = null; try { while (true) { - console.log("[WorkspaceFetch][sidebar-refresh] request", { - serverId, - cursor, - existingWorkspaces: summarizeWorkspaceCollection(existingWorkspaces?.values()), - }); const payload = await client.fetchWorkspaces({ sort: [{ key: "activity_at", direction: "desc" }], page: cursor ? { limit: 200, cursor } : { limit: 200 }, }); - console.log("[WorkspaceFetch][sidebar-refresh] response", { - serverId, - cursor, - pageInfo: payload.pageInfo, - payload: summarizeWorkspaceCollection(payload.entries), - }); for (const entry of payload.entries) { const workspace = toWorkspaceDescriptor(entry); next.set( @@ -388,10 +365,6 @@ export function useSidebarWorkspacesList(options?: { const store = useSessionStore.getState(); store.setWorkspaces(serverId, next); store.setHasHydratedWorkspaces(serverId, true); - console.log("[WorkspaceFetch][sidebar-refresh] applied", { - serverId, - nextWorkspaces: summarizeWorkspaceCollection(next.values()), - }); } catch (error) { console.error("[WorkspaceFetch][sidebar-refresh] failed", { serverId, diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 92a8ef714..8aac268d6 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -788,6 +788,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, // --- Message input --- + { + id: "message-input-focus-cmd-l-mac", + action: "message-input.action", + combo: "Cmd+L", + when: { mac: true, commandCenter: false }, + payload: { type: "message-input", kind: "focus" }, + help: { + id: "focus-message-input", + section: "agent-input", + label: "Focus message input", + keys: ["mod", "L"], + }, + }, + { + id: "message-input-focus-ctrl-l-non-mac", + action: "message-input.action", + combo: "Ctrl+L", + when: { mac: false, commandCenter: false, terminal: false }, + payload: { type: "message-input", kind: "focus" }, + help: { + id: "focus-message-input", + section: "agent-input", + label: "Focus message input", + keys: ["mod", "L"], + }, + }, { id: "message-input-voice-toggle-cmd-shift-d-mac", action: "message-input.action", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 28e72240d..02045f1c0 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -965,7 +965,7 @@ function ChatAgentContent({ ", - 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")', + "--hostnames ", + 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', ) - .action(withOutput(runDaemonRestartCommand)); + .addOption(new Option("--allowed-hosts ").hideHelp()) + .action( + withOutput((...args) => { + const [options, command] = args.slice(-2) as [(typeof args)[number], Command]; + return runDaemonRestartCommand( + { + ...options, + hostnames: + typeof options.hostnames === "string" + ? options.hostnames + : typeof options.allowedHosts === "string" + ? options.allowedHosts + : undefined, + }, + command, + ); + }), + ); // Advanced agent commands (less common operations) program.addCommand(createAgentCommand()); diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts index 5cb4f5b80..d9ef61297 100644 --- a/packages/cli/src/commands/daemon/index.ts +++ b/packages/cli/src/commands/daemon/index.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { startCommand } from "./start.js"; import { runStatusCommand } from "./status.js"; import { runStopCommand } from "./stop.js"; @@ -36,10 +36,27 @@ export function createDaemonCommand(): Command { .option("--no-mcp", "Disable Agent MCP on restarted daemon") .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") .option( - "--allowed-hosts ", - 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")', + "--hostnames ", + 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', ) - .action(withOutput(runRestartCommand)); + .addOption(new Option("--allowed-hosts ").hideHelp()) + .action( + withOutput((...args) => { + const [options, command] = args.slice(-2) as [(typeof args)[number], Command]; + return runRestartCommand( + { + ...options, + hostnames: + typeof options.hostnames === "string" + ? options.hostnames + : typeof options.allowedHosts === "string" + ? options.allowedHosts + : undefined, + }, + command, + ); + }), + ); return daemon; } diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 7d00a3271..812a48ae4 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -13,7 +13,7 @@ export interface DaemonStartOptions { relay?: boolean; mcp?: boolean; injectMcp?: boolean; - allowedHosts?: string; + hostnames?: string; } export interface LocalDaemonPidInfo { @@ -113,8 +113,8 @@ function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv { } else if (options.port) { childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`; } - if (options.allowedHosts) { - childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts; + if (options.hostnames) { + childEnv.PASEO_HOSTNAMES = options.hostnames; } return childEnv; } @@ -322,6 +322,7 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD ...envWithHome(options.home), // Status should reflect local persisted config + pid file, not inherited daemon env overrides. PASEO_LISTEN: undefined, + PASEO_HOSTNAMES: undefined, PASEO_ALLOWED_HOSTS: undefined, }; const home = resolvePaseoHome(env); diff --git a/packages/cli/src/commands/daemon/restart.ts b/packages/cli/src/commands/daemon/restart.ts index 82df597f5..741057f6a 100644 --- a/packages/cli/src/commands/daemon/restart.ts +++ b/packages/cli/src/commands/daemon/restart.ts @@ -61,7 +61,7 @@ function toStartOptions(options: CommandOptions): DaemonStartOptions { relay: typeof options.relay === "boolean" ? options.relay : undefined, mcp: typeof options.mcp === "boolean" ? options.mcp : undefined, injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined, - allowedHosts: typeof options.allowedHosts === "string" ? options.allowedHosts : undefined, + hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined, }; if (startOptions.listen && startOptions.port) { diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index 1e5cd2264..7a3e5991e 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import chalk from "chalk"; import { startLocalDaemonForeground, @@ -9,6 +9,10 @@ import { getErrorMessage } from "../../utils/errors.js"; export type { DaemonStartOptions as StartOptions } from "./local-daemon.js"; +type RawStartCommandOptions = StartOptions & { + allowedHosts?: string; +}; + export function startCommand(): Command { return new Command("start") .description("Start the local Paseo daemon") @@ -20,11 +24,15 @@ export function startCommand(): Command { .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") .option( - "--allowed-hosts ", - 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")', + "--hostnames ", + 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', ) - .action(async (options: StartOptions) => { - await runStart(options); + .addOption(new Option("--allowed-hosts ").hideHelp()) + .action(async (options: RawStartCommandOptions) => { + await runStart({ + ...options, + hostnames: options.hostnames ?? options.allowedHosts, + }); }); } diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index a9d4168d3..480e857a7 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -1,5 +1,5 @@ import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts"; -import { Command } from "commander"; +import { Command, Option } from "commander"; import { writeFileSync } from "node:fs"; import path from "node:path"; import { @@ -24,6 +24,10 @@ interface OnboardOptions extends DaemonStartOptions { voice?: "ask" | "enable" | "disable"; } +type RawOnboardOptions = OnboardOptions & { + allowedHosts?: string; +}; + type OnboardPersistedConfig = PersistedConfig & { features?: PersistedConfig["features"] & { dictation?: PersistedConfig["features"] extends { dictation?: infer T } @@ -64,7 +68,7 @@ function parseTimeoutMs(raw: string | undefined): number { return Math.ceil(seconds * 1000); } -function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides { +function toCliOverrides(options: OnboardOptions): CliConfigOverrides { const cliOverrides: CliConfigOverrides = {}; if (options.listen) { @@ -77,9 +81,9 @@ function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides { cliOverrides.relayEnabled = false; } - if (options.allowedHosts) { - const raw = options.allowedHosts.trim(); - cliOverrides.allowedHosts = + if (options.hostnames) { + const raw = options.hostnames.trim(); + cliOverrides.hostnames = raw.toLowerCase() === "true" ? true : raw @@ -297,13 +301,17 @@ export function onboardCommand(): Command { .option("--no-relay", "Disable relay connection") .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") .option( - "--allowed-hosts ", - 'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")', + "--hostnames ", + 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', ) + .addOption(new Option("--allowed-hosts ").hideHelp()) .option("--timeout ", "Max time to wait for daemon readiness (default: 600)") .option("--voice ", "Voice setup mode: ask, enable, disable", "ask") - .action(async (options: OnboardOptions) => { - await runOnboard(options); + .action(async (options: RawOnboardOptions) => { + await runOnboard({ + ...options, + hostnames: options.hostnames ?? options.allowedHosts, + }); }); } diff --git a/packages/cli/tests/21-run-output-schema-helper.test.ts b/packages/cli/tests/21-run-output-schema-helper.test.ts index 6db80fd66..398b16175 100644 --- a/packages/cli/tests/21-run-output-schema-helper.test.ts +++ b/packages/cli/tests/21-run-output-schema-helper.test.ts @@ -2,10 +2,10 @@ import assert from "node:assert"; import { - resolveProviderAndModel, resolveStructuredResponseMessage, type StructuredResponseTimelineClient, } from "../src/commands/agent/run.ts"; +import { resolveProviderAndModel } from "../src/utils/provider-model.ts"; type TimelineEntry = { item: { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 9b18e8e5b..d251e2a9f 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.55-rc.2", + "version": "0.1.56", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -8,12 +8,13 @@ "build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml", "build:main": "tsc -p tsconfig.json", "dev": "./scripts/dev.sh", + "dev:win": "powershell ./scripts/dev.ps1", "test": "vitest run", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.55-rc.2", - "@getpaseo/server": "0.1.55-rc.2", + "@getpaseo/cli": "0.1.56", + "@getpaseo/server": "0.1.56", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" diff --git a/packages/desktop/scripts/dev.ps1 b/packages/desktop/scripts/dev.ps1 new file mode 100644 index 000000000..609704eac --- /dev/null +++ b/packages/desktop/scripts/dev.ps1 @@ -0,0 +1,36 @@ +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$DesktopDir = (Resolve-Path "$ScriptDir\..").Path +$AppDir = (Resolve-Path "$DesktopDir\..\app").Path +$RootDir = (Resolve-Path "$DesktopDir\..\..").Path + +# Build the Electron main process +npm run build:main + +# Get a random available port for Metro +$env:EXPO_PORT = (npx get-port-cli).Trim() + +# Set EXPO_DEV_URL in the environment so Electron inherits it +$env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)" + +# Allow any origin in dev so Electron on random ports works. +# SECURITY: wildcard CORS is unsafe in production — only acceptable here because +# the daemon binds to localhost and this script is never used for production. +$env:PASEO_CORS_ORIGINS = "*" + +Write-Host @" +====================================================== + Paseo Desktop Dev (Windows) +====================================================== + Metro: http://localhost:$($env:EXPO_PORT) +====================================================== +"@ + +# Launch Metro + Electron together, kill both on exit +& "$RootDir\node_modules\.bin\concurrently" ` + --kill-others ` + --names "metro,electron" ` + --prefix-colors "magenta,cyan" ` + "cd `"$AppDir`" && npx expo start --port $($env:EXPO_PORT)" ` + "npx wait-on tcp:$($env:EXPO_PORT) && npx electron `"$DesktopDir`"" diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 7e948d764..454748fa5 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.55-rc.2", + "version": "0.1.56", "description": "Native module for two way audio streaming", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 14a114124..5803e4b3f 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.55-rc.2", + "version": "0.1.56", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index b31fa4141..d33a39760 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.55-rc.2", + "version": "0.1.56", "description": "Paseo relay for bridging daemon and client connections", "type": "module", "publishConfig": { diff --git a/packages/server/package.json b/packages/server/package.json index 99d7f9555..4c3ed9db9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.55-rc.2", + "version": "0.1.56", "description": "Paseo backend server", "type": "module", "publishConfig": { @@ -60,8 +60,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.55-rc.2", - "@getpaseo/relay": "0.1.55-rc.2", + "@getpaseo/highlight": "0.1.56", + "@getpaseo/relay": "0.1.56", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 3ebe2ed9a..6a3f00f04 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -1201,8 +1201,28 @@ export class DaemonClient { } } - clearAgentAttention(agentId: string | string[]): void { - this.sendSessionMessage({ type: "clear_agent_attention", agentId }); + async clearAgentAttention(agentId: string | string[]): Promise { + const requestId = this.createRequestId(); + const message = SessionInboundMessageSchema.parse({ + type: "clear_agent_attention", + agentId, + requestId, + }); + await this.sendRequest({ + requestId, + message, + timeout: 15000, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "clear_agent_attention_response") { + return null; + } + if (msg.payload.requestId !== requestId) { + return null; + } + return msg.payload; + }, + }); } sendHeartbeat(params: { @@ -1745,7 +1765,27 @@ export class DaemonClient { } async cancelAgent(agentId: string): Promise { - this.sendSessionMessage({ type: "cancel_agent_request", agentId }); + const requestId = this.createRequestId(); + const message = SessionInboundMessageSchema.parse({ + type: "cancel_agent_request", + agentId, + requestId, + }); + await this.sendRequest({ + requestId, + message, + timeout: 15000, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "cancel_agent_response") { + return null; + } + if (msg.payload.requestId !== requestId) { + return null; + } + return msg.payload; + }, + }); } async setAgentMode(agentId: string, modeId: string): Promise { diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index ad95ce1b6..16ff84152 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -119,7 +119,7 @@ describe("agent MCP end-to-end (offline)", () => { listen: `127.0.0.1:${port}`, paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: true, staticDir, mcpDebug: false, @@ -192,7 +192,7 @@ describe("agent MCP end-to-end (offline)", () => { listen: `127.0.0.1:${port}`, paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: true, staticDir, mcpDebug: false, @@ -216,7 +216,7 @@ describe("agent MCP end-to-end (offline)", () => { listen: `127.0.0.1:${disabledPort}`, paseoHome: disabledPaseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: true, mcpInjectIntoAgents: false, staticDir: disabledStaticDir, @@ -307,7 +307,7 @@ describe("agent MCP end-to-end (offline)", () => { listen: `127.0.0.1:${port}`, paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: true, staticDir, mcpDebug: false, diff --git a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts index da63dae7f..8b5c3aa71 100644 --- a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts +++ b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts @@ -42,7 +42,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise(); const createAgentMcpTransport = async (callerAgentId?: string) => { @@ -62,7 +62,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise { @@ -129,7 +129,7 @@ async function startAgentMcpServer(logger: pino.Logger): Promise ({ + createOpencodeClient: vi.fn(), +})); + +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js"; + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("OpenCodeAgentSession slash command timeout handling", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("waits for SSE completion when slash commands hit a header timeout", async () => { + const idleEventGate = createDeferred(); + + vi.mocked(createOpencodeClient).mockReturnValue({ + session: { + create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }), + command: vi.fn().mockRejectedValue(new Error("fetch failed: Headers Timeout Error")), + }, + provider: { + list: vi.fn().mockResolvedValue({ + data: { + connected: ["openai"], + all: [{ id: "openai", name: "OpenAI", models: {} }], + }, + }), + }, + event: { + subscribe: vi.fn().mockResolvedValue({ + stream: (async function* () { + await idleEventGate.promise; + yield { + type: "session.idle", + properties: { sessionID: "session-1" }, + }; + })(), + }), + }, + command: { + list: vi.fn().mockResolvedValue({ + data: [{ name: "help", description: "Show help", hints: [] }], + }), + }, + app: { + agents: vi.fn().mockResolvedValue({ data: [] }), + }, + } as never); + + vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ + ensureRunning: vi.fn().mockResolvedValue({ port: 1234, url: "http://127.0.0.1:1234" }), + } as never); + + const client = new OpenCodeAgentClient(createTestLogger()); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp" }); + + const runPromise = session.run("/help"); + await Promise.resolve(); + idleEventGate.resolve(); + + await expect(runPromise).resolves.toMatchObject({ + sessionId: "session-1", + finalText: "", + timeline: [], + usage: undefined, + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 4e6a416a8..65fe87ec4 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -316,6 +316,65 @@ const hasOpenCode = isBinaryInstalled("opencode"); }); describe("OpenCode adapter context-window normalization", () => { + test("close reconciliation aborts then archives upstream session", async () => { + const abort = vi.fn().mockResolvedValue({ data: true, error: undefined }); + const update = vi.fn().mockResolvedValue({ + data: { id: "session-1", time: { archived: Date.now() } }, + error: undefined, + }); + + await __openCodeInternals.reconcileOpenCodeSessionClose({ + client: { + session: { + abort, + update, + }, + } as never, + sessionId: "session-1", + directory: "/tmp/project", + logger: createTestLogger(), + }); + + expect(abort).toHaveBeenCalledWith({ + sessionID: "session-1", + directory: "/tmp/project", + }); + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith({ + sessionID: "session-1", + directory: "/tmp/project", + time: { + archived: expect.any(Number), + }, + }); + }); + + test("close reconciliation still archives when abort returns an error", async () => { + const abort = vi.fn().mockResolvedValue({ + data: undefined, + error: { data: {}, errors: [], success: false }, + }); + const update = vi.fn().mockResolvedValue({ + data: { id: "session-1", time: { archived: Date.now() } }, + error: undefined, + }); + + await __openCodeInternals.reconcileOpenCodeSessionClose({ + client: { + session: { + abort, + update, + }, + } as never, + sessionId: "session-1", + directory: "/tmp/project", + logger: createTestLogger(), + }); + + expect(abort).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + }); + test("builds OpenCode file parts for image prompt blocks", () => { expect( __openCodeInternals.buildOpenCodePromptParts([ diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 3299794a4..369e11a76 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -108,6 +108,12 @@ const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [ "does not exist", "unsupported model", ] as const; +const OPENCODE_HEADERS_TIMEOUT_TOKENS = [ + "headers timeout", + "headers timeout error", + "headers_timeout", + "und_err_headers_timeout", +] as const; const OpencodeToolStateSchema = z .object({ @@ -232,6 +238,73 @@ function normalizeTurnFailureError(error: unknown): string { return normalized.length > 0 ? normalized : "Unknown error"; } +function isOpenCodeNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "name" in error && + (error as { name?: unknown }).name === "NotFoundError" + ); +} + +async function reconcileOpenCodeSessionClose(params: { + client: Pick; + sessionId: string; + directory: string; + logger: Logger; +}): Promise { + const { client, sessionId, directory, logger } = params; + + try { + const response = await client.session.abort({ + sessionID: sessionId, + directory, + }); + if (response.error && !isOpenCodeNotFoundError(response.error)) { + logger.warn( + { + sessionId, + error: normalizeTurnFailureError(response.error), + }, + "Failed to abort OpenCode session during close", + ); + } + } catch (error) { + logger.warn( + { + sessionId, + error: normalizeTurnFailureError(error), + }, + "Failed to abort OpenCode session during close", + ); + } + + try { + const response = await client.session.update({ + sessionID: sessionId, + directory, + time: { archived: Date.now() }, + }); + if (response.error && !isOpenCodeNotFoundError(response.error)) { + logger.warn( + { + sessionId, + error: normalizeTurnFailureError(response.error), + }, + "Failed to archive OpenCode session during close", + ); + } + } catch (error) { + logger.warn( + { + sessionId, + error: normalizeTurnFailureError(error), + }, + "Failed to archive OpenCode session during close", + ); + } +} + function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolean { const normalized = typeof message === "string" ? message.trim().toLowerCase() : ""; if (!normalized) { @@ -240,6 +313,49 @@ function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolea return OPENCODE_FATAL_RETRY_MESSAGE_TOKENS.some((token) => normalized.includes(token)); } +function isOpenCodeHeadersTimeoutFailure(error: unknown): boolean { + const diagnostics = new Set(); + const queue: unknown[] = [error]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) { + continue; + } + + const normalized = stringifyUnknownError(current).trim().toLowerCase(); + if (normalized) { + diagnostics.add(normalized); + } + + if (typeof current === "object") { + const record = current as { + message?: unknown; + code?: unknown; + name?: unknown; + cause?: unknown; + }; + + for (const value of [record.message, record.code, record.name]) { + if (typeof value === "string") { + const diagnostic = value.trim().toLowerCase(); + if (diagnostic) { + diagnostics.add(diagnostic); + } + } + } + + if (record.cause) { + queue.push(record.cause); + } + } + } + + return [...diagnostics].some((diagnostic) => + OPENCODE_HEADERS_TIMEOUT_TOKENS.some((token) => diagnostic.includes(token)), + ); +} + function isAlreadyPresentMcpError(error: unknown): boolean { const normalized = stringifyUnknownError(error).toLowerCase(); return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token)); @@ -575,6 +691,7 @@ export const __openCodeInternals = { hasNormalizedOpenCodeUsage, mergeOpenCodeStepFinishUsage, parseOpenCodeModelLookupKey, + reconcileOpenCodeSessionClose, resolveOpenCodeModelLookupKeyFromAssistantMessage, resolveOpenCodeSelectedModelContextWindow, }; @@ -1535,6 +1652,17 @@ class OpenCodeAgentSession implements AgentSession { }) .then((response) => { if (response.error) { + if (isOpenCodeHeadersTimeoutFailure(response.error)) { + this.logger.warn( + { + err: response.error, + commandName: slashCommand.commandName, + turnId, + }, + "OpenCode slash command hit a header timeout; waiting for SSE terminal event", + ); + return; + } const errorMsg = normalizeTurnFailureError(response.error); this.finishForegroundTurn( { type: "turn_failed", provider: "opencode", error: errorMsg }, @@ -1548,6 +1676,17 @@ class OpenCodeAgentSession implements AgentSession { } }) .catch((err) => { + if (isOpenCodeHeadersTimeoutFailure(err)) { + this.logger.warn( + { + err, + commandName: slashCommand.commandName, + turnId, + }, + "OpenCode slash command hit a header timeout; waiting for SSE terminal event", + ); + return; + } this.finishForegroundTurn( { type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) }, turnId, @@ -1937,6 +2076,12 @@ class OpenCodeAgentSession implements AgentSession { async close(): Promise { this.abortController?.abort(); + await reconcileOpenCodeSessionClose({ + client: this.client, + sessionId: this.sessionId, + directory: this.config.cwd, + logger: this.logger, + }); this.subscribers.clear(); this.activeForegroundTurnId = null; } diff --git a/packages/server/src/server/allowed-hosts.test.ts b/packages/server/src/server/allowed-hosts.test.ts deleted file mode 100644 index 1e0659bde..000000000 --- a/packages/server/src/server/allowed-hosts.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { isHostAllowed, mergeAllowedHosts, parseAllowedHostsEnv } from "./allowed-hosts.js"; - -describe("allowed hosts (vite-style)", () => { - it("allows localhost by default", () => { - expect(isHostAllowed("localhost:6767", undefined)).toBe(true); - }); - - it("allows subdomains of .localhost by default", () => { - expect(isHostAllowed("foo.localhost:6767", undefined)).toBe(true); - }); - - it("allows IP addresses by default", () => { - expect(isHostAllowed("127.0.0.1:6767", undefined)).toBe(true); - expect(isHostAllowed("[::1]:6767", undefined)).toBe(true); - }); - - it("rejects non-default hosts when no allowlist is provided", () => { - expect(isHostAllowed("evil.com:6767", undefined)).toBe(false); - }); - - it("allows any host when set to true", () => { - expect(isHostAllowed("evil.com:6767", true)).toBe(true); - }); - - it("supports leading-dot patterns", () => { - const allowed = [".example.com"]; - expect(isHostAllowed("example.com:6767", allowed)).toBe(true); - expect(isHostAllowed("foo.example.com:6767", allowed)).toBe(true); - expect(isHostAllowed("foo.bar.example.com:6767", allowed)).toBe(true); - expect(isHostAllowed("notexample.com:6767", allowed)).toBe(false); - }); - - it("merges arrays (append + de-dupe) and short-circuits on true", () => { - expect(mergeAllowedHosts([["a"], ["a", "b"]])).toEqual(["a", "b"]); - expect(mergeAllowedHosts([["a"], true, ["b"]])).toBe(true); - }); - - it("parses env var values", () => { - expect(parseAllowedHostsEnv(undefined)).toBeUndefined(); - expect(parseAllowedHostsEnv("")).toBeUndefined(); - expect(parseAllowedHostsEnv("true")).toBe(true); - expect(parseAllowedHostsEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]); - }); -}); diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index ee2b4e520..9d1096aea 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -1,11 +1,11 @@ import os from "node:os"; import path from "node:path"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; -import { Writable } from "node:stream"; import pino from "pino"; import { afterEach, describe, expect, test, vi } from "vitest"; import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js"; +import { generateLocalPairingOffer } from "./pairing-offer.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; @@ -50,7 +50,7 @@ describe("paseo daemon bootstrap", () => { listen: "127.0.0.1:0", paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: false, staticDir, mcpDebug: false, @@ -150,30 +150,20 @@ describe("paseo daemon bootstrap", () => { }); }); - test("emits a relay pairing offer for unix socket listeners", async () => { + test("generates a relay pairing offer for unix socket listeners", async () => { const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-socket-relay-")); const paseoHome = path.join(paseoHomeRoot, ".paseo"); const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); const socketPath = path.join(paseoHomeRoot, "run", "paseo.sock"); await mkdir(path.dirname(socketPath), { recursive: true }); await mkdir(paseoHome, { recursive: true }); - - const lines: string[] = []; - const logger = pino( - { level: "info" }, - new Writable({ - write(chunk, _encoding, callback) { - lines.push(chunk.toString("utf8")); - callback(); - }, - }), - ); + const logger = pino({ level: "silent" }); const config: PaseoDaemonConfig = { listen: socketPath, paseoHome, corsAllowedOrigins: [], - allowedHosts: true, + hostnames: true, mcpEnabled: false, staticDir, mcpDebug: false, @@ -191,7 +181,16 @@ describe("paseo daemon bootstrap", () => { try { await daemon.start(); - expect(lines.some((line) => line.includes('"msg":"pairing_offer"'))).toBe(true); + const pairing = await generateLocalPairingOffer({ + paseoHome, + relayEnabled: true, + relayEndpoint: "127.0.0.1:9", + relayPublicEndpoint: "127.0.0.1:9", + appBaseUrl: "https://app.paseo.sh", + includeQr: false, + }); + expect(pairing.relayEnabled).toBe(true); + expect(pairing.url?.startsWith("https://app.paseo.sh/#offer=")).toBe(true); } finally { await daemon.stop().catch(() => undefined); await daemon.agentManager.flush().catch(() => undefined); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 67a22ddae..970cf1cb8 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -108,6 +108,7 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { LoopService } from "./loop-service.js"; import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore } from "./daemon-config-store.js"; +import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; @@ -119,7 +120,6 @@ import type { AgentProviderRuntimeSettingsMap, ProviderOverride, } from "./agent/provider-launch-config.js"; -import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js"; import { ScriptRouteStore, createScriptProxyMiddleware, @@ -128,6 +128,7 @@ import { import { ScriptHealthMonitor } from "./script-health-monitor.js"; import { createScriptStatusEmitter } from "./script-status-projection.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; +import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js"; type AgentMcpTransportMap = Map; @@ -170,7 +171,8 @@ export type PaseoDaemonConfig = { listen: string; paseoHome: string; corsAllowedOrigins: string[]; - allowedHosts?: AllowedHostsConfig; + allowedHosts?: HostnamesConfig; + hostnames?: HostnamesConfig; mcpEnabled?: boolean; mcpInjectIntoAgents?: boolean; staticDir: string; @@ -238,6 +240,7 @@ export async function createPaseoDaemon( const scriptRouteStore = new ScriptRouteStore(); const scriptRuntimeStore = new WorkspaceScriptRuntimeStore(); + const configuredHostnames = config.hostnames ?? config.allowedHosts; let wsServer: VoiceAssistantWebSocketServer | null = null; const scriptHealthMonitor = new ScriptHealthMonitor({ routeStore: scriptRouteStore, @@ -264,7 +267,7 @@ export async function createPaseoDaemon( if (listenTarget.type === "tcp") { app.use((req, res, next) => { const hostHeader = typeof req.headers.host === "string" ? req.headers.host : undefined; - if (!isHostAllowed(hostHeader, config.allowedHosts)) { + if (!isHostnameAllowed(hostHeader, configuredHostnames)) { res.status(403).json({ error: "Invalid Host header" }); return; } @@ -418,6 +421,10 @@ export async function createPaseoDaemon( }); const terminalManager = createTerminalManager(); + const workspaceGitService = new WorkspaceGitServiceImpl({ + logger, + paseoHome: config.paseoHome, + }); const detachAgentStoragePersistence = attachAgentStoragePersistence( logger, @@ -431,6 +438,7 @@ export async function createPaseoDaemon( agentStorage, projectRegistry, workspaceRegistry, + workspaceGitService, logger, }); logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped"); @@ -439,6 +447,7 @@ export async function createPaseoDaemon( const checkoutDiffManager = new CheckoutDiffManager({ logger, paseoHome: config.paseoHome, + workspaceGitService, }); const loopService = new LoopService({ paseoHome: config.paseoHome, @@ -647,7 +656,7 @@ export async function createPaseoDaemon( config.paseoHome, daemonConfigStore, mcpBaseUrl, - { allowedOrigins, allowedHosts: config.allowedHosts }, + { allowedOrigins, hostnames: configuredHostnames }, speechService, terminalManager, { @@ -675,6 +684,7 @@ export async function createPaseoDaemon( () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null), (hostname) => scriptHealthMonitor.getHealthForHostname(hostname), + workspaceGitService, ); if (typeof process.send === "function" && process.env.PASEO_SUPERVISED === "1") { @@ -694,8 +704,7 @@ export async function createPaseoDaemon( relay: { endpoint: relayPublicEndpoint }, }); - const url = encodeOfferToFragmentUrl({ offer, appBaseUrl }); - logger.info({ url }, "pairing_offer"); + encodeOfferToFragmentUrl({ offer, appBaseUrl }); relayTransport?.stop().catch(() => undefined); relayTransport = startRelayTransport({ diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index 356f8cf15..35d573e7b 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -1,137 +1,184 @@ -import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -const { getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } = vi.hoisted( - () => { - const hoistedWatchCalls: Array<{ path: string; close: ReturnType }> = []; - return { - getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })), - resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"), - readdirMock: vi.fn(async (directory: string) => { - if (directory === "/tmp/repo") { - return [ - { name: "packages", isDirectory: () => true }, - { name: ".git", isDirectory: () => true }, - { name: "README.md", isDirectory: () => false }, - ]; - } - if (directory === path.join("/tmp/repo", "packages")) { - return [ - { name: "server", isDirectory: () => true }, - { name: "app", isDirectory: () => true }, - ]; - } - if (directory === path.join("/tmp/repo", "packages", "server")) { - return [{ name: "src", isDirectory: () => true }]; - } - if (directory === path.join("/tmp/repo", "packages", "server", "src")) { - return [{ name: "server", isDirectory: () => true }]; - } - return []; - }), - watchCalls: hoistedWatchCalls, - }; - }, -); - -vi.mock("../utils/run-git-command.js", () => ({ - runGitCommand: vi.fn(async () => ({ - stdout: "/tmp/repo\n", - stderr: "", - truncated: false, - exitCode: 0, - signal: null, +const { getCheckoutDiffMock, toCheckoutErrorMock } = vi.hoisted(() => ({ + getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })), + toCheckoutErrorMock: vi.fn((error: unknown) => ({ + message: error instanceof Error ? error.message : String(error), })), })); -vi.mock("node:fs/promises", async () => { - const actual = await vi.importActual("node:fs/promises"); - return { - ...actual, - readdir: readdirMock, - }; -}); - -vi.mock("node:fs", async () => { - const actual = await vi.importActual("node:fs"); - return { - ...actual, - watch: vi.fn((watchPath: string) => { - const close = vi.fn(); - watchCalls.push({ path: watchPath, close }); - return { - close, - on: vi.fn().mockReturnThis(), - } as any; - }), - }; -}); - vi.mock("../utils/checkout-git.js", () => ({ getCheckoutDiff: getCheckoutDiffMock, })); vi.mock("./checkout-git-utils.js", () => ({ - READ_ONLY_GIT_ENV: {}, - resolveCheckoutGitDir: resolveCheckoutGitDirMock, - toCheckoutError: vi.fn((error: unknown) => ({ - message: error instanceof Error ? error.message : String(error), - })), + toCheckoutError: toCheckoutErrorMock, })); import { CheckoutDiffManager } from "./checkout-diff-manager.js"; -describe("CheckoutDiffManager Linux watchers", () => { - const originalPlatform = process.platform; - +describe("CheckoutDiffManager", () => { beforeEach(() => { - watchCalls.length = 0; - getCheckoutDiffMock.mockClear(); - resolveCheckoutGitDirMock.mockClear(); - readdirMock.mockClear(); - Object.defineProperty(process, "platform", { - configurable: true, - value: "linux", - }); + vi.useFakeTimers(); + getCheckoutDiffMock.mockReset(); + getCheckoutDiffMock.mockResolvedValue({ diff: "", structured: [] }); + toCheckoutErrorMock.mockClear(); }); afterEach(() => { - Object.defineProperty(process, "platform", { - configurable: true, - value: originalPlatform, - }); + vi.useRealTimers(); }); - test("watches nested repository directories on Linux", async () => { + function createManager(options?: { + repoRoot?: string | null; + getCheckoutDiffImplementation?: typeof getCheckoutDiffMock; + }) { + const unsubscribe = vi.fn(); + let onChange: (() => void) | null = null; + const mockRequestWorkingTreeWatch = vi.fn(async (_cwd: string, listener: () => void) => { + onChange = listener; + return { + repoRoot: options?.repoRoot === undefined ? "/tmp/repo" : options.repoRoot, + unsubscribe, + }; + }); + + const workspaceGitService = { + subscribe: vi.fn(), + peekSnapshot: vi.fn(), + getSnapshot: vi.fn(), + refresh: vi.fn(), + scheduleRefreshForCwd: vi.fn(), + requestWorkingTreeWatch: mockRequestWorkingTreeWatch, + dispose: vi.fn(), + }; + + if (options?.getCheckoutDiffImplementation) { + getCheckoutDiffMock.mockImplementation(options.getCheckoutDiffImplementation); + } + const logger = { child: () => logger, warn: vi.fn(), }; + const manager = new CheckoutDiffManager({ logger: logger as any, paseoHome: "/tmp/paseo-test", + workspaceGitService: workspaceGitService as any, }); - const subscription = await manager.subscribe( + return { + manager, + workspaceGitService, + mockRequestWorkingTreeWatch, + unsubscribe, + getOnChange: () => onChange, + }; + } + + test("subscribe requests a working tree watch with the correct cwd", async () => { + const { manager, mockRequestWorkingTreeWatch } = createManager(); + + await manager.subscribe( { - cwd: path.join("/tmp/repo", "packages", "server"), + cwd: "/tmp/repo/packages/server", compare: { mode: "uncommitted" }, }, () => {}, ); - expect(subscription.initial.error).toBeNull(); - expect(watchCalls.map((entry) => entry.path).sort()).toEqual([ - "/tmp/repo", - "/tmp/repo/.git", - "/tmp/repo/packages", - "/tmp/repo/packages/app", + expect(mockRequestWorkingTreeWatch).toHaveBeenCalledWith( "/tmp/repo/packages/server", - "/tmp/repo/packages/server/src", - "/tmp/repo/packages/server/src/server", - ]); + expect.any(Function), + ); + }); + + test("unsubscribe calls the working tree watch unsubscribe", async () => { + const { manager, unsubscribe } = createManager(); + + const subscription = await manager.subscribe( + { + cwd: "/tmp/repo/packages/server", + compare: { mode: "uncommitted" }, + }, + () => {}, + ); subscription.unsubscribe(); - manager.dispose(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + test("diffCwd uses repoRoot from the working tree watch result", async () => { + const { manager } = createManager({ repoRoot: "/tmp/repo" }); + + await manager.subscribe( + { + cwd: "/tmp/repo/packages/server", + compare: { mode: "uncommitted" }, + }, + () => {}, + ); + + expect(getCheckoutDiffMock).toHaveBeenCalledWith( + "/tmp/repo", + expect.objectContaining({ mode: "uncommitted", includeStructured: true }), + { paseoHome: "/tmp/paseo-test" }, + ); + }); + + test("diff refresh is triggered when the working tree watch callback fires", async () => { + getCheckoutDiffMock + .mockResolvedValueOnce({ + diff: "", + structured: [{ path: "a.ts", additions: 1, deletions: 0, status: "modified" }], + }) + .mockResolvedValueOnce({ + diff: "", + structured: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }], + }); + + const { manager, getOnChange } = createManager(); + const listener = vi.fn(); + + await manager.subscribe( + { + cwd: "/tmp/repo/packages/server", + compare: { mode: "uncommitted" }, + }, + listener, + ); + + const onChange = getOnChange(); + expect(onChange).toBeTypeOf("function"); + + onChange?.(); + await vi.advanceTimersByTimeAsync(150); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ + cwd: "/tmp/repo/packages/server", + files: [{ path: "b.ts", additions: 2, deletions: 0, status: "modified" }], + error: null, + }); + }); + + test("falls back to cwd when the working tree watch returns no repo root", async () => { + const { manager } = createManager({ repoRoot: null }); + + await manager.subscribe( + { + cwd: "/tmp/plain", + compare: { mode: "uncommitted" }, + }, + () => {}, + ); + + expect(getCheckoutDiffMock).toHaveBeenCalledWith( + "/tmp/plain", + expect.objectContaining({ mode: "uncommitted", includeStructured: true }), + { paseoHome: "/tmp/paseo-test" }, + ); }); }); diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 0274fff22..8e2029d96 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -1,15 +1,11 @@ -import { watch, type FSWatcher } from "node:fs"; -import { readdir } from "node:fs/promises"; -import { join } from "node:path"; import type pino from "pino"; import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; import { getCheckoutDiff } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; -import { runGitCommand } from "../utils/run-git-command.js"; -import { READ_ONLY_GIT_ENV, resolveCheckoutGitDir, toCheckoutError } from "./checkout-git-utils.js"; +import { toCheckoutError } from "./checkout-git-utils.js"; const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150; -const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000; export type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest["compare"]; @@ -31,27 +27,26 @@ type CheckoutDiffWatchTarget = { diffCwd: string; compare: CheckoutDiffCompareInput; listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>; - watchers: FSWatcher[]; - fallbackRefreshInterval: NodeJS.Timeout | null; + workingTreeWatchUnsubscribe: (() => void) | null; debounceTimer: NodeJS.Timeout | null; refreshPromise: Promise | null; refreshQueued: boolean; latestPayload: CheckoutDiffSnapshotPayload | null; latestFingerprint: string | null; - watchedPaths: Set; - repoWatchPath: string | null; - linuxTreeRefreshPromise: Promise | null; - linuxTreeRefreshQueued: boolean; }; export class CheckoutDiffManager { - private readonly logger: pino.Logger; private readonly paseoHome: string; + private readonly workspaceGitService: WorkspaceGitService; private readonly targets = new Map(); - constructor(options: { logger: pino.Logger; paseoHome: string }) { - this.logger = options.logger.child({ module: "checkout-diff-manager" }); + constructor(options: { + logger: pino.Logger; + paseoHome: string; + workspaceGitService: WorkspaceGitService; + }) { this.paseoHome = options.paseoHome; + this.workspaceGitService = options.workspaceGitService; } async subscribe( @@ -93,22 +88,16 @@ export class CheckoutDiffManager { getMetrics(): CheckoutDiffMetrics { let checkoutDiffSubscriptionCount = 0; - let checkoutDiffWatcherCount = 0; - let checkoutDiffFallbackRefreshTargetCount = 0; for (const target of this.targets.values()) { checkoutDiffSubscriptionCount += target.listeners.size; - checkoutDiffWatcherCount += target.watchers.length; - if (target.fallbackRefreshInterval) { - checkoutDiffFallbackRefreshTargetCount += 1; - } } return { checkoutDiffTargetCount: this.targets.size, checkoutDiffSubscriptionCount, - checkoutDiffWatcherCount, - checkoutDiffFallbackRefreshTargetCount, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, }; } @@ -144,15 +133,8 @@ export class CheckoutDiffManager { clearTimeout(target.debounceTimer); target.debounceTimer = null; } - if (target.fallbackRefreshInterval) { - clearInterval(target.fallbackRefreshInterval); - target.fallbackRefreshInterval = null; - } - for (const watcher of target.watchers) { - watcher.close(); - } - target.watchers = []; - target.watchedPaths.clear(); + target.workingTreeWatchUnsubscribe?.(); + target.workingTreeWatchUnsubscribe = null; target.listeners.clear(); } @@ -172,22 +154,6 @@ export class CheckoutDiffManager { this.targets.delete(targetKey); } - private async resolveCheckoutWatchRoot(cwd: string): Promise { - try { - const { stdout } = await runGitCommand( - ["rev-parse", "--path-format=absolute", "--show-toplevel"], - { - cwd, - env: READ_ONLY_GIT_ENV, - }, - ); - const root = stdout.trim(); - return root.length > 0 ? root : null; - } catch { - return null; - } - } - private scheduleTargetRefresh(target: CheckoutDiffWatchTarget): void { if (target.debounceTimer) { clearTimeout(target.debounceTimer); @@ -274,212 +240,27 @@ export class CheckoutDiffManager { return existing; } - const watchRoot = await this.resolveCheckoutWatchRoot(cwd); const target: CheckoutDiffWatchTarget = { key: targetKey, cwd, - diffCwd: watchRoot ?? cwd, + diffCwd: cwd, compare, listeners: new Set(), - watchers: [], - fallbackRefreshInterval: null, + workingTreeWatchUnsubscribe: null, debounceTimer: null, refreshPromise: null, refreshQueued: false, latestPayload: null, latestFingerprint: null, - watchedPaths: new Set(), - repoWatchPath: null, - linuxTreeRefreshPromise: null, - linuxTreeRefreshQueued: false, }; - - const repoWatchPath = watchRoot ?? cwd; - target.repoWatchPath = repoWatchPath; - const watchPaths = new Set([repoWatchPath]); - const gitDir = await resolveCheckoutGitDir(cwd); - if (gitDir) { - watchPaths.add(gitDir); - } - - let hasRecursiveRepoCoverage = false; - const allowRecursiveRepoWatch = process.platform !== "linux"; - if (process.platform === "linux") { - hasRecursiveRepoCoverage = await this.ensureLinuxRepoTreeWatchers(target, repoWatchPath); - } - for (const watchPath of watchPaths) { - if (process.platform === "linux" && watchPath === repoWatchPath) { - continue; - } - const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch; - const watcherIsRecursive = this.addWatcher(target, watchPath, shouldTryRecursive); - if (watchPath === repoWatchPath && watcherIsRecursive) { - hasRecursiveRepoCoverage = true; - } - } - - const missingRepoCoverage = !hasRecursiveRepoCoverage; - if (target.watchers.length === 0 || missingRepoCoverage) { - target.fallbackRefreshInterval = setInterval(() => { - this.scheduleTargetRefresh(target); - }, CHECKOUT_DIFF_FALLBACK_REFRESH_MS); - this.logger.warn( - { - cwd, - compare, - intervalMs: CHECKOUT_DIFF_FALLBACK_REFRESH_MS, - reason: - target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage", - }, - "Checkout diff watchers unavailable; using timed refresh fallback", - ); - } + const { repoRoot, unsubscribe } = await this.workspaceGitService.requestWorkingTreeWatch( + cwd, + () => this.scheduleTargetRefresh(target), + ); + target.diffCwd = repoRoot ?? cwd; + target.workingTreeWatchUnsubscribe = unsubscribe; this.targets.set(targetKey, target); return target; } - - private addWatcher( - target: CheckoutDiffWatchTarget, - watchPath: string, - shouldTryRecursive: boolean, - ): boolean { - if (target.watchedPaths.has(watchPath)) { - return false; - } - - const { cwd, compare } = target; - const onChange = () => { - if (process.platform === "linux" && target.repoWatchPath) { - void this.refreshLinuxRepoTreeWatchers(target); - } - this.scheduleTargetRefresh(target); - }; - const createWatcher = (recursive: boolean): FSWatcher => - watch(watchPath, { recursive }, () => { - onChange(); - }); - - let watcher: FSWatcher | null = null; - let watcherIsRecursive = false; - try { - if (shouldTryRecursive) { - watcher = createWatcher(true); - watcherIsRecursive = true; - } else { - watcher = createWatcher(false); - } - } catch (error) { - if (shouldTryRecursive) { - try { - watcher = createWatcher(false); - this.logger.warn( - { err: error, watchPath, cwd, compare }, - "Checkout diff recursive watch unavailable; using non-recursive fallback", - ); - } catch (fallbackError) { - this.logger.warn( - { err: fallbackError, watchPath, cwd, compare }, - "Failed to start checkout diff watcher", - ); - } - } else { - this.logger.warn( - { err: error, watchPath, cwd, compare }, - "Failed to start checkout diff watcher", - ); - } - } - - if (!watcher) { - return false; - } - - watcher.on("error", (error) => { - this.logger.warn({ err: error, watchPath, cwd, compare }, "Checkout diff watcher error"); - }); - target.watchers.push(watcher); - target.watchedPaths.add(watchPath); - return watcherIsRecursive; - } - - private async ensureLinuxRepoTreeWatchers( - target: CheckoutDiffWatchTarget, - rootPath: string, - ): Promise { - const directories = await this.listLinuxWatchDirectories(rootPath); - let complete = true; - for (const directory of directories) { - const watcherWasRecursive = this.addWatcher(target, directory, false); - if (!watcherWasRecursive && !target.watchedPaths.has(directory)) { - complete = false; - } - } - return complete && target.watchedPaths.has(rootPath); - } - - private async refreshLinuxRepoTreeWatchers(target: CheckoutDiffWatchTarget): Promise { - if (process.platform !== "linux" || !target.repoWatchPath) { - return; - } - const rootPath = target.repoWatchPath; - if (target.linuxTreeRefreshPromise) { - target.linuxTreeRefreshQueued = true; - return; - } - - target.linuxTreeRefreshPromise = (async () => { - do { - target.linuxTreeRefreshQueued = false; - try { - await this.ensureLinuxRepoTreeWatchers(target, rootPath); - } catch (error) { - this.logger.warn( - { - err: error, - cwd: target.cwd, - compare: target.compare, - rootPath, - }, - "Failed to refresh Linux checkout diff tree watchers", - ); - } - } while (target.linuxTreeRefreshQueued); - })(); - - try { - await target.linuxTreeRefreshPromise; - } finally { - target.linuxTreeRefreshPromise = null; - } - } - - private async listLinuxWatchDirectories(rootPath: string): Promise { - const directories: string[] = []; - const pending = [rootPath]; - - while (pending.length > 0) { - const directory = pending.pop(); - if (!directory) { - continue; - } - directories.push(directory); - - let entries; - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch { - continue; - } - - for (const entry of entries) { - if (!entry.isDirectory() || entry.name === ".git") { - continue; - } - pending.push(join(directory, entry.name)); - } - } - - return directories; - } } diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 02fc0cf8e..3115e63d7 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -11,11 +11,7 @@ import type { import { ProviderOverrideSchema } from "./agent/provider-launch-config.js"; import { AgentProviderSchema } from "./agent/provider-manifest.js"; import { resolveSpeechConfig } from "./speech/speech-config-resolver.js"; -import { - mergeAllowedHosts, - parseAllowedHostsEnv, - type AllowedHostsConfig, -} from "./allowed-hosts.js"; +import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js"; const DEFAULT_PORT = 6767; const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443"; @@ -42,7 +38,7 @@ export type CliConfigOverrides = Partial<{ relayEnabled: boolean; mcpEnabled: boolean; mcpInjectIntoAgents: boolean; - allowedHosts: AllowedHostsConfig; + hostnames: HostnamesConfig; }>; const OptionalVoiceLlmProviderSchema = z @@ -133,10 +129,10 @@ export function loadConfig( const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? []; - const allowedHosts = mergeAllowedHosts([ - persisted.daemon?.allowedHosts, - parseAllowedHostsEnv(env.PASEO_ALLOWED_HOSTS), - options?.cli?.allowedHosts, + const hostnames = mergeHostnames([ + persisted.daemon?.hostnames, + parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS), + options?.cli?.hostnames, ]); const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true; @@ -181,7 +177,7 @@ export function loadConfig( corsAllowedOrigins: Array.from( new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)), ), - allowedHosts, + hostnames, mcpEnabled, mcpInjectIntoAgents, mcpDebug: env.MCP_DEBUG === "1", diff --git a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts index 80b464d3f..2c065c93c 100644 --- a/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts @@ -8,6 +8,7 @@ import { Writable } from "node:stream"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { generateLocalPairingOffer } from "../pairing-offer.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; function createCapturingLogger() { @@ -22,18 +23,25 @@ function createCapturingLogger() { return { logger, lines }; } -function parseOfferUrlFromLogs(lines: string[]): string { - for (const line of lines) { - try { - const obj = JSON.parse(line) as { msg?: string; url?: string }; - if (obj.msg === "pairing_offer" && typeof obj.url === "string") { - return obj.url; - } - } catch { - // ignore non-JSON lines - } +async function getPairingOfferUrl(args: { + paseoHome: string; + relayEnabled?: boolean; + relayEndpoint?: string; + relayPublicEndpoint?: string; + appBaseUrl?: string; +}): Promise { + const pairing = await generateLocalPairingOffer({ + paseoHome: args.paseoHome, + relayEnabled: args.relayEnabled, + relayEndpoint: args.relayEndpoint, + relayPublicEndpoint: args.relayPublicEndpoint, + appBaseUrl: args.appBaseUrl, + includeQr: false, + }); + if (!pairing.url) { + throw new Error("Expected relay pairing URL to be available"); } - throw new Error(`pairing_offer log not found. saw ${lines.length} lines`); + return pairing.url; } function decodeOfferFromFragmentUrl(url: string): unknown { @@ -72,7 +80,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { test("emits relay-only offer URL with stable serverId", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; - const { logger, lines } = createCapturingLogger(); + const { logger } = createCapturingLogger(); const daemon = await createTestPaseoDaemon({ listen: "0.0.0.0", @@ -81,7 +89,13 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { }); try { - const offerUrl = parseOfferUrlFromLogs(lines); + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + appBaseUrl: daemon.config.appBaseUrl, + }); expect(offerUrl.startsWith("https://app.paseo.sh/#offer=")).toBe(true); const offer = decodeOfferFromFragmentUrl(offerUrl) as { @@ -111,7 +125,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { const tempHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-offer-home-")); - const { logger: logger1, lines: lines1 } = createCapturingLogger(); + const { logger: logger1 } = createCapturingLogger(); const daemon1 = await createTestPaseoDaemon({ listen: "0.0.0.0", logger: logger1, @@ -124,7 +138,13 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { let staticDir2: string | null = null; try { - const offerUrl1 = parseOfferUrlFromLogs(lines1); + const offerUrl1 = await getPairingOfferUrl({ + paseoHome: daemon1.paseoHome, + relayEnabled: daemon1.config.relayEnabled, + relayEndpoint: daemon1.config.relayEndpoint, + relayPublicEndpoint: daemon1.config.relayPublicEndpoint, + appBaseUrl: daemon1.config.appBaseUrl, + }); const offer1 = decodeOfferFromFragmentUrl(offerUrl1) as { serverId: string; daemonPublicKeyB64: string; @@ -133,7 +153,7 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { await daemon1.close(); - const { logger: logger2, lines: lines2 } = createCapturingLogger(); + const { logger: logger2 } = createCapturingLogger(); const daemon2 = await createTestPaseoDaemon({ listen: "0.0.0.0", logger: logger2, @@ -144,7 +164,13 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { staticDir2 = daemon2.staticDir; try { - const offerUrl2 = parseOfferUrlFromLogs(lines2); + const offerUrl2 = await getPairingOfferUrl({ + paseoHome: daemon2.paseoHome, + relayEnabled: daemon2.config.relayEnabled, + relayEndpoint: daemon2.config.relayEndpoint, + relayPublicEndpoint: daemon2.config.relayPublicEndpoint, + appBaseUrl: daemon2.config.appBaseUrl, + }); const offer2 = decodeOfferFromFragmentUrl(offerUrl2) as { serverId: string; daemonPublicKeyB64: string; @@ -207,12 +233,6 @@ describe("ConnectionOfferV2 (daemon E2E)", () => { stdoutLines.push(text); for (const line of text.split("\n")) { if (!line.trim()) continue; - if (line.includes("pairing_offer")) { - clearTimeout(timeout); - reject(new Error("unexpected pairing_offer log when --no-relay is set")); - return; - } - try { const parsed = JSON.parse(line) as { msg?: string }; if (parsed.msg !== `Server listening on http://0.0.0.0:${port}`) continue; diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index ecc474c65..6241d7578 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; import { Buffer } from "node:buffer"; +import { generateLocalPairingOffer } from "../pairing-offer.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; import { buildRelayWebSocketUrl } from "../../shared/daemon-endpoints.js"; @@ -26,19 +27,25 @@ function createCapturingLogger() { return { logger, lines }; } -function parseOfferUrlFromLogs(lines: string[]): string { - for (const line of lines) { - if (!line.includes("pairing_offer")) continue; - try { - const obj = JSON.parse(line) as { msg?: string; url?: string }; - if (obj.msg === "pairing_offer" && typeof obj.url === "string") { - return obj.url; - } - } catch { - // ignore - } +async function getPairingOfferUrl(args: { + paseoHome: string; + relayEnabled?: boolean; + relayEndpoint?: string; + relayPublicEndpoint?: string; + appBaseUrl?: string; +}): Promise { + const pairing = await generateLocalPairingOffer({ + paseoHome: args.paseoHome, + relayEnabled: args.relayEnabled, + relayEndpoint: args.relayEndpoint, + relayPublicEndpoint: args.relayPublicEndpoint, + appBaseUrl: args.appBaseUrl, + includeQr: false, + }); + if (!pairing.url) { + throw new Error("Expected relay pairing URL to be available"); } - throw new Error(`pairing_offer log not found. saw ${lines.length} lines`); + return pairing.url; } function decodeOfferFromFragmentUrl(url: string): { @@ -199,7 +206,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis }); try { - const offerUrl = parseOfferUrlFromLogs(lines); + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + appBaseUrl: daemon.config.appBaseUrl, + }); const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); const stableClientId = `cid_test_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; @@ -314,7 +327,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis }); try { - const offerUrl = parseOfferUrlFromLogs(lines); + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + appBaseUrl: daemon.config.appBaseUrl, + }); const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); // Previously, the daemon would time out waiting for `hello` and reconnect every ~10s. diff --git a/packages/server/src/server/hostnames.test.ts b/packages/server/src/server/hostnames.test.ts new file mode 100644 index 000000000..88778f519 --- /dev/null +++ b/packages/server/src/server/hostnames.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { PersistedConfigSchema } from "./persisted-config.js"; +import { isHostnameAllowed, mergeHostnames, parseHostnamesEnv } from "./hostnames.js"; + +describe("hostnames (vite-style)", () => { + it("allows localhost by default", () => { + expect(isHostnameAllowed("localhost:6767", undefined)).toBe(true); + }); + + it("allows subdomains of .localhost by default", () => { + expect(isHostnameAllowed("foo.localhost:6767", undefined)).toBe(true); + }); + + it("allows IP addresses by default", () => { + expect(isHostnameAllowed("127.0.0.1:6767", undefined)).toBe(true); + expect(isHostnameAllowed("[::1]:6767", undefined)).toBe(true); + }); + + it("rejects non-default hosts when no allowlist is provided", () => { + expect(isHostnameAllowed("evil.com:6767", undefined)).toBe(false); + }); + + it("allows any host when set to true", () => { + expect(isHostnameAllowed("evil.com:6767", true)).toBe(true); + }); + + it("supports leading-dot patterns", () => { + const hostnames = [".example.com"]; + expect(isHostnameAllowed("example.com:6767", hostnames)).toBe(true); + expect(isHostnameAllowed("foo.example.com:6767", hostnames)).toBe(true); + expect(isHostnameAllowed("foo.bar.example.com:6767", hostnames)).toBe(true); + expect(isHostnameAllowed("notexample.com:6767", hostnames)).toBe(false); + }); + + it("merges arrays (append + de-dupe) and short-circuits on true", () => { + expect(mergeHostnames([["a"], ["a", "b"]])).toEqual(["a", "b"]); + expect(mergeHostnames([["a"], true, ["b"]])).toBe(true); + }); + + it("parses env var values", () => { + expect(parseHostnamesEnv(undefined)).toBeUndefined(); + expect(parseHostnamesEnv("")).toBeUndefined(); + expect(parseHostnamesEnv("true")).toBe(true); + expect(parseHostnamesEnv("localhost,.example.com")).toEqual(["localhost", ".example.com"]); + }); + + it("normalizes persisted allowedHosts into hostnames for backward compatibility", () => { + const parsed = PersistedConfigSchema.parse({ + daemon: { + allowedHosts: [".example.com"], + }, + }); + + expect(parsed.daemon?.hostnames).toEqual([".example.com"]); + }); +}); diff --git a/packages/server/src/server/allowed-hosts.ts b/packages/server/src/server/hostnames.ts similarity index 72% rename from packages/server/src/server/allowed-hosts.ts rename to packages/server/src/server/hostnames.ts index 6186b1fe3..0253305b2 100644 --- a/packages/server/src/server/allowed-hosts.ts +++ b/packages/server/src/server/hostnames.ts @@ -1,6 +1,6 @@ import net from "node:net"; -export type AllowedHostsConfig = true | string[] | undefined; +export type HostnamesConfig = true | string[] | undefined; function normalizeHostname(hostname: string): string { return hostname.trim().toLowerCase(); @@ -25,7 +25,7 @@ function parseHostnameFromHostHeader(hostHeader: string): string | null { return normalizeHostname(trimmed.slice(0, colonIndex)); } -function matchesAllowedHostPattern(hostname: string, pattern: string): boolean { +function matchesHostnamePattern(hostname: string, pattern: string): boolean { const normalizedPattern = normalizeHostname(pattern); if (!normalizedPattern) return false; @@ -47,33 +47,33 @@ function isDefaultAllowedHostname(hostname: string): boolean { } /** - * Vite-style allowed hosts check, adapted to raw Host headers. + * Vite-style hostname allowlist check, adapted to raw Host headers. * * Semantics: - * - `allowedHosts === true` => allow any host. - * - `allowedHosts === []` or `undefined` => allow localhost, *.localhost, and all IPs. - * - `allowedHosts === ['.example.com', 'myhost']` => allow those *in addition* to defaults. + * - `hostnames === true` => allow any host. + * - `hostnames === []` or `undefined` => allow localhost, *.localhost, and all IPs. + * - `hostnames === ['.example.com', 'myhost']` => allow those *in addition* to defaults. */ -export function isHostAllowed( +export function isHostnameAllowed( hostHeader: string | undefined, - allowedHosts: AllowedHostsConfig, + hostnames: HostnamesConfig, ): boolean { const hostname = hostHeader ? parseHostnameFromHostHeader(hostHeader) : null; if (!hostname) return false; - if (allowedHosts === true) return true; + if (hostnames === true) return true; // Defaults are always allowed. if (isDefaultAllowedHostname(hostname)) return true; - const patterns = allowedHosts ?? []; + const patterns = hostnames ?? []; for (const pattern of patterns) { - if (matchesAllowedHostPattern(hostname, pattern)) return true; + if (matchesHostnamePattern(hostname, pattern)) return true; } return false; } -export function mergeAllowedHosts(values: Array): AllowedHostsConfig { +export function mergeHostnames(values: Array): HostnamesConfig { let merged: string[] = []; for (const value of values) { if (value === true) return true; @@ -85,7 +85,7 @@ export function mergeAllowedHosts(values: Array): AllowedHos return deduped; } -export function parseAllowedHostsEnv(raw: string | undefined): AllowedHostsConfig { +export function parseHostnamesEnv(raw: string | undefined): HostnamesConfig { if (!raw) return undefined; const trimmed = raw.trim(); if (!trimmed) return undefined; diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 5fd0483a2..362369932 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -228,6 +228,7 @@ export const PersistedConfigSchema = z daemon: z .object({ listen: z.string().optional(), + hostnames: z.union([z.literal(true), z.array(z.string())]).optional(), allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(), mcp: z .object({ @@ -252,6 +253,10 @@ export const PersistedConfigSchema = z .optional(), }) .strict() + .transform(({ allowedHosts, ...daemon }) => { + const hostnames = daemon.hostnames ?? allowedHosts; + return hostnames === undefined ? daemon : { ...daemon, hostnames }; + }) .optional(), app: z diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index fcc9ffd99..62ccfb0a4 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -156,7 +156,6 @@ import { getCheckoutDiff, getCachedCheckoutShortstat, getCheckoutStatus, - getCheckoutStatusLite, listBranchSuggestions, commitChanges, mergeToBase, @@ -191,6 +190,7 @@ import { handlePaseoWorktreeListRequest as handleWorktreeListRequest, handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage, killTerminalsUnderPath as killWorktreeTerminalsUnderPath, + registerPendingWorktreeWorkspace as registerPendingWorktreeWorkspaceSession, } from "./worktree-session.js"; const execAsync = promisify(exec); @@ -1465,6 +1465,63 @@ export class Session { return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null; } + private async buildProjectPlacement(cwd: string): Promise { + return buildProjectPlacementForCwdStandalone({ + cwd, + workspaceGitService: this.workspaceGitService, + }); + } + + private buildPersistedProjectRecord(input: { + workspaceId: string; + placement: ProjectPlacementPayload; + createdAt: string; + updatedAt: string; + }): PersistedProjectRecord { + return createPersistedProjectRecord({ + projectId: input.placement.projectKey, + rootPath: deriveProjectRootPath({ + cwd: input.workspaceId, + checkout: input.placement.checkout, + }), + kind: deriveProjectKind(input.placement.checkout), + displayName: input.placement.projectName, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + archivedAt: null, + }); + } + + private buildPersistedWorkspaceRecord(input: { + workspaceId: string; + placement: ProjectPlacementPayload; + createdAt: string; + updatedAt: string; + }): PersistedWorkspaceRecord { + return createPersistedWorkspaceRecord({ + workspaceId: input.workspaceId, + projectId: input.placement.projectKey, + cwd: input.workspaceId, + kind: deriveWorkspaceKind(input.placement.checkout), + displayName: deriveWorkspaceDisplayName({ + cwd: input.workspaceId, + checkout: input.placement.checkout, + }), + createdAt: input.createdAt, + updatedAt: input.updatedAt, + archivedAt: null, + }); + } + + private async archiveProjectRecordIfEmpty(projectId: string, archivedAt: string): Promise { + const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( + (workspace) => workspace.projectId === projectId && !workspace.archivedAt, + ); + if (siblingWorkspaces.length === 0) { + await this.projectRegistry.archive(projectId, archivedAt); + } + } + private async resolveWorkspaceByIdOrDirectory( workspaceId: string, ): Promise { @@ -1477,10 +1534,8 @@ export class Session { private async resolveWorkspaceDirectory(cwd: string): Promise { const normalizedCwd = normalizePersistedWorkspaceId(cwd); try { - const checkout = await getCheckoutStatusLite(normalizedCwd, { - paseoHome: this.paseoHome, - }); - return normalizePersistedWorkspaceId(checkout.worktreeRoot ?? normalizedCwd); + const snapshot = await this.workspaceGitService.getSnapshot(normalizedCwd); + return normalizePersistedWorkspaceId(snapshot.git.repoRoot ?? normalizedCwd); } catch { return normalizedCwd; } @@ -1823,184 +1878,10 @@ export class Session { await this.handleCheckoutPrStatusRequest(msg); break; - case "paseo_worktree_list_request": - await this.handlePaseoWorktreeListRequest(msg); - break; - - case "paseo_worktree_archive_request": - await this.handlePaseoWorktreeArchiveRequest(msg); - break; - - case "create_paseo_worktree_request": - await this.handleCreatePaseoWorktreeRequest(msg); - break; - - case "list_available_editors_request": - await this.handleListAvailableEditorsRequest(msg); - break; - - case "open_in_editor_request": - await this.handleOpenInEditorRequest(msg); - break; - - case "open_project_request": - await this.handleOpenProjectRequest(msg); - break; - - case "archive_workspace_request": - await this.handleArchiveWorkspaceRequest(msg); - break; - - case "file_explorer_request": - await this.handleFileExplorerRequest(msg); - break; - - case "project_icon_request": - await this.handleProjectIconRequest(msg); - break; - - case "file_download_token_request": - await this.handleFileDownloadTokenRequest(msg); - break; - - case "list_provider_models_request": - await this.handleListProviderModelsRequest(msg); - break; - - case "list_provider_modes_request": - await this.handleListProviderModesRequest(msg); - break; - - case "list_provider_features_request": - await this.handleListProviderFeaturesRequest(msg); - break; - - case "list_available_providers_request": - await this.handleListAvailableProvidersRequest(msg); - break; - - case "get_providers_snapshot_request": - await this.handleGetProvidersSnapshotRequest(msg); - break; - - case "refresh_providers_snapshot_request": - await this.handleRefreshProvidersSnapshotRequest(msg); - break; - - case "provider_diagnostic_request": - await this.handleProviderDiagnosticRequest(msg); - break; - - case "clear_agent_attention": - await this.handleClearAgentAttention(msg.agentId, msg.requestId); - break; - - case "client_heartbeat": - this.handleClientHeartbeat(msg); - break; - - case "ping": { - const now = Date.now(); - this.emit({ - type: "pong", - payload: { - requestId: msg.requestId, - clientSentAt: msg.clientSentAt, - serverReceivedAt: now, - serverSentAt: now, - }, - }); - break; - } - - case "list_commands_request": - await this.handleListCommandsRequest(msg); - break; - - case "register_push_token": - this.handleRegisterPushToken(msg.token); - break; - - case "subscribe_terminals_request": - this.handleSubscribeTerminalsRequest(msg); - break; - - case "unsubscribe_terminals_request": - this.handleUnsubscribeTerminalsRequest(msg); - break; - - case "list_terminals_request": - await this.handleListTerminalsRequest(msg); - break; - - case "create_terminal_request": - await this.handleCreateTerminalRequest(msg); - break; - - case "subscribe_terminal_request": - await this.handleSubscribeTerminalRequest(msg); - break; - - case "unsubscribe_terminal_request": - this.handleUnsubscribeTerminalRequest(msg); - break; - - case "terminal_input": - this.handleTerminalInput(msg); - break; - - case "kill_terminal_request": - await this.handleKillTerminalRequest(msg); - break; - - case "capture_terminal_request": - await this.handleCaptureTerminalRequest(msg); - break; - - case "chat/create": - await this.handleChatCreateRequest(msg); - break; - - case "chat/list": - await this.handleChatListRequest(msg); - break; - - case "chat/inspect": - await this.handleChatInspectRequest(msg); - break; - - case "chat/delete": - await this.handleChatDeleteRequest(msg); - break; - - case "chat/post": - await this.handleChatPostRequest(msg); - break; - - case "chat/read": - await this.handleChatReadRequest(msg); - break; - - case "chat/wait": - await this.handleChatWaitRequest(msg); - break; - case "github_search_request": await this.handleGitHubSearchRequest(msg); break; - case "directory_suggestions_request": - await this.handleDirectorySuggestionsRequest(msg); - break; - - case "checkout_pr_create_request": - await this.handleCheckoutPrCreateRequest(msg); - break; - - case "checkout_pr_status_request": - await this.handleCheckoutPrStatusRequest(msg); - break; - case "paseo_worktree_list_request": await this.handlePaseoWorktreeListRequest(msg); break; @@ -2074,7 +1955,7 @@ export class Session { break; case "clear_agent_attention": - await this.handleClearAgentAttention(msg.agentId); + await this.handleClearAgentAttention(msg.agentId, msg.requestId); break; case "client_heartbeat": @@ -3378,6 +3259,7 @@ export class Session { { paseoHome: this.paseoHome, sessionLogger: this.sessionLogger, + workspaceGitService: this.workspaceGitService, checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch), createBranchFromBase: (params) => this.createBranchFromBase(params), }, @@ -5030,7 +4912,6 @@ export class Session { const { cwd, requestId } = msg; try { - await this.workspaceGitService.refresh(cwd, { priority: "high" }); const snapshot = await this.workspaceGitService.getSnapshot(cwd); this.emit({ type: "checkout_pr_status_response", @@ -5788,7 +5669,19 @@ export class Session { projectRecord?: PersistedProjectRecord | null, ): Promise { const base = await this.describeWorkspaceRecord(workspace, projectRecord); - const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + let snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); + if (!snapshot) { + try { + snapshot = await this.workspaceGitService.getSnapshot(workspace.cwd); + } catch (error) { + this.sessionLogger.warn( + { err: error, cwd: workspace.cwd }, + "Failed to load git snapshot for workspace", + ); + return base; + } + } + if (!snapshot) { return base; } @@ -6229,7 +6122,7 @@ export class Session { const placement = await buildProjectPlacementForCwdStandalone({ cwd: normalizedCwd, - paseoHome: this.paseoHome, + workspaceGitService: this.workspaceGitService, }); const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout); const timestamp = new Date().toISOString(); @@ -6263,56 +6156,21 @@ export class Session { branchName: string; }): Promise { await this.findOrCreateWorkspaceForDirectory(options.repoRoot); - const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath); - const basePlacement = await this.buildProjectPlacementForCwd(options.repoRoot); - if (!basePlacement) { - throw new Error(`Workspace not found for repo root ${options.repoRoot}`); - } - - const projectId = basePlacement.projectKey; - const now = new Date().toISOString(); - const existingWorkspace = await this.findWorkspaceByDirectory(workspaceDirectory); - if (!existingWorkspace) { - const newRecord = createPersistedWorkspaceRecord({ - workspaceId: workspaceDirectory, - projectId, - cwd: workspaceDirectory, - displayName: options.branchName, - kind: "worktree", - createdAt: now, - updatedAt: now, - }); - await this.workspaceRegistry.upsert(newRecord); - await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true }); - return newRecord; - } - - await this.workspaceRegistry.upsert( - createPersistedWorkspaceRecord({ - workspaceId: existingWorkspace.workspaceId, - projectId, - cwd: workspaceDirectory, - displayName: options.branchName, - kind: "worktree", - createdAt: existingWorkspace.createdAt, - updatedAt: now, - }), + return registerPendingWorktreeWorkspaceSession( + { + buildPersistedProjectRecord: (input) => this.buildPersistedProjectRecord(input), + buildPersistedWorkspaceRecord: (input) => this.buildPersistedWorkspaceRecord(input), + buildProjectPlacement: (cwd) => this.buildProjectPlacement(cwd), + findWorkspaceByDirectory: (directory) => this.findWorkspaceByDirectory(directory), + projectRegistry: this.projectRegistry, + syncWorkspaceGitWatchTarget: (cwd, syncOptions) => + this.syncWorkspaceGitWatchTarget(cwd, syncOptions), + workspaceRegistry: this.workspaceRegistry, + archiveProjectRecordIfEmpty: (projectId, archivedAt) => + this.archiveProjectRecordIfEmpty(projectId, archivedAt), + }, + options, ); - await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true }); - - if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) { - const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( - (workspace) => - workspace.projectId === existingWorkspace.projectId && - workspace.workspaceId !== existingWorkspace.workspaceId && - !workspace.archivedAt, - ); - if (siblingWorkspaces.length === 0) { - await this.projectRegistry.archive(existingWorkspace.projectId, now); - } - } - - return (await this.workspaceRegistry.get(existingWorkspace.workspaceId))!; } private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise { @@ -6790,10 +6648,13 @@ export class Session { return handleCreateWorktreeRequest( { paseoHome: this.paseoHome, + workspaceGitService: this.workspaceGitService, describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace), emit: (message) => this.emit(message), registerPendingWorktreeWorkspace: (options) => this.registerPendingWorktreeWorkspace(options), + syncWorkspaceGitWatchTarget: (cwd, syncOptions) => + this.syncWorkspaceGitWatchTarget(cwd, syncOptions), sessionLogger: this.sessionLogger, runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options), }, diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 302adc7a9..352292b0d 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -71,6 +71,8 @@ function createSessionForWorkspaceGitWatchTests(): { peekSnapshot: ReturnType; getSnapshot: ReturnType; refresh: ReturnType; + requestWorkingTreeWatch: ReturnType; + scheduleRefreshForCwd: ReturnType; dispose: ReturnType; }; subscriptions: Array<{ @@ -111,6 +113,11 @@ function createSessionForWorkspaceGitWatchTests(): { peekSnapshot: vi.fn((cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), getSnapshot: vi.fn(async (cwd: string) => createWorkspaceRuntimeSnapshot(cwd)), refresh: vi.fn(async () => {}), + requestWorkingTreeWatch: vi.fn(async (cwd: string) => ({ + repoRoot: cwd, + unsubscribe: vi.fn(), + })), + scheduleRefreshForCwd: vi.fn(), dispose: vi.fn(), }; @@ -351,62 +358,17 @@ describe("workspace git watch targets", () => { }); }); - test("checkout_pr_status_request explicitly refreshes the focused workspace before reading runtime data", async () => { + test("checkout_pr_status_request reads cached snapshot without forcing a refresh", async () => { const { session, emitted, workspaceGitService } = createSessionForWorkspaceGitWatchTests(); - let refreshed = false; - - workspaceGitService.refresh.mockImplementation(async () => { - refreshed = true; - }); - workspaceGitService.getSnapshot.mockImplementation(async (cwd: string) => - createWorkspaceRuntimeSnapshot(cwd, { - github: { - pullRequest: refreshed - ? { - url: "https://github.com/acme/repo/pull/457", - title: "After explicit refresh", - state: "merged", - baseRefName: "main", - headRefName: "workspace-git-service", - isMerged: true, - } - : { - url: "https://github.com/acme/repo/pull/456", - title: "Before explicit refresh", - state: "open", - baseRefName: "main", - headRefName: "workspace-git-service", - isMerged: false, - }, - refreshedAt: refreshed ? "2026-04-12T00:10:00.000Z" : "2026-04-12T00:05:00.000Z", - }, - }), - ); await session.handleMessage({ type: "checkout_pr_status_request", cwd: "/tmp/repo", - requestId: "req-pr-refresh", + requestId: "req-pr-cached", }); - expect(workspaceGitService.refresh).toHaveBeenCalledWith("/tmp/repo", { - priority: "high", - }); - expect( - emitted.find((message) => message.type === "checkout_pr_status_response")?.payload, - ).toEqual({ - cwd: "/tmp/repo", - status: { - url: "https://github.com/acme/repo/pull/457", - title: "After explicit refresh", - state: "merged", - baseRefName: "main", - headRefName: "workspace-git-service", - isMerged: true, - }, - githubFeaturesEnabled: true, - error: null, - requestId: "req-pr-refresh", - }); + expect(workspaceGitService.refresh).not.toHaveBeenCalled(); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo"); + expect(emitted.find((message) => message.type === "checkout_pr_status_response")).toBeDefined(); }); }); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 1dc7d1b18..eca80beea 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -113,6 +113,11 @@ function createNoopWorkspaceGitService() { }, }), refresh: async () => {}, + requestWorkingTreeWatch: async (cwd: string) => ({ + repoRoot: cwd, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, dispose: () => {}, }; } @@ -1177,7 +1182,6 @@ describe("workspace aggregation", () => { test("create paseo worktree request returns a registered workspace descriptor", async () => { const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-"))); const repoDir = path.join(tempDir, "repo"); const paseoHome = path.join(tempDir, "paseo-home"); @@ -1188,6 +1192,45 @@ describe("workspace aggregation", () => { writeFileSync(path.join(repoDir, "file.txt"), "hello\n"); execSync("git add .", { cwd: repoDir, stdio: "pipe" }); execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); + const workspaceGitService = createNoopWorkspaceGitService(); + workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => { + if (cwd === repoDir) { + return createWorkspaceRuntimeSnapshot(cwd, { + git: { + repoRoot: repoDir, + currentBranch: "main", + remoteUrl: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + } + + if (cwd.includes("worktree-123")) { + return createWorkspaceRuntimeSnapshot(cwd, { + git: { + repoRoot: cwd, + currentBranch: "worktree-123", + remoteUrl: null, + isPaseoOwnedWorktree: true, + mainRepoRoot: repoDir, + }, + }); + } + + return createWorkspaceRuntimeSnapshot(cwd, { + git: { + repoRoot: cwd, + currentBranch: "main", + remoteUrl: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + }); + const session = createSessionForWorkspaceTests({ + workspaceGitService, + }) as any; const workspaces = new Map(); const projects = new Map(); diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 902dcb0f8..c34fa0a3d 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -85,7 +85,7 @@ export async function createTestPaseoDaemon( listen: `${listenHost}:0`, paseoHome, corsAllowedOrigins: options.corsAllowedOrigins ?? [], - allowedHosts: true, + hostnames: true, mcpEnabled: true, staticDir, mcpDebug: false, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ff325460a..01551a61a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -23,8 +23,8 @@ import { wrapSessionMessage, } from "./messages.js"; import { asUint8Array, decodeTerminalStreamFrame } from "../shared/terminal-stream-protocol.js"; -import type { AllowedHostsConfig } from "./allowed-hosts.js"; -import { isHostAllowed } from "./allowed-hosts.js"; +import type { HostnamesConfig } from "./hostnames.js"; +import { isHostnameAllowed } from "./hostnames.js"; import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { @@ -63,11 +63,71 @@ type PendingConnection = { type WebSocketServerConfig = { allowedOrigins: Set; - allowedHosts?: AllowedHostsConfig; + hostnames?: HostnamesConfig; }; type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics; +function createFallbackWorkspaceGitService(): WorkspaceGitServiceImpl { + return { + subscribe: async ({ cwd }: { cwd: string }) => ({ + initial: { + cwd, + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }, + unsubscribe: () => {}, + }), + peekSnapshot: () => null, + getSnapshot: async (cwd: string) => ({ + cwd, + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }), + refresh: async () => {}, + requestWorkingTreeWatch: async (cwd: string) => ({ + repoRoot: cwd, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + dispose: () => {}, + } as unknown as WorkspaceGitServiceImpl; +} + function createNoopProjectRegistry(): ProjectRegistry { return { initialize: async () => {}, @@ -327,6 +387,7 @@ export class VoiceAssistantWebSocketServer { getDaemonTcpPort?: () => number | null, getDaemonTcpHost?: () => string | null, resolveScriptHealth?: (hostname: string) => ScriptHealthState | null, + workspaceGitService?: WorkspaceGitServiceImpl, ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -354,10 +415,7 @@ export class VoiceAssistantWebSocketServer { throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager."); } this.checkoutDiffManager = checkoutDiffManager; - this.workspaceGitService = new WorkspaceGitServiceImpl({ - logger: this.logger, - paseoHome, - }); + this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService(); this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; this.daemonConfigStore = daemonConfigStore; @@ -403,7 +461,7 @@ export class VoiceAssistantWebSocketServer { }); }); - const { allowedOrigins, allowedHosts } = wsConfig; + const { allowedOrigins, hostnames } = wsConfig; this.wss = new WebSocketServer({ server, path: "/ws", @@ -411,7 +469,7 @@ export class VoiceAssistantWebSocketServer { const requestMetadata = extractSocketRequestMetadata(req); const origin = requestMetadata.origin; const requestHost = requestMetadata.host ?? null; - if (requestHost && !isHostAllowed(requestHost, allowedHosts)) { + if (requestHost && !isHostnameAllowed(requestHost, hostnames)) { this.incrementRuntimeCounter("hostRejected"); this.logger.warn( { ...requestMetadata, host: requestHost }, @@ -553,8 +611,8 @@ export class VoiceAssistantWebSocketServer { await Promise.all(cleanupPromises); this.providerSnapshotManager.destroy(); - this.workspaceGitService.dispose(); this.checkoutDiffManager.dispose(); + this.workspaceGitService.dispose(); this.pendingConnections.clear(); this.sessions.clear(); this.externalSessionsByKey.clear(); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 97baf930c..a792c1b6c 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import path from "node:path"; import type { CheckoutStatusGit, PullRequestStatusResult } from "../utils/checkout-git.js"; import { WorkspaceGitServiceImpl, @@ -116,11 +117,28 @@ function createWatcher() { }; } +function createDirent(name: string, isDirectory: boolean) { + return { + name, + isDirectory: () => isDirectory, + }; +} + async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + function createService(options?: { getCheckoutStatus?: ReturnType; getCheckoutShortstat?: ReturnType; @@ -129,6 +147,8 @@ function createService(options?: { resolveAbsoluteGitDir?: ReturnType; hasOriginRemote?: ReturnType; runGitFetch?: ReturnType; + runGitCommand?: ReturnType; + readdir?: ReturnType; watch?: ReturnType; now?: () => Date; }) { @@ -137,6 +157,7 @@ function createService(options?: { paseoHome: "/tmp/paseo-test", deps: { watch: options?.watch ?? ((() => createWatcher()) as unknown as any), + readdir: options?.readdir ?? vi.fn(async () => []), getCheckoutStatus: options?.getCheckoutStatus ?? vi.fn(async (cwd: string) => createCheckoutStatus(cwd)), getCheckoutShortstat: @@ -151,6 +172,15 @@ function createService(options?: { resolveAbsoluteGitDir: options?.resolveAbsoluteGitDir ?? vi.fn(async () => "/tmp/repo/.git"), hasOriginRemote: options?.hasOriginRemote ?? vi.fn(async () => false), runGitFetch: options?.runGitFetch ?? vi.fn(async () => {}), + runGitCommand: + options?.runGitCommand ?? + vi.fn(async () => ({ + stdout: "/tmp/repo\n", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })), now: options?.now ?? (() => new Date("2026-04-12T00:00:00.000Z")), }, }); @@ -217,6 +247,48 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); + test("cold getSnapshot calls share one workspace target setup and cache the snapshot", async () => { + const checkoutStatusDeferred = createDeferred(); + const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise); + const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); + + const service = createService({ + getCheckoutStatus, + getPullRequestStatus, + resolveAbsoluteGitDir, + }); + + const firstSnapshotPromise = service.getSnapshot("/tmp/repo"); + const secondSnapshotPromise = service.getSnapshot("/tmp/repo/."); + await flushPromises(); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledTimes(0); + expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0); + expect((service as any).workspaceTargets.size).toBe(0); + expect((service as any).workspaceTargetSetups.size).toBe(1); + + checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo")); + + await expect(Promise.all([firstSnapshotPromise, secondSnapshotPromise])).resolves.toEqual([ + createSnapshot("/tmp/repo"), + createSnapshot("/tmp/repo"), + ]); + + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1); + expect((service as any).workspaceTargets.size).toBe(1); + expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo")); + + await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo")); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => { const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git"); @@ -432,4 +504,172 @@ describe("WorkspaceGitServiceImpl", () => { subscription.unsubscribe(); service.dispose(); }); + + test("watches nested repository directories on Linux", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + configurable: true, + value: "linux", + }); + + const watchCalls: Array<{ path: string; close: ReturnType }> = []; + const watch = vi.fn((watchPath: string) => { + const watcher = createWatcher(); + watchCalls.push({ path: watchPath, close: watcher.close }); + return watcher as any; + }); + const readdir = vi.fn(async (directory: string) => { + if (directory === "/tmp/repo") { + return [ + createDirent("packages", true), + createDirent(".git", true), + createDirent("README.md", false), + ]; + } + if (directory === path.join("/tmp/repo", "packages")) { + return [createDirent("server", true), createDirent("app", true)]; + } + if (directory === path.join("/tmp/repo", "packages", "server")) { + return [createDirent("src", true)]; + } + if (directory === path.join("/tmp/repo", "packages", "server", "src")) { + return [createDirent("server", true)]; + } + return []; + }); + + const service = createService({ watch, readdir }); + const subscription = await service.requestWorkingTreeWatch( + path.join("/tmp/repo", "packages", "server"), + vi.fn(), + ); + + expect(subscription.repoRoot).toBe("/tmp/repo"); + expect(watchCalls.map((entry) => entry.path).sort()).toEqual([ + "/tmp/repo", + "/tmp/repo/.git", + "/tmp/repo/packages", + "/tmp/repo/packages/app", + "/tmp/repo/packages/server", + "/tmp/repo/packages/server/src", + "/tmp/repo/packages/server/src/server", + ]); + + subscription.unsubscribe(); + service.dispose(); + Object.defineProperty(process, "platform", { + configurable: true, + value: originalPlatform, + }); + }); + + test("requestWorkingTreeWatch reference-counts watchers by cwd", async () => { + const watchers = [createWatcher(), createWatcher()]; + const watch = vi + .fn() + .mockReturnValueOnce(watchers[0] as any) + .mockReturnValueOnce(watchers[1] as any); + const service = createService({ watch }); + + const firstListener = vi.fn(); + const secondListener = vi.fn(); + const first = await service.requestWorkingTreeWatch("/tmp/repo", firstListener); + const second = await service.requestWorkingTreeWatch("/tmp/repo/.", secondListener); + + expect(first.repoRoot).toBe("/tmp/repo"); + expect(second.repoRoot).toBe("/tmp/repo"); + expect(watch).toHaveBeenCalledTimes(2); + + first.unsubscribe(); + expect(watchers[0].close).not.toHaveBeenCalled(); + expect(watchers[1].close).not.toHaveBeenCalled(); + + second.unsubscribe(); + expect(watchers[0].close).toHaveBeenCalledTimes(1); + expect(watchers[1].close).toHaveBeenCalledTimes(1); + + service.dispose(); + }); + + test("sets a 5-second fallback polling interval when recursive watch is unavailable", async () => { + if (process.platform === "linux") { + // On Linux, recursive watch is never attempted — the service uses per-directory + // watchers from the start. This scenario only applies to macOS/Windows where + // recursive watch is tried first and may fail. + return; + } + + const recursiveUnsupported = new Error("recursive unsupported"); + const watch = vi + .fn() + .mockImplementationOnce((_watchPath: string, options: { recursive: boolean }) => { + if (options.recursive) { + throw recursiveUnsupported; + } + return createWatcher() as any; + }) + .mockImplementationOnce(() => createWatcher() as any); + + const service = createService({ watch }); + const subscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn()); + const target = (service as any).workingTreeWatchTargets.get("/tmp/repo"); + + expect(target?.fallbackRefreshInterval).not.toBeNull(); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("non-git directories fall back to watching cwd with polling", async () => { + const watch = vi.fn(() => createWatcher() as any); + const runGitCommand = vi.fn(async () => { + throw new Error("not a git repository"); + }); + const resolveAbsoluteGitDir = vi.fn(async () => null); + const service = createService({ + watch, + runGitCommand, + resolveAbsoluteGitDir, + }); + + const subscription = await service.requestWorkingTreeWatch("/tmp/plain", vi.fn()); + const target = (service as any).workingTreeWatchTargets.get("/tmp/plain"); + + expect(subscription.repoRoot).toBeNull(); + const expectedRecursive = process.platform !== "linux"; + expect(watch).toHaveBeenCalledWith( + "/tmp/plain", + { recursive: expectedRecursive }, + expect.any(Function), + ); + expect(target?.repoWatchPath).toBe("/tmp/plain"); + expect(target?.fallbackRefreshInterval).not.toBeNull(); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("working tree changes notify listeners and schedule workspace refresh", async () => { + const watchCallbacks: Array<() => void> = []; + const watch = vi.fn( + (_watchPath: string, _options: { recursive: boolean }, callback: () => void) => { + watchCallbacks.push(callback); + return createWatcher() as any; + }, + ); + const service = createService({ watch }); + const refreshSpy = vi.spyOn(service as any, "scheduleWorkspaceRefresh"); + const listener = vi.fn(); + + const subscription = await service.requestWorkingTreeWatch("/tmp/repo", listener); + expect(watchCallbacks).toHaveLength(2); + + watchCallbacks[0]?.(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(refreshSpy).toHaveBeenCalledWith("/tmp/repo"); + + subscription.unsubscribe(); + service.dispose(); + }); }); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 62508470a..2adf7d315 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -1,5 +1,5 @@ import { watch, type FSWatcher } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { readFile, readdir } from "node:fs/promises"; import { join, resolve } from "node:path"; import type pino from "pino"; import type { CheckoutContext } from "../utils/checkout-git.js"; @@ -12,10 +12,12 @@ import { resolveAbsoluteGitDir, } from "../utils/checkout-git.js"; import { runGitCommand } from "../utils/run-git-command.js"; +import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; import { normalizeWorkspaceId } from "./workspace-registry-model.js"; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; +const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000; export type WorkspaceGitRuntimeSnapshot = { cwd: string; @@ -59,6 +61,11 @@ export interface WorkspaceGitService { peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null; getSnapshot(cwd: string): Promise; refresh(cwd: string, options?: { priority?: "normal" | "high" }): Promise; + requestWorkingTreeWatch( + cwd: string, + onChange: () => void, + ): Promise<{ repoRoot: string | null; unsubscribe: () => void }>; + scheduleRefreshForCwd(cwd: string): void; dispose(): void; } @@ -66,6 +73,7 @@ export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => vo interface WorkspaceGitServiceDependencies { watch: typeof watch; + readdir: typeof readdir; getCheckoutStatus: typeof getCheckoutStatus; getCheckoutShortstat: typeof getCheckoutShortstat; getPullRequestStatus: typeof getPullRequestStatus; @@ -73,6 +81,7 @@ interface WorkspaceGitServiceDependencies { resolveAbsoluteGitDir: (cwd: string) => Promise; hasOriginRemote: (cwd: string) => Promise; runGitFetch: (cwd: string) => Promise; + runGitCommand: typeof runGitCommand; now: () => Date; } @@ -102,6 +111,18 @@ interface RepoGitTarget { fetchInFlight: boolean; } +interface WorkingTreeWatchTarget { + cwd: string; + repoRoot: string | null; + repoWatchPath: string | null; + watchers: FSWatcher[]; + watchedPaths: Set; + fallbackRefreshInterval: NodeJS.Timeout | null; + linuxTreeRefreshPromise: Promise | null; + linuxTreeRefreshQueued: boolean; + listeners: Set<() => void>; +} + export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly logger: pino.Logger; private readonly paseoHome: string; @@ -109,12 +130,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly workspaceTargets = new Map(); private readonly repoTargets = new Map(); private readonly workspaceTargetSetups = new Map>(); + private readonly workingTreeWatchTargets = new Map(); + private readonly workingTreeWatchSetups = new Map>(); constructor(options: WorkspaceGitServiceOptions) { this.logger = options.logger.child({ module: "workspace-git-service" }); this.paseoHome = options.paseoHome; this.deps = { - watch, + watch: options.deps?.watch ?? watch, + readdir: options.deps?.readdir ?? readdir, getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus, getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat, getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus, @@ -122,6 +146,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir, hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote, runGitFetch: options.deps?.runGitFetch ?? runGitFetch, + runGitCommand: options.deps?.runGitCommand ?? runGitCommand, now: options.deps?.now ?? (() => new Date()), }; } @@ -151,7 +176,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (target?.latestSnapshot) { return target.latestSnapshot; } - return this.refreshSnapshot(cwd); + + const ensuredTarget = await this.ensureWorkspaceTarget(cwd); + return ensuredTarget.latestSnapshot ?? (await this.refreshSnapshot(cwd)); } peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null { @@ -170,17 +197,47 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { await this.ensureWorkspaceTarget(cwd); } + async requestWorkingTreeWatch( + cwd: string, + onChange: () => void, + ): Promise<{ repoRoot: string | null; unsubscribe: () => void }> { + cwd = normalizeWorkspaceId(cwd); + const target = await this.ensureWorkingTreeWatchTarget(cwd); + target.listeners.add(onChange); + + return { + repoRoot: target.repoRoot, + unsubscribe: () => { + this.removeWorkingTreeWatchListener(cwd, onChange); + }, + }; + } + + scheduleRefreshForCwd(cwd: string): void { + cwd = normalizeWorkspaceId(cwd); + const target = this.workspaceTargets.get(cwd); + if (target) { + this.scheduleWorkspaceRefresh(target); + } + } + dispose(): void { for (const target of this.workspaceTargets.values()) { this.closeWorkspaceTarget(target); } this.workspaceTargets.clear(); + this.workspaceTargetSetups.clear(); for (const target of this.repoTargets.values()) { this.closeRepoTarget(target); } this.repoTargets.clear(); - this.workspaceTargetSetups.clear(); + + for (const target of this.workingTreeWatchTargets.values()) { + this.closeWorkingTreeWatchTarget(target); + } + this.workingTreeWatchTargets.clear(); + this.workingTreeWatchSetups.clear(); } private async ensureWorkspaceTarget(cwd: string): Promise { @@ -201,6 +258,24 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return setup; } + private async ensureWorkingTreeWatchTarget(cwd: string): Promise { + const existingTarget = this.workingTreeWatchTargets.get(cwd); + if (existingTarget) { + return existingTarget; + } + + const existingSetup = this.workingTreeWatchSetups.get(cwd); + if (existingSetup) { + return existingSetup; + } + + const setup = this.createWorkingTreeWatchTarget(cwd).finally(() => { + this.workingTreeWatchSetups.delete(cwd); + }); + this.workingTreeWatchSetups.set(cwd, setup); + return setup; + } + private async createWorkspaceTarget(cwd: string): Promise { const target: WorkspaceGitTarget = { cwd, @@ -230,6 +305,83 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return target; } + private async createWorkingTreeWatchTarget(cwd: string): Promise { + const repoRoot = await this.resolveCheckoutWatchRoot(cwd); + const target: WorkingTreeWatchTarget = { + cwd, + repoRoot, + repoWatchPath: null, + watchers: [], + watchedPaths: new Set(), + fallbackRefreshInterval: null, + linuxTreeRefreshPromise: null, + linuxTreeRefreshQueued: false, + listeners: new Set(), + }; + + const repoWatchPath = repoRoot ?? cwd; + target.repoWatchPath = repoWatchPath; + const watchPaths = new Set([repoWatchPath]); + const gitDir = await this.deps.resolveAbsoluteGitDir(cwd); + if (gitDir) { + watchPaths.add(gitDir); + } + + let hasRecursiveRepoCoverage = false; + const allowRecursiveRepoWatch = process.platform !== "linux"; + if (process.platform === "linux") { + hasRecursiveRepoCoverage = await this.ensureLinuxRepoTreeWatchers(target, repoWatchPath); + } + for (const watchPath of watchPaths) { + if (process.platform === "linux" && watchPath === repoWatchPath) { + continue; + } + const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch; + const watcherIsRecursive = this.addWorkingTreeWatcher(target, watchPath, shouldTryRecursive); + if (watchPath === repoWatchPath && watcherIsRecursive) { + hasRecursiveRepoCoverage = true; + } + } + + const missingRepoCoverage = repoRoot === null || !hasRecursiveRepoCoverage; + if (target.watchers.length === 0 || missingRepoCoverage) { + target.fallbackRefreshInterval = setInterval(() => { + this.scheduleWorkspaceRefresh(cwd); + for (const listener of target.listeners) { + listener(); + } + }, WORKING_TREE_WATCH_FALLBACK_REFRESH_MS); + this.logger.warn( + { + cwd, + intervalMs: WORKING_TREE_WATCH_FALLBACK_REFRESH_MS, + reason: + target.watchers.length === 0 ? "no_watchers" : "missing_recursive_repo_root_coverage", + }, + "Working tree watchers unavailable; using timed refresh fallback", + ); + } + + this.workingTreeWatchTargets.set(cwd, target); + return target; + } + + private async resolveCheckoutWatchRoot(cwd: string): Promise { + try { + const { stdout } = await this.deps.runGitCommand( + ["rev-parse", "--path-format=absolute", "--show-toplevel"], + { + cwd, + env: READ_ONLY_GIT_ENV, + }, + ); + const root = stdout.trim(); + return root.length > 0 ? root : null; + } catch { + return null; + } + } + private async resolveWorkspaceGitRefsRoot(gitDir: string): Promise { try { const commonDir = (await readFile(join(gitDir, "commondir"), "utf8")).trim(); @@ -308,7 +460,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { void this.runRepoFetch(repoTarget); } - private scheduleWorkspaceRefresh(target: WorkspaceGitTarget): void { + private scheduleWorkspaceRefresh(targetOrCwd: WorkspaceGitTarget | string): void { + const target = + typeof targetOrCwd === "string" + ? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd)) + : targetOrCwd; + if (!target) { + return; + } + if (target.debounceTimer) { clearTimeout(target.debounceTimer); } @@ -319,6 +479,149 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }, WORKSPACE_GIT_WATCH_DEBOUNCE_MS); } + private addWorkingTreeWatcher( + target: WorkingTreeWatchTarget, + watchPath: string, + shouldTryRecursive: boolean, + ): boolean { + if (target.watchedPaths.has(watchPath)) { + return false; + } + + const { cwd } = target; + const onChange = () => { + if (process.platform === "linux" && target.repoWatchPath) { + void this.refreshLinuxRepoTreeWatchers(target); + } + this.scheduleWorkspaceRefresh(cwd); + for (const listener of target.listeners) { + listener(); + } + }; + const createWatcher = (recursive: boolean): FSWatcher => + this.deps.watch(watchPath, { recursive }, () => { + onChange(); + }); + + let watcher: FSWatcher | null = null; + let watcherIsRecursive = false; + try { + if (shouldTryRecursive) { + watcher = createWatcher(true); + watcherIsRecursive = true; + } else { + watcher = createWatcher(false); + } + } catch (error) { + if (shouldTryRecursive) { + try { + watcher = createWatcher(false); + this.logger.warn( + { err: error, watchPath, cwd }, + "Working tree recursive watch unavailable; using non-recursive fallback", + ); + } catch (fallbackError) { + this.logger.warn( + { err: fallbackError, watchPath, cwd }, + "Failed to start working tree watcher", + ); + } + } else { + this.logger.warn({ err: error, watchPath, cwd }, "Failed to start working tree watcher"); + } + } + + if (!watcher) { + return false; + } + + watcher.on("error", (error) => { + this.logger.warn({ err: error, watchPath, cwd }, "Working tree watcher error"); + }); + target.watchers.push(watcher); + target.watchedPaths.add(watchPath); + return watcherIsRecursive; + } + + private async ensureLinuxRepoTreeWatchers( + target: WorkingTreeWatchTarget, + rootPath: string, + ): Promise { + const directories = await this.listLinuxWatchDirectories(rootPath); + let complete = true; + for (const directory of directories) { + const watcherWasRecursive = this.addWorkingTreeWatcher(target, directory, false); + if (!watcherWasRecursive && !target.watchedPaths.has(directory)) { + complete = false; + } + } + return complete && target.watchedPaths.has(rootPath); + } + + private async refreshLinuxRepoTreeWatchers(target: WorkingTreeWatchTarget): Promise { + if (process.platform !== "linux" || !target.repoWatchPath) { + return; + } + const rootPath = target.repoWatchPath; + if (target.linuxTreeRefreshPromise) { + target.linuxTreeRefreshQueued = true; + return; + } + + target.linuxTreeRefreshPromise = (async () => { + do { + target.linuxTreeRefreshQueued = false; + try { + await this.ensureLinuxRepoTreeWatchers(target, rootPath); + } catch (error) { + this.logger.warn( + { + err: error, + cwd: target.cwd, + rootPath, + }, + "Failed to refresh Linux working tree watchers", + ); + } + } while (target.linuxTreeRefreshQueued); + })(); + + try { + await target.linuxTreeRefreshPromise; + } finally { + target.linuxTreeRefreshPromise = null; + } + } + + private async listLinuxWatchDirectories(rootPath: string): Promise { + const directories: string[] = []; + const pending = [rootPath]; + + while (pending.length > 0) { + const directory = pending.pop(); + if (!directory) { + continue; + } + directories.push(directory); + + let entries; + try { + entries = await this.deps.readdir(directory, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === ".git") { + continue; + } + pending.push(join(directory, entry.name)); + } + } + + return directories; + } + private async refreshWorkspaceTarget(target: WorkspaceGitTarget): Promise { if (target.refreshPromise) { target.refreshQueued = true; @@ -435,6 +738,21 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.workspaceTargets.delete(target.cwd); } + private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void { + const target = this.workingTreeWatchTargets.get(cwd); + if (!target) { + return; + } + + target.listeners.delete(listener); + if (target.listeners.size > 0) { + return; + } + + this.closeWorkingTreeWatchTarget(target); + this.workingTreeWatchTargets.delete(cwd); + } + private closeWorkspaceTarget(target: WorkspaceGitTarget): void { if (target.debounceTimer) { clearTimeout(target.debounceTimer); @@ -448,6 +766,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.listeners.clear(); } + private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void { + if (target.fallbackRefreshInterval) { + clearInterval(target.fallbackRefreshInterval); + target.fallbackRefreshInterval = null; + } + + for (const watcher of target.watchers) { + watcher.close(); + } + target.watchers = []; + target.watchedPaths.clear(); + target.listeners.clear(); + } + private closeRepoTarget(target: RepoGitTarget): void { if (target.intervalId) { clearInterval(target.intervalId); diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts new file mode 100644 index 000000000..5f81aa3e2 --- /dev/null +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -0,0 +1,232 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../test-utils/test-logger.js"; +import { AgentStorage } from "./agent/agent-storage.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; +import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; +import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; + +function createNoopWorkspaceGitService(): WorkspaceGitService { + return { + subscribe: async (params) => ({ + initial: { + cwd: params.cwd, + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }, + unsubscribe: () => {}, + }), + peekSnapshot: () => null, + getSnapshot: async (cwd) => ({ + cwd, + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }), + refresh: async () => {}, + requestWorkingTreeWatch: async () => ({ + repoRoot: null, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + dispose: () => {}, + }; +} + +describe("bootstrapWorkspaceRegistries", () => { + let tmpDir: string; + let paseoHome: string; + let agentStorage: AgentStorage; + let projectRegistry: FileBackedProjectRegistry; + let workspaceRegistry: FileBackedWorkspaceRegistry; + let workspaceGitService: WorkspaceGitService; + const logger = createTestLogger(); + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-bootstrap-")); + paseoHome = path.join(tmpDir, ".paseo"); + agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger); + projectRegistry = new FileBackedProjectRegistry( + path.join(paseoHome, "projects", "projects.json"), + logger, + ); + workspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(paseoHome, "projects", "workspaces.json"), + logger, + ); + workspaceGitService = createNoopWorkspaceGitService(); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("materializes workspace registries from non-archived agent records", async () => { + await agentStorage.initialize(); + await agentStorage.upsert({ + id: "agent-1", + provider: "codex", + cwd: "/tmp/non-git-project", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + lastActivityAt: "2026-03-02T00:00:00.000Z", + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }); + await agentStorage.upsert({ + id: "agent-2", + provider: "codex", + cwd: "/tmp/non-git-project", + createdAt: "2026-03-01T01:00:00.000Z", + updatedAt: "2026-03-03T00:00:00.000Z", + lastActivityAt: "2026-03-03T00:00:00.000Z", + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "running", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }); + await agentStorage.upsert({ + id: "agent-archived", + provider: "codex", + cwd: "/tmp/archived-project", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + lastActivityAt: "2026-03-01T00:00:00.000Z", + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: "2026-03-02T00:00:00.000Z", + }); + + await bootstrapWorkspaceRegistries({ + paseoHome, + agentStorage, + projectRegistry, + workspaceRegistry, + workspaceGitService, + logger, + }); + + const workspaces = await workspaceRegistry.list(); + expect(workspaces).toHaveLength(1); + expect(workspaces[0]?.workspaceId).toBe("/tmp/non-git-project"); + expect(workspaces[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z"); + expect(workspaces[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z"); + + const projects = await projectRegistry.list(); + expect(projects).toHaveLength(1); + expect(projects[0]?.projectId).toBe("/tmp/non-git-project"); + expect(projects[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z"); + expect(projects[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z"); + }); + + test("does not rematerialize when registry files already exist", async () => { + await projectRegistry.initialize(); + await workspaceRegistry.initialize(); + await projectRegistry.upsert({ + projectId: "/tmp/existing", + rootPath: "/tmp/existing", + kind: "non_git", + displayName: "existing", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }); + await workspaceRegistry.upsert({ + workspaceId: "/tmp/existing", + projectId: "/tmp/existing", + cwd: "/tmp/existing", + kind: "directory", + displayName: "existing", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + }); + + await agentStorage.initialize(); + await agentStorage.upsert({ + id: "agent-1", + provider: "codex", + cwd: "/tmp/another-project", + createdAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + lastActivityAt: "2026-03-02T00:00:00.000Z", + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: "idle", + lastModeId: null, + config: null, + runtimeInfo: { provider: "codex", sessionId: null }, + persistence: null, + archivedAt: null, + }); + + await bootstrapWorkspaceRegistries({ + paseoHome, + agentStorage, + projectRegistry, + workspaceRegistry, + workspaceGitService, + logger, + }); + + expect(await projectRegistry.list()).toHaveLength(1); + expect(await workspaceRegistry.list()).toHaveLength(1); + expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("/tmp/existing"); + }); +}); diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index c9403f810..625334ffe 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -13,6 +13,7 @@ import { deriveWorkspaceKind, normalizeWorkspaceId, } from "./workspace-registry-model.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -53,6 +54,7 @@ export async function bootstrapWorkspaceRegistries(options: { agentStorage: AgentStorage; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; + workspaceGitService: WorkspaceGitService; logger: Logger; }): Promise { const [projectsExists, workspacesExists] = await Promise.all([ @@ -79,7 +81,7 @@ export async function bootstrapWorkspaceRegistries(options: { const normalizedCwd = normalizeWorkspaceId(record.cwd); const placement = await buildProjectPlacementForCwd({ cwd: normalizedCwd, - paseoHome: options.paseoHome, + workspaceGitService: options.workspaceGitService, }); const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout); const existing = recordsByWorkspaceId.get(workspaceId) ?? { placement, records: [] }; diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 545095e71..8bce33279 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -1,7 +1,7 @@ import { resolve } from "node:path"; -import { getCheckoutStatusLite } from "../utils/checkout-git.js"; import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; export type PersistedProjectKind = "git" | "non_git"; @@ -198,45 +198,15 @@ export async function detectStaleWorkspaces( export async function buildProjectPlacementForCwd(input: { cwd: string; - paseoHome: string; + workspaceGitService: WorkspaceGitService; }): Promise { const normalizedCwd = normalizeWorkspaceId(input.cwd); - const checkout = await getCheckoutStatusLite(normalizedCwd, { paseoHome: input.paseoHome }) - .then((status): ProjectCheckoutLitePayload => { - if (!status.isGit) { - return { - cwd: normalizedCwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; - } - - if (status.isPaseoOwnedWorktree && status.mainRepoRoot) { - return { - cwd: normalizedCwd, - isGit: true, - currentBranch: status.currentBranch, - remoteUrl: status.remoteUrl, - worktreeRoot: status.worktreeRoot, - isPaseoOwnedWorktree: true, - mainRepoRoot: status.mainRepoRoot, - }; - } - - return { - cwd: normalizedCwd, - isGit: true, - currentBranch: status.currentBranch, - remoteUrl: status.remoteUrl, - worktreeRoot: status.worktreeRoot, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; - }) + const checkout = await input.workspaceGitService + .getSnapshot(normalizedCwd) + .then( + (snapshot): ProjectCheckoutLitePayload => + checkoutLiteFromGitSnapshot(normalizedCwd, snapshot.git), + ) .catch( (): ProjectCheckoutLitePayload => ({ cwd: normalizedCwd, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index a020aaf0b..2c91c2e1b 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -17,10 +17,12 @@ import { } from "./messages.js"; import { findGitHubPrAttachment } from "./agent/prompt-attachments.js"; import type { + PersistedProjectRecord, PersistedWorkspaceRecord, ProjectRegistry, WorkspaceRegistry, } from "./workspace-registry.js"; +import type { WorkspaceGitService } from "./workspace-git-service.js"; import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js"; import { applyWorktreeSetupProgressEvent, @@ -33,7 +35,11 @@ import { import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { ScriptRouteStore } from "./script-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; -import { getCheckoutStatusLite, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js"; +import { + getCheckoutStatus, + getCurrentBranch, + resolveRepositoryDefaultBranch, +} from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import { computeWorktreePath, @@ -51,8 +57,7 @@ import { WorktreeSetupError, } from "../utils/worktree.js"; import { writePaseoWorktreeMetadata } from "../utils/worktree-metadata.js"; -import { runGitCommand } from "../utils/run-git-command.js"; -import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js"; +import { toCheckoutError } from "./checkout-git-utils.js"; const execFileAsync = promisify(execFile); const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; @@ -70,6 +75,7 @@ type EmitSessionMessage = (message: SessionOutboundMessage) => void; type BuildAgentSessionConfigDependencies = { paseoHome?: string; sessionLogger: Logger; + workspaceGitService?: WorkspaceGitService; checkoutExistingBranch: (cwd: string, branch: string) => Promise; createBranchFromBase: (params: { cwd: string; @@ -90,6 +96,18 @@ type ArchivePaseoWorktreeDependencies = { }; type RegisterPendingWorktreeWorkspaceDependencies = { + buildPersistedProjectRecord: (input: { + workspaceId: string; + placement: ProjectPlacementPayload; + createdAt: string; + updatedAt: string; + }) => PersistedProjectRecord; + buildPersistedWorkspaceRecord: (input: { + workspaceId: string; + placement: ProjectPlacementPayload; + createdAt: string; + updatedAt: string; + }) => PersistedWorkspaceRecord; buildProjectPlacement: (cwd: string) => Promise; findWorkspaceByDirectory: (directory: string) => Promise; projectRegistry: Pick; @@ -121,6 +139,7 @@ type HandleWorkspaceSetupStatusRequestDependencies = { type HandleCreatePaseoWorktreeRequestDependencies = { paseoHome?: string; + workspaceGitService?: WorkspaceGitService; describeWorkspaceRecord: ( workspace: PersistedWorkspaceRecord, ) => Promise; @@ -130,6 +149,7 @@ type HandleCreatePaseoWorktreeRequestDependencies = { worktreePath: string; branchName: string; }) => Promise; + syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise; sessionLogger: Logger; runWorktreeSetupInBackground: (options: { requestCwd: string; @@ -178,7 +198,11 @@ export async function buildAgentSessionConfig( const baseBranch = githubPrAttachment.baseRefName?.trim() || normalized.baseBranch || - (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome)); + (await resolveGitCreateBaseBranch( + cwd, + dependencies.workspaceGitService, + dependencies.paseoHome, + )); const worktreeSlug = normalized.worktreeSlug ?? slugify(resolveGitHubPrBranchName(githubPrAttachment)); const createdWorktree = await createGitHubPrWorktree({ @@ -208,11 +232,7 @@ export async function buildAgentSessionConfig( if (normalized.createNewBranch) { targetBranch = normalized.newBranchName!; } else { - const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { - cwd, - env: READ_ONLY_GIT_ENV, - }); - targetBranch = stdout.trim(); + targetBranch = (await getCurrentBranch(cwd)) ?? ""; } if (!targetBranch) { @@ -225,7 +245,12 @@ export async function buildAgentSessionConfig( ); const baseBranch = - normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome)); + normalized.baseBranch ?? + (await resolveGitCreateBaseBranch( + cwd, + dependencies.workspaceGitService, + dependencies.paseoHome, + )); const createdWorktree = await createAgentWorktree({ branchName: targetBranch, cwd, @@ -237,7 +262,12 @@ export async function buildAgentSessionConfig( worktreeBootstrap = createdWorktree; } else if (normalized.createNewBranch) { const baseBranch = - normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome)); + normalized.baseBranch ?? + (await resolveGitCreateBaseBranch( + cwd, + dependencies.workspaceGitService, + dependencies.paseoHome, + )); await dependencies.createBranchFromBase({ cwd, baseBranch, @@ -322,13 +352,31 @@ export function assertSafeGitRef(ref: string, label: string): void { } } -export async function resolveGitCreateBaseBranch(cwd: string, paseoHome?: string): Promise { - const checkout = await getCheckoutStatusLite(cwd, { paseoHome }); - if (!checkout.isGit) { - throw new Error("Cannot create a worktree outside a git repository"); - } +export async function resolveGitCreateBaseBranch( + cwd: string, + workspaceGitService?: WorkspaceGitService, + paseoHome?: string, +): Promise { + let repoRoot = cwd; + if (workspaceGitService) { + const snapshot = await workspaceGitService.getSnapshot(cwd); + if (!snapshot.git.isGit) { + throw new Error("Cannot create a worktree outside a git repository"); + } - const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd; + repoRoot = snapshot.git.isPaseoOwnedWorktree + ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? cwd) + : (snapshot.git.repoRoot ?? cwd); + } else { + const checkout = await getCheckoutStatus(cwd, paseoHome ? { paseoHome } : undefined); + if (!checkout.isGit) { + throw new Error("Cannot create a worktree outside a git repository"); + } + + repoRoot = checkout.isPaseoOwnedWorktree + ? (checkout.mainRepoRoot ?? checkout.repoRoot ?? cwd) + : (checkout.repoRoot ?? cwd); + } const baseBranch = await resolveRepositoryDefaultBranch(repoRoot); if (!baseBranch) { throw new Error("Unable to resolve repository default branch"); @@ -603,46 +651,47 @@ export async function registerPendingWorktreeWorkspace( ): Promise { const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath); const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot); - const projectId = basePlacement.projectKey; + const placement: ProjectPlacementPayload = { + ...basePlacement, + checkout: { + cwd: workspaceDirectory, + isGit: true, + currentBranch: options.branchName, + remoteUrl: basePlacement.checkout.remoteUrl, + worktreeRoot: options.worktreePath, + isPaseoOwnedWorktree: true, + mainRepoRoot: options.repoRoot, + }, + }; const now = new Date().toISOString(); const existingWorkspace = await dependencies.findWorkspaceByDirectory(workspaceDirectory); - if (!existingWorkspace) { - const newRecord: import("./workspace-registry.js").PersistedWorkspaceRecord = { - workspaceId: workspaceDirectory, - projectId, - cwd: workspaceDirectory, - displayName: options.branchName, - kind: "worktree", - createdAt: now, - updatedAt: now, - archivedAt: null, - }; - await dependencies.workspaceRegistry.upsert(newRecord); - const workspace = await dependencies.workspaceRegistry.get(workspaceDirectory); - if (!workspace) { - throw new Error(`Workspace not found after upsert: ${workspaceDirectory}`); - } - await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true }); - return workspace; - } - - await dependencies.workspaceRegistry.upsert({ - workspaceId: existingWorkspace.workspaceId, - projectId, - cwd: workspaceDirectory, - displayName: options.branchName, - kind: "worktree", - createdAt: existingWorkspace.createdAt, + const existingProject = await dependencies.projectRegistry.get(placement.projectKey); + const nextProjectRecord = dependencies.buildPersistedProjectRecord({ + workspaceId: workspaceDirectory, + placement, + createdAt: existingProject?.createdAt ?? now, updatedAt: now, - archivedAt: null, }); + const nextWorkspaceRecord = dependencies.buildPersistedWorkspaceRecord({ + workspaceId: workspaceDirectory, + placement, + createdAt: existingWorkspace?.createdAt ?? now, + updatedAt: now, + }); + + await dependencies.projectRegistry.upsert(nextProjectRecord); + await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord); await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true }); - if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) { + if ( + existingWorkspace && + !existingWorkspace.archivedAt && + existingWorkspace.projectId !== nextWorkspaceRecord.projectId + ) { await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now); } - return (await dependencies.workspaceRegistry.get(existingWorkspace.workspaceId))!; + return (await dependencies.workspaceRegistry.get(nextWorkspaceRecord.workspaceId))!; } export async function handleCreatePaseoWorktreeRequest( @@ -650,14 +699,29 @@ export async function handleCreatePaseoWorktreeRequest( request: Extract, ): Promise { try { - const checkout = await getCheckoutStatusLite(request.cwd, { - paseoHome: dependencies.paseoHome, - }); - if (!checkout.isGit) { - throw new Error("Create worktree requires a git repository"); - } + let repoRoot = request.cwd; + if (dependencies.workspaceGitService) { + const snapshot = await dependencies.workspaceGitService.getSnapshot(request.cwd); + if (!snapshot.git.isGit) { + throw new Error("Create worktree requires a git repository"); + } - const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd; + repoRoot = snapshot.git.isPaseoOwnedWorktree + ? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? request.cwd) + : (snapshot.git.repoRoot ?? request.cwd); + } else { + const checkout = await getCheckoutStatus( + request.cwd, + dependencies.paseoHome ? { paseoHome: dependencies.paseoHome } : undefined, + ); + if (!checkout.isGit) { + throw new Error("Create worktree requires a git repository"); + } + + repoRoot = checkout.isPaseoOwnedWorktree + ? (checkout.mainRepoRoot ?? checkout.repoRoot ?? request.cwd) + : (checkout.repoRoot ?? request.cwd); + } const baseBranch = await resolveRepositoryDefaultBranch(repoRoot); if (!baseBranch) { throw new Error("Unable to resolve repository default branch"); diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 136748676..9b2f2ac27 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -20,11 +20,11 @@ import { __setPullRequestStatusCacheTtlForTests, commitAll, getCachedCheckoutShortstat, + getCurrentBranch, getCheckoutDiff, getCheckoutShortstat, getPullRequestStatus, getCheckoutStatus, - getCheckoutStatusLite, listBranchSuggestions, mergeToBase, mergeFromBase, @@ -91,6 +91,15 @@ describe("checkout git utilities", () => { ); }); + it("returns null for getCurrentBranch in a repo with no commits", async () => { + const emptyRepo = join(tempDir, "empty-repo"); + execSync(`mkdir -p ${emptyRepo}`); + execSync("git init -b main", { cwd: emptyRepo }); + + const branch = await getCurrentBranch(emptyRepo); + expect(branch).toBeNull(); + }); + it("handles status/diff/commit in a normal repo", async () => { writeFileSync(join(repoDir, "file.txt"), "updated\n"); @@ -170,13 +179,16 @@ const x = 1; expect(removedLine?.tokens).toEqual([{ text: "old comment line", style: "comment" }]); }); - it("returns lightweight checkout status for normal repos", async () => { - const status = await getCheckoutStatusLite(repoDir); + it("returns checkout root metadata for normal repos", async () => { + const status = await getCheckoutStatus(repoDir); expect(status.isGit).toBe(true); + if (!status.isGit) { + return; + } expect(status.currentBranch).toBe("main"); - expect(status.worktreeRoot).toBe(repoDir); + expect(status.repoRoot).toBe(repoDir); expect(status.isPaseoOwnedWorktree).toBe(false); - expect(status.mainRepoRoot).toBeNull(); + expect(status.mainRepoRoot ?? null).toBeNull(); }); it("exposes hasRemote when origin is configured", async () => { @@ -404,7 +416,7 @@ const x = 1; expect(message).toBe("worktree update"); }); - it("returns lightweight checkout status for .paseo worktrees", async () => { + it("returns checkout root metadata for .paseo worktrees", async () => { const result = await createWorktree({ branchName: "main", cwd: repoDir, @@ -413,9 +425,12 @@ const x = 1; paseoHome, }); - const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome }); + const status = await getCheckoutStatus(result.worktreePath, { paseoHome }); expect(status.isGit).toBe(true); - expect(status.worktreeRoot).toBe(result.worktreePath); + if (!status.isGit) { + return; + } + expect(status.repoRoot).toBe(result.worktreePath); expect(status.isPaseoOwnedWorktree).toBe(true); expect(status.mainRepoRoot).toBe(repoDir); }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 2ea834458..1452b14e8 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -516,38 +516,6 @@ export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPas export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit; -export type CheckoutStatusLiteNotGit = { - isGit: false; - currentBranch: null; - remoteUrl: null; - worktreeRoot: null; - isPaseoOwnedWorktree: false; - mainRepoRoot: null; -}; - -export type CheckoutStatusLiteGitNonPaseo = { - isGit: true; - currentBranch: string | null; - remoteUrl: string | null; - worktreeRoot: string; - isPaseoOwnedWorktree: false; - mainRepoRoot: null; -}; - -export type CheckoutStatusLiteGitPaseo = { - isGit: true; - currentBranch: string | null; - remoteUrl: string | null; - worktreeRoot: string; - isPaseoOwnedWorktree: true; - mainRepoRoot: string; -}; - -export type CheckoutStatusLiteResult = - | CheckoutStatusLiteNotGit - | CheckoutStatusLiteGitNonPaseo - | CheckoutStatusLiteGitPaseo; - export interface CheckoutDiffResult { diff: string; structured?: ParsedDiffFile[]; @@ -591,12 +559,16 @@ async function requireGitRepo(cwd: string): Promise { } export async function getCurrentBranch(cwd: string): Promise { - const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { - cwd, - env: READ_ONLY_GIT_ENV, - }); - const branch = stdout.trim(); - return branch.length > 0 ? branch : null; + try { + const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { + cwd, + env: READ_ONLY_GIT_ENV, + }); + const branch = stdout.trim(); + return branch.length > 0 ? branch : null; + } catch { + return null; + } } async function getWorktreeRoot(cwd: string): Promise { @@ -1190,43 +1162,6 @@ export async function getCheckoutStatus( }; } -export async function getCheckoutStatusLite( - cwd: string, - context?: CheckoutContext, -): Promise { - const inspected = await inspectCheckoutContext(cwd, context); - if (!inspected) { - return { - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; - } - - if (inspected.configured.isPaseoOwnedWorktree) { - return { - isGit: true, - currentBranch: inspected.currentBranch, - remoteUrl: inspected.remoteUrl, - worktreeRoot: inspected.worktreeRoot, - isPaseoOwnedWorktree: true, - mainRepoRoot: await getMainRepoRoot(cwd), - }; - } - - return { - isGit: true, - currentBranch: inspected.currentBranch, - remoteUrl: inspected.remoteUrl, - worktreeRoot: inspected.worktreeRoot, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }; -} - export interface CheckoutShortstat { additions: number; deletions: number; diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index a332dbac5..c4bf466d5 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -21,6 +21,6 @@ export default defineConfig({ maxForks: 1, }, }, - exclude: ["**/node_modules/**", "**/dist/**"], + exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**"], }, }); diff --git a/packages/website/package.json b/packages/website/package.json index 186ba4832..055c9800c 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.55-rc.2", + "version": "0.1.56", "private": true, "type": "module", "scripts": { diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index a4e12119a..c9b74032a 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -14,7 +14,22 @@ "listen": { "type": "string" }, + "hostnames": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, "allowedHosts": { + "description": "Deprecated: use hostnames instead", "anyOf": [ { "type": "boolean", diff --git a/packages/website/src/routes/docs/configuration.tsx b/packages/website/src/routes/docs/configuration.tsx index 1aac1f2a6..368ae6157 100644 --- a/packages/website/src/routes/docs/configuration.tsx +++ b/packages/website/src/routes/docs/configuration.tsx @@ -51,8 +51,7 @@ function Configuration() {
  • CLI flags
  • - Lists append across sources (for example, allowedHosts{" "} - and + Lists append across sources (for example, hostnames and cors.allowedOrigins).

    @@ -60,7 +59,7 @@ function Configuration() {

    Example

    - Minimal example that configures listening address, host allowlist, provider keys, and MCP: + Minimal example that configures listening address, hostnames, provider keys, and MCP:

               {`{
    @@ -71,11 +70,16 @@ function Configuration() {
       },
       "daemon": {
         "listen": "127.0.0.1:6767",
    -    "allowedHosts": ["localhost", ".localhost"],
    +    "hostnames": ["localhost", ".localhost"],
         "mcp": { "enabled": true }
       }
     }`}
             
    +

    + daemon.hostnames is the primary field. The old{" "} + daemon.allowedHosts name still works as a deprecated + alias for backward compatibility. +

    @@ -229,8 +233,12 @@ docker run --rm -i \\ daemon.listen
  • - PASEO_ALLOWED_HOSTS — override/extend{" "} - daemon.allowedHosts + PASEO_HOSTNAMES — override/extend{" "} + daemon.hostnames +
  • +
  • + PASEO_ALLOWED_HOSTS — deprecated alias for{" "} + PASEO_HOSTNAMES
  • PASEO_LOG_CONSOLE_LEVEL — override{" "} diff --git a/packages/website/src/routes/docs/security.tsx b/packages/website/src/routes/docs/security.tsx index d148331cf..5b0d03f78 100644 --- a/packages/website/src/routes/docs/security.tsx +++ b/packages/website/src/routes/docs/security.tsx @@ -185,7 +185,7 @@ function Security() { 100.x.y.z:6767)
  • - Add your Tailscale hostname to allowedHosts and{" "} + Add your Tailscale hostname to hostnames and{" "} cors.allowedOrigins
  • @@ -199,7 +199,7 @@ function Security() { daemon reachable on all network interfaces, including public Wi-Fi and local networks. This can expose your daemon to unauthorized access. If you must bind to all interfaces, ensure you have proper firewall rules and review your{" "} - allowedHosts configuration. + hostnames configuration.
  • @@ -216,7 +216,7 @@ function Security() { on incoming requests. Requests with unrecognized hosts are rejected.

    - Configure via daemon.allowedHosts in{" "} + Configure via daemon.hostnames in{" "} config.json:

      diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 new file mode 100644 index 000000000..d86811c82 --- /dev/null +++ b/scripts/dev.ps1 @@ -0,0 +1,65 @@ +$ErrorActionPreference = "Stop" + +# Ensure node_modules/.bin is in PATH +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$env:PATH = "$ScriptDir\..\node_modules\.bin;$env:PATH" + +# Derive PASEO_HOME: stable name for worktrees, temporary dir otherwise +if (-not $env:PASEO_HOME) { + $GitDir = git rev-parse --git-dir 2>$null + $GitCommonDir = git rev-parse --git-common-dir 2>$null + + if ($GitDir -and $GitCommonDir -and ($GitDir -ne $GitCommonDir)) { + # Inside a worktree — derive a stable home from the worktree name + $WorktreeRoot = git rev-parse --show-toplevel + $WorktreeName = (Split-Path -Leaf $WorktreeRoot).ToLower() -replace '[^a-z0-9-]', '-' -replace '-+', '-' -replace '^-|-$', '' + $env:PASEO_HOME = "$env:USERPROFILE\.paseo-$WorktreeName" + New-Item -ItemType Directory -Force -Path $env:PASEO_HOME | Out-Null + } else { + $env:PASEO_HOME = Join-Path ([System.IO.Path]::GetTempPath()) "paseo-dev-$([System.Guid]::NewGuid().ToString('N').Substring(0,6))" + New-Item -ItemType Directory -Force -Path $env:PASEO_HOME | Out-Null + # Register cleanup on exit + $TempPaseoHome = $env:PASEO_HOME + Register-EngineEvent PowerShell.Exiting -Action { + Remove-Item -Recurse -Force $TempPaseoHome -ErrorAction SilentlyContinue + } | Out-Null + } +} + +# Share speech models with the main install to avoid duplicate downloads +if (-not $env:PASEO_LOCAL_MODELS_DIR) { + $env:PASEO_LOCAL_MODELS_DIR = "$env:USERPROFILE\.paseo\models\local-speech" + New-Item -ItemType Directory -Force -Path $env:PASEO_LOCAL_MODELS_DIR | Out-Null +} + +Write-Host @" +====================================================== + Paseo Dev (Windows) +====================================================== + Home: $($env:PASEO_HOME) + Models: $($env:PASEO_LOCAL_MODELS_DIR) + Daemon: localhost:6767 +====================================================== +"@ + +# Allow any origin in dev so Electron on random ports all work. +# SECURITY: wildcard CORS is unsafe in production — only acceptable here because +# the daemon binds to localhost and this script is never used for production. +# Build dependencies required by the daemon (they only ship dist/) +Write-Host "Building @getpaseo/highlight..." +npm run build --workspace=@getpaseo/highlight +Write-Host "Building @getpaseo/relay..." +npm run build --workspace=@getpaseo/relay + +$env:PASEO_CORS_ORIGINS = "*" + +# Configure the app to auto-connect to this daemon on localhost +$env:EXPO_PUBLIC_LOCAL_DAEMON = "localhost:6767" +$env:BROWSER = "none" + +# Run both with concurrently +concurrently ` + --names "daemon,metro" ` + --prefix-colors "cyan,magenta" ` + "npm run dev:server" ` + "cd packages/app && npx expo start" diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..c39f1daeb --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["**/.claude/**"], + }, +});