diff --git a/docs/architecture.md b/docs/architecture.md index e628dbbaa..c16cafdec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -132,6 +132,12 @@ Electron wrapper for macOS, Linux, and Windows. - Native file access for workspace integration - Same WebSocket client as mobile app +**Multi-window (hybrid land-on model).** `createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents` `PendingOpenProjectStore`: each window pulls its own pending project path on mount (`paseo:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `paseo ` launch. + +> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys. +> +> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up. + ### `packages/website` — Marketing site TanStack Router + Cloudflare Workers. Serves paseo.sh. diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 5ae98afb2..69ed91212 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -577,7 +577,7 @@ Each entry in the `models` array: The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`. -This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. `agents.providers.claude.models` is still supported and is additive for the built-in Claude provider; duplicate IDs are de-duplicated. +This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top. ### Gotcha: `extends: "claude"` with third-party endpoints diff --git a/packages/app/src/assistant-file-links/index.ts b/packages/app/src/assistant-file-links/index.ts index 0a0e19207..723945409 100644 --- a/packages/app/src/assistant-file-links/index.ts +++ b/packages/app/src/assistant-file-links/index.ts @@ -3,6 +3,7 @@ export { AssistantMarkdownCodeLink, AssistantMarkdownLink, } from "./link"; +export { type AssistantLinkPress, useAssistantLinkPress } from "./link-press-context"; export { classifyAssistantFileLink, normalizeInlinePathTarget, diff --git a/packages/app/src/assistant-file-links/link-press-context.ts b/packages/app/src/assistant-file-links/link-press-context.ts new file mode 100644 index 000000000..2d6ae1963 --- /dev/null +++ b/packages/app/src/assistant-file-links/link-press-context.ts @@ -0,0 +1,24 @@ +import { createContext, useContext } from "react"; +import type { TextProps } from "react-native"; + +// Carries a link's press handler down to the leaf text spans that render its +// label. On iOS an assistant link is a nested UITextView span, and +// react-native-uitextview only attaches onPress to the *string* children it +// converts into RNUITextViewChild nodes (src/Text.tsx) — element children (the +// MarkdownInheritedText spans markdown produces for link text) pass through +// untouched, so an onPress placed on the wrapping span never reaches a tappable +// native node. Threading the handler through context lets each leaf span hand +// onPress to its own string children, where the native tap recognizer can find +// it. Provided only on iOS (Android/web links tap fine via their own paths). +export interface AssistantLinkPress { + onPress: () => void; + accessibilityRole?: TextProps["accessibilityRole"]; +} + +const AssistantLinkPressContext = createContext(null); + +export const AssistantLinkPressProvider = AssistantLinkPressContext.Provider; + +export function useAssistantLinkPress(): AssistantLinkPress | null { + return useContext(AssistantLinkPressContext); +} diff --git a/packages/app/src/assistant-file-links/link.tsx b/packages/app/src/assistant-file-links/link.tsx index 4c19322a6..d209411ac 100644 --- a/packages/app/src/assistant-file-links/link.tsx +++ b/packages/app/src/assistant-file-links/link.tsx @@ -1,5 +1,6 @@ import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"; import { + Platform, Pressable, Text, View, @@ -10,6 +11,7 @@ import { import { StyleSheet } from "react-native-unistyles"; import { isNative, isWeb } from "@/constants/platform"; import { MarkdownTextSpan } from "@/components/markdown-text"; +import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useStableEvent } from "@/hooks/use-stable-event"; @@ -56,23 +58,42 @@ export function AssistantMarkdownLink({ () => [style, hovered && { textDecorationLine: "underline" as const }], [style, hovered], ); + const linkPress = useMemo( + () => ({ onPress, accessibilityRole: "link" }), + [onPress], + ); if (isNative) { // Must be a MarkdownTextSpan, not a plain : on iOS the link renders // inside the paragraph's native UITextView, and a plain nested there // is not hoisted into a UITextViewChild, so its text is silently dropped - // (the link disappears). The span composes correctly and stays selectable; - // onPress is forwarded (reliable tap-to-open on iOS is tracked by #21). + // (the link disappears). The span composes correctly and stays selectable. + // + // Tap-to-open: react-native-uitextview only wires onPress onto the *string* + // children it turns into RNUITextViewChild nodes — the element children that + // markdown emits for link text pass through untouched, so an onPress placed + // here never reaches a tappable native node. We thread it down through + // AssistantLinkPressProvider so each leaf text span re-attaches it to its + // own string children, where the native tap recognizer can find it. iOS + // only: Android forwards onPress through nested already, and web uses + // the path below. + const span = ( + + {children} + + ); return ( - - {children} - + {Platform.OS === "ios" ? ( + {span} + ) : ( + span + )} ); } diff --git a/packages/app/src/components/markdown-text.ios.tsx b/packages/app/src/components/markdown-text.ios.tsx index 8d50abbda..1e8d67da1 100644 --- a/packages/app/src/components/markdown-text.ios.tsx +++ b/packages/app/src/components/markdown-text.ios.tsx @@ -9,10 +9,11 @@ interface MarkdownTextSpanProps { children: ReactNode; // Links route through this span too (see assistant-file-links/link.tsx). A // plain nested in the paragraph UITextView is dropped, so the link - // must be a UITextView span to be visible. onPress is forwarded best-effort: - // react-native-uitextview nulls onPress on the root native view, so reliable - // tap-to-open is still tracked by #21 — but visible+selectable text beats an - // invisible link. + // must be a UITextView span to be visible. onPress is wired onto the leaf + // string children here: react-native-uitextview attaches it to the + // RNUITextViewChild nodes it builds from string content, which the native tap + // recognizer dispatches to. The link's handler reaches these leaf spans via + // AssistantLinkPressProvider (see assistant-file-links/link-press-context). onPress?: TextProps["onPress"]; accessibilityRole?: TextProps["accessibilityRole"]; } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index ce2c738f2..4d986a41d 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -100,6 +100,7 @@ import { AssistantMarkdownLink, type InlinePathTarget, useAssistantFileLinkActions, + useAssistantLinkPress, } from "@/assistant-file-links"; import { getCompactionMarkerLabel } from "./message-compaction-label"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; @@ -1532,8 +1533,19 @@ function MarkdownInheritedText({ () => [inheritedStyles, textStyle, overrideStyle], [inheritedStyles, textStyle, overrideStyle], ); + // When this span renders link label text on iOS, pick up the link's press + // handler from context and hand it to MarkdownTextSpan, which forwards it to + // the leaf string children react-native-uitextview makes tappable. Null + // outside a link (and on every other platform, where no provider mounts), so + // ordinary text is unaffected. See assistant-file-links/link-press-context. + const linkPress = useAssistantLinkPress(); return ( - + {children} ); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index f1fad57fc..40cc83cb5 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -128,7 +128,12 @@ import { archiveWorkspaceOptimistically, archiveWorkspacesOptimistically, } from "@/workspace/workspace-archive"; -import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; +import { + isWeb as platformIsWeb, + isNative as platformIsNative, + getIsElectron, +} from "@/constants/platform"; +import { getDesktopHost } from "@/desktop/host"; const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey; @@ -153,14 +158,24 @@ const ThemedCopy = withUnistyles(Copy); const ThemedArchive = withUnistyles(Archive); const ThemedPencil = withUnistyles(Pencil); -const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const foregroundColorMapping = (theme: Theme) => ({ + color: theme.colors.foreground, +}); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); -const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] }); -const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); -const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] }); -const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] }); +const redColorMapping = (theme: Theme) => ({ + color: theme.colors.palette.red[500], +}); +const amberColorMapping = (theme: Theme) => ({ + color: theme.colors.palette.amber[500], +}); +const greenColorMapping = (theme: Theme) => ({ + color: theme.colors.palette.green[500], +}); +const purpleColorMapping = (theme: Theme) => ({ + color: theme.colors.palette.purple[500], +}); const syncedLoaderColorMapping = (theme: Theme) => ({ color: theme.colorScheme === "light" @@ -515,6 +530,7 @@ function ProjectRowTrailingActions({ > @@ -532,6 +548,9 @@ const markAsReadLeadingIcon = ( ); const archiveLeadingIcon = ; const renameLeadingIcon = ; +const openInNewWindowLeadingIcon = ( + +); function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { return ( @@ -544,18 +563,35 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { function ProjectKebabMenu({ projectKey, + projectPath, onRemoveProject, removeProjectStatus, }: { projectKey: string; + projectPath: string; onRemoveProject: () => void; removeProjectStatus: "idle" | "pending" | "success"; }) { + const toast = useToast(); const handleOpenProjectSettings = useCallback(() => { if (projectKey.trim().length === 0) return; router.navigate(buildProjectSettingsRoute(projectKey)); }, [projectKey]); const canOpenProjectSettings = projectKey.trim().length > 0; + // Desktop-only: open a second window that lands on this project via the same + // open-project flow as a CLI launch. The project stays visible here too — no + // ownership, no move. + const canOpenInNewWindow = getIsElectron() && projectPath.trim().length > 0; + const handleOpenInNewWindow = useCallback(() => { + const trimmedPath = projectPath.trim(); + if (trimmedPath.length === 0) return; + void getDesktopHost() + ?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath }) + ?.catch((error) => { + console.warn("[sidebar] openNew failed", error); + toast.error("Couldn't open a new window"); + }); + }, [projectPath, toast]); return ( ) : null} + {canOpenInNewWindow ? ( + + Open in new window + + ) : null} ; onWorkspacePress?: () => void; }) { - const hydratedWorkspaces = useStatusModeWorkspaceEntries({ serverId, projects }); + const hydratedWorkspaces = useStatusModeWorkspaceEntries({ + serverId, + projects, + }); const projectNamesByKey = useProjectNamesMap(serverId); const showShortcutBadges = useShowShortcutBadges(); @@ -2459,7 +2507,10 @@ function ProjectModeList({ [parentGestureRef], ); - const projectIconByProjectKey = useProjectIconDataByProjectKey({ serverId, projects }); + const projectIconByProjectKey = useProjectIconDataByProjectKey({ + serverId, + projects, + }); useEffect(() => { const timeouts = creatingWorkspaceTimeoutsRef.current; diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index ade806290..f61909903 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -98,6 +98,7 @@ export interface DesktopWindowBridge { } export interface DesktopWindowModuleBridge { + openNew?: (options?: { pendingOpenProjectPath?: string | null }) => Promise; getCurrentWindow?: () => DesktopWindowBridge; } diff --git a/packages/desktop/.gitignore b/packages/desktop/.gitignore index ce59b2b70..e60b37123 100644 --- a/packages/desktop/.gitignore +++ b/packages/desktop/.gitignore @@ -7,3 +7,6 @@ node_modules/ # TypeScript *.tsbuildinfo + +# Isolated dev environment (PASEO_HOME + Electron userData) created by scripts/dev.sh +.dev/ diff --git a/packages/desktop/scripts/dev.ps1 b/packages/desktop/scripts/dev.ps1 index 70db1044e..f7b07064d 100644 --- a/packages/desktop/scripts/dev.ps1 +++ b/packages/desktop/scripts/dev.ps1 @@ -49,12 +49,60 @@ $env:PASEO_ELECTRON_FLAGS = "$($ExistingElectronFlags)--remote-debugging-port=$R # the daemon binds to localhost and this script is never used for production. $env:PASEO_CORS_ORIGINS = "*" +# Fully isolate the dev instance from a production Paseo install so `npm run dev` +# works while the installed app is open. Without this the dev build loses the +# Electron single-instance lock to the installed app and quits, and ends up +# pointed at the production daemon, whose CORS allowlist rejects the Metro origin. +# PASEO_HOME defaults to a script-managed dev home. If you override it (to point +# dev at real data), we DON'T touch your config.json — only the managed home gets +# its daemon config seeded below, so we never rewrite a production config. +$DevStateDir = "$DesktopDir\.dev" +if (-not $env:PASEO_HOME) { + $env:PASEO_HOME = "$DevStateDir\paseo-home" + $PaseoHomeManaged = $true +} else { + $PaseoHomeManaged = $false +} +if (-not $env:PASEO_ELECTRON_USER_DATA_DIR) { $env:PASEO_ELECTRON_USER_DATA_DIR = "$DevStateDir\user-data" } +New-Item -ItemType Directory -Force -Path $env:PASEO_HOME, $env:PASEO_ELECTRON_USER_DATA_DIR | Out-Null + +$DevDaemonPort = if ($env:PASEO_DEV_DAEMON_PORT) { $env:PASEO_DEV_DAEMON_PORT } else { "6788" } +if (-not $env:PASEO_LISTEN) { $env:PASEO_LISTEN = "127.0.0.1:$DevDaemonPort" } + +# Seed the isolated daemon config. The desktop daemon-manager decides whether a +# daemon is already running by reading `daemon.listen` from this config.json +# (it does NOT honor the PASEO_LISTEN env var) and probing that address. Without +# this it reads the default 6767, finds a production daemon there, and connects +# the dev app to prod — whose CORS allowlist then rejects the Metro origin. Pin +# the dev port + wildcard CORS in the file so the dev app starts its OWN daemon. +# ONLY seed the script-managed home: never rewrite a user-supplied PASEO_HOME +# (that could clobber a production config.json with the dev port + wildcard CORS). +if ($PaseoHomeManaged) { + node -e ' +const fs = require("fs"); +const [path, port] = [process.argv[1], process.argv[2]]; +let cfg = {}; +try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {} +cfg.version = cfg.version || 1; +cfg.daemon = cfg.daemon || {}; +cfg.daemon.listen = `127.0.0.1:${port}`; +cfg.daemon.cors = cfg.daemon.cors || {}; +cfg.daemon.cors.allowedOrigins = ["*"]; +fs.writeFileSync(path, JSON.stringify(cfg, null, 2)); +' "$($env:PASEO_HOME)/config.json" $DevDaemonPort +} else { + Write-Host " (custom PASEO_HOME - leaving its config.json untouched)" +} + Write-Host @" ====================================================== Paseo Desktop Dev (Windows) ====================================================== - Metro: http://localhost:$($env:EXPO_PORT) - CDP: http://127.0.0.1:$RemoteDebuggingPort + Metro: http://localhost:$($env:EXPO_PORT) + CDP: http://127.0.0.1:$RemoteDebuggingPort + Daemon: $($env:PASEO_LISTEN) (isolated) + PASEO_HOME: $($env:PASEO_HOME) + userData: $($env:PASEO_ELECTRON_USER_DATA_DIR) ====================================================== "@ diff --git a/packages/desktop/scripts/dev.sh b/packages/desktop/scripts/dev.sh index efce05667..4a959d962 100755 --- a/packages/desktop/scripts/dev.sh +++ b/packages/desktop/scripts/dev.sh @@ -22,11 +22,63 @@ export PASEO_ELECTRON_FLAGS="${PASEO_ELECTRON_FLAGS:+$PASEO_ELECTRON_FLAGS }--re # and the daemon still binds to localhost. export PASEO_CORS_ORIGINS="*" +# Fully isolate the dev instance from a production Paseo install so `npm run dev` +# works while the installed app is open. Without this the dev build (a) loses the +# Electron single-instance lock to the installed app and quits, and (b) ends up +# pointed at the production daemon, whose CORS allowlist rejects the Metro origin. +# PASEO_HOME defaults to a script-managed dev home. If you override it (to point +# dev at real data), we DON'T touch your config.json — only the managed home gets +# its daemon config seeded below, so we never rewrite a production ~/.paseo config. +# - PASEO_ELECTRON_USER_DATA_DIR: a separate Electron profile → separate +# single-instance lock, so dev and the installed app coexist. +# - PASEO_LISTEN: a distinct port so the dev daemon never collides with prod's 6767. +DEV_STATE_DIR="$DESKTOP_DIR/.dev" +if [ -n "${PASEO_HOME:-}" ]; then + PASEO_HOME_MANAGED=0 +else + PASEO_HOME="$DEV_STATE_DIR/paseo-home" + PASEO_HOME_MANAGED=1 +fi +export PASEO_HOME +export PASEO_ELECTRON_USER_DATA_DIR="${PASEO_ELECTRON_USER_DATA_DIR:-$DEV_STATE_DIR/user-data}" +mkdir -p "$PASEO_HOME" "$PASEO_ELECTRON_USER_DATA_DIR" + +DEV_DAEMON_PORT="${PASEO_DEV_DAEMON_PORT:-6788}" +export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:$DEV_DAEMON_PORT}" + +# Seed the isolated daemon config. The desktop daemon-manager decides whether a +# daemon is already running by reading `daemon.listen` from this config.json +# (it does NOT honor the PASEO_LISTEN env var) and probing that address. Without +# this it reads the default 6767, finds a production daemon there, and connects +# the dev app to prod — whose CORS allowlist then rejects the Metro origin. Pin +# the dev port + wildcard CORS in the file so the dev app starts its OWN daemon. +# ONLY seed the script-managed home: never rewrite a user-supplied PASEO_HOME +# (that could clobber a production config.json with the dev port + wildcard CORS). +if [ "$PASEO_HOME_MANAGED" = "1" ]; then + node -e ' +const fs = require("fs"); +const [path, port] = [process.argv[1], process.argv[2]]; +let cfg = {}; +try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {} +cfg.version = cfg.version || 1; +cfg.daemon = cfg.daemon || {}; +cfg.daemon.listen = `127.0.0.1:${port}`; +cfg.daemon.cors = cfg.daemon.cors || {}; +cfg.daemon.cors.allowedOrigins = ["*"]; +fs.writeFileSync(path, JSON.stringify(cfg, null, 2)); +' "$PASEO_HOME/config.json" "$DEV_DAEMON_PORT" +else + echo " (custom PASEO_HOME — leaving its config.json untouched)" +fi + echo "══════════════════════════════════════════════════════" echo " Paseo Desktop Dev" echo "══════════════════════════════════════════════════════" -echo " Metro: http://localhost:${EXPO_PORT}" -echo " CDP: http://127.0.0.1:${REMOTE_DEBUGGING_PORT}" +echo " Metro: http://localhost:${EXPO_PORT}" +echo " CDP: http://127.0.0.1:${REMOTE_DEBUGGING_PORT}" +echo " Daemon: ${PASEO_LISTEN} (isolated)" +echo " PASEO_HOME: ${PASEO_HOME}" +echo " userData: ${PASEO_ELECTRON_USER_DATA_DIR}" echo "══════════════════════════════════════════════════════" # Launch Metro + Electron together, kill both on exit diff --git a/packages/desktop/src/features/menu.ts b/packages/desktop/src/features/menu.ts index b89244482..94f29ac53 100644 --- a/packages/desktop/src/features/menu.ts +++ b/packages/desktop/src/features/menu.ts @@ -6,6 +6,10 @@ interface ShowContextMenuInput { hasSelection?: boolean; } +interface ApplicationMenuOptions { + onNewWindow: () => void; +} + function withBrowserWindow( callback: (win: BrowserWindow) => void, ): (_item: Electron.MenuItem, baseWin: Electron.BaseWindow | undefined) => void { @@ -41,10 +45,12 @@ function reloadFocusedContentsOrWindow(win: BrowserWindow, options?: { ignoreCac win.webContents.reload(); } -export function setupApplicationMenu(): void { +function buildApplicationMenuTemplate( + options: ApplicationMenuOptions, +): Electron.MenuItemConstructorOptions[] { const isMac = process.platform === "darwin"; - const template: Electron.MenuItemConstructorOptions[] = [ + return [ ...(isMac ? [ { @@ -63,6 +69,18 @@ export function setupApplicationMenu(): void { }, ] : []), + { + label: "File", + submenu: [ + { + label: "New Window", + accelerator: "CmdOrCtrl+Shift+N", + click: () => { + options.onNewWindow(); + }, + }, + ], + }, { label: "Edit", submenu: [ @@ -130,8 +148,10 @@ export function setupApplicationMenu(): void { ], }, ]; +} - const menu = Menu.buildFromTemplate(template); +export function setupApplicationMenu(options: ApplicationMenuOptions): void { + const menu = Menu.buildFromTemplate(buildApplicationMenuTemplate(options)); Menu.setApplicationMenu(menu); ipcMain.handle("paseo:menu:showContextMenu", (event, input?: ShowContextMenuInput) => { diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 01e4883da..17721ee30 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -53,6 +53,7 @@ import { setWorkspaceActivePaseoBrowserId, } from "./features/browser-webviews.js"; import { parseOpenProjectPathFromArgv } from "./open-project-routing.js"; +import { PendingOpenProjectStore } from "./pending-open-project-store.js"; import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js"; import { clampWindowStateToWorkAreas, createWindowStateStore } from "./settings/window-state.js"; import { @@ -94,7 +95,6 @@ function preventUnsafeBrowserWebviewNavigation( event.preventDefault(); } } -const OPEN_PROJECT_EVENT = "paseo:event:open-project"; const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut"; const BROWSER_FORWARDED_KEY_EVENT = "paseo:event:browser-forwarded-key"; @@ -257,6 +257,12 @@ let pendingOpenProjectPath = parseOpenProjectPathFromArgv({ isDefaultApp: process.defaultApp, }); +// Each window pulls its own pending open-project path on mount, keyed by +// webContents id, so deep-linked windows (second-instance launches, the +// in-app "Open in new window" action) land on the right project without +// racing a global. +const pendingOpenProjectStore = new PendingOpenProjectStore(); + if (PASEO_DEBUG) { log.info("[open-project] argv:", process.argv); log.info("[open-project] isDefaultApp:", process.defaultApp); @@ -265,10 +271,13 @@ if (PASEO_DEBUG) { // The renderer pulls the pending path on mount via IPC — this avoids // a race where the push event arrives before React registers its listener. -ipcMain.handle("paseo:get-pending-open-project", () => { - log.info("[open-project] renderer requested pending path:", pendingOpenProjectPath); - const result = pendingOpenProjectPath; - pendingOpenProjectPath = null; +ipcMain.handle("paseo:get-pending-open-project", (event) => { + const webContentsId = event.sender.id; + const result = pendingOpenProjectStore.take(webContentsId); + log.info("[open-project] renderer requested pending path:", { + webContentsId, + pendingPath: result, + }); return result; }); @@ -326,7 +335,10 @@ ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknow }); protocol.registerSchemesAsPrivileged([ - { scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } }, + { + scheme: APP_SCHEME, + privileges: { standard: true, secure: true, supportFetchAPI: true }, + }, ]); // --------------------------------------------------------------------------- @@ -395,12 +407,24 @@ function getWorkAreasPrimaryFirst(): Electron.Rectangle[] { return [primary, ...others].map((display) => display.workArea); } -async function createMainWindow(): Promise { +async function createWindow( + options: { + pendingOpenProjectPath?: string | null; + restoreWindowState?: boolean; + } = {}, +): Promise { const iconPath = getWindowIconPath(); const systemTheme = resolveSystemWindowTheme(); - const windowStateStore = createWindowStateStore({ userDataPath: app.getPath("userData") }); - const savedWindowState = await windowStateStore.load(); + // Only the first window of a session restores and persists saved geometry. + // Additional windows (⌘N, second-instance, "Open in new window") open at the + // default size and let the OS cascade them, so they neither stack on top of + // the restored window nor fight over the single window-state store. + const restoreWindowState = options.restoreWindowState ?? false; + const windowStateStore = restoreWindowState + ? createWindowStateStore({ userDataPath: app.getPath("userData") }) + : null; + const savedWindowState = windowStateStore ? await windowStateStore.load() : null; const restoredWindowState = savedWindowState ? clampWindowStateToWorkAreas(savedWindowState, getWorkAreasPrimaryFirst()) : null; @@ -424,6 +448,12 @@ async function createMainWindow(): Promise { }, }); + const webContentsId = mainWindow.webContents.id; + pendingOpenProjectStore.set(webContentsId, options.pendingOpenProjectPath); + mainWindow.on("closed", () => { + pendingOpenProjectStore.delete(webContentsId); + }); + if (devWorktreeName) { app.dock?.setBadge(devWorktreeName); } @@ -434,7 +464,9 @@ async function createMainWindow(): Promise { setupDarwinCompositorWatchdog(mainWindow); setupWindowResizeEvents(mainWindow); - setupWindowStatePersistence(mainWindow, windowStateStore); + if (windowStateStore) { + setupWindowStatePersistence(mainWindow, windowStateStore); + } setupDefaultContextMenu(mainWindow); setupDragDropPrevention(mainWindow); mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => { @@ -531,31 +563,27 @@ async function createMainWindow(): Promise { const { loadReactDevTools } = await import("./features/react-devtools.js"); await loadReactDevTools(); await mainWindow.loadURL(DEV_SERVER_URL); - return; + return mainWindow; } await mainWindow.loadURL(`${APP_SCHEME}://app/`); -} - -function sendOpenProjectEvent(win: BrowserWindow, projectPath: string): void { - const send = () => { - log.info("[open-project] sending event to renderer:", projectPath); - win.webContents.send(OPEN_PROJECT_EVENT, { path: projectPath }); - }; - - if (win.webContents.isLoadingMainFrame()) { - log.info("[open-project] waiting for did-finish-load before sending event"); - win.webContents.once("did-finish-load", send); - return; - } - - send(); + return mainWindow; } // --------------------------------------------------------------------------- // App lifecycle // --------------------------------------------------------------------------- +// Resolves once bootstrap() has registered the custom protocol handler and IPC +// handlers and created the first window. second-instance window creation waits +// on this rather than app.whenReady(): in packaged mode createWindow loads +// `paseo://app/`, which fails if the protocol handler isn't registered yet, and +// a second instance can arrive mid-cold-start. +let resolveBootstrapComplete: () => void; +const bootstrapComplete = new Promise((resolve) => { + resolveBootstrapComplete = resolve; +}); + function setupSingleInstanceLock(): boolean { if (DISABLE_SINGLE_INSTANCE_LOCK) { log.info("[single-instance] disabled by PASEO_DISABLE_SINGLE_INSTANCE_LOCK"); @@ -575,15 +603,14 @@ function setupSingleInstanceLock(): boolean { isDefaultApp: false, }); log.info("[open-project] second-instance openProjectPath:", openProjectPath); - const win = BrowserWindow.getAllWindows()[0]; - if (win) { - win.show(); - if (win.isMinimized()) win.restore(); - win.focus(); - if (openProjectPath) { - sendOpenProjectEvent(win, openProjectPath); - } - } + // Relaunching the app (CLI `paseo [path]`, double-click, etc.) opens a new + // window rather than focusing the existing one. Wait for bootstrap (not just + // app.whenReady) so the protocol + IPC handlers exist before the window loads. + void bootstrapComplete + .then(() => createWindow({ pendingOpenProjectPath: openProjectPath })) + .catch((error) => { + log.error("[window] failed to create window from second-instance", error); + }); }); return true; @@ -689,7 +716,13 @@ async function bootstrap(): Promise { }); applyAppIcon(); - setupApplicationMenu(); + setupApplicationMenu({ + onNewWindow: () => { + void createWindow().catch((error) => { + log.error("[window] failed to create window from menu", error); + }); + }, + }); ensureNotificationCenterRegistration(); if (await runDesktopSmokeIfRequested()) { return; @@ -701,11 +734,29 @@ async function bootstrap(): Promise { registerOpenerHandlers(); registerEditorTargetHandlers(); - await createMainWindow(); + // In-app "Open in new window": opens a window that lands on the given project + // via the same open-project flow as a CLI launch (no move, no ownership). + ipcMain.handle("paseo:window:openNew", async (_event, options?: unknown) => { + const pendingPath = + options && typeof options === "object" && "pendingOpenProjectPath" in options + ? (options as { pendingOpenProjectPath?: unknown }).pendingOpenProjectPath + : null; + await createWindow({ + pendingOpenProjectPath: typeof pendingPath === "string" ? pendingPath : null, + }); + }); + + // The first window of the session restores and persists saved geometry. + await createWindow({ pendingOpenProjectPath, restoreWindowState: true }); + pendingOpenProjectPath = null; + + // Protocol + IPC handlers and the first window now exist: release any + // second-instance launches that arrived during cold start. + resolveBootstrapComplete(); app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { - await createMainWindow(); + await createWindow({ restoreWindowState: true }); } }); } diff --git a/packages/desktop/src/pending-open-project-store.test.ts b/packages/desktop/src/pending-open-project-store.test.ts new file mode 100644 index 000000000..448e15bf1 --- /dev/null +++ b/packages/desktop/src/pending-open-project-store.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { PendingOpenProjectStore } from "./pending-open-project-store"; + +describe("PendingOpenProjectStore", () => { + it("stores pending paths per window and consumes them independently", () => { + const store = new PendingOpenProjectStore(); + + store.set(101, "/tmp/project-a"); + store.set(202, "/tmp/project-b"); + + expect(store.take(101)).toBe("/tmp/project-a"); + expect(store.take(202)).toBe("/tmp/project-b"); + }); + + it("clears a pending path after it is consumed", () => { + const store = new PendingOpenProjectStore(); + + store.set(101, "/tmp/project-a"); + + expect(store.take(101)).toBe("/tmp/project-a"); + expect(store.take(101)).toBeNull(); + }); + + it("ignores empty and whitespace-only paths", () => { + const store = new PendingOpenProjectStore(); + + store.set(101, " "); + + expect(store.take(101)).toBeNull(); + }); +}); diff --git a/packages/desktop/src/pending-open-project-store.ts b/packages/desktop/src/pending-open-project-store.ts new file mode 100644 index 000000000..27543e88c --- /dev/null +++ b/packages/desktop/src/pending-open-project-store.ts @@ -0,0 +1,32 @@ +export class PendingOpenProjectStore { + private readonly pendingPathByWebContentsId = new Map(); + + set(webContentsId: number, projectPath: string | null | undefined): void { + const normalizedPath = this.normalizeProjectPath(projectPath); + if (!normalizedPath) { + this.pendingPathByWebContentsId.delete(webContentsId); + return; + } + + this.pendingPathByWebContentsId.set(webContentsId, normalizedPath); + } + + take(webContentsId: number): string | null { + const projectPath = this.pendingPathByWebContentsId.get(webContentsId) ?? null; + this.pendingPathByWebContentsId.delete(webContentsId); + return projectPath; + } + + delete(webContentsId: number): void { + this.pendingPathByWebContentsId.delete(webContentsId); + } + + private normalizeProjectPath(projectPath: string | null | undefined): string | null { + if (typeof projectPath !== "string") { + return null; + } + + const trimmedPath = projectPath.trim(); + return trimmedPath ? trimmedPath : null; + } +} diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index fa2ec5e12..60e78874b 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -20,6 +20,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", { }, }, window: { + openNew: (options?: { pendingOpenProjectPath?: string | null }) => + ipcRenderer.invoke("paseo:window:openNew", options), getCurrentWindow: () => ({ toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"), isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"), diff --git a/packages/desktop/src/window/window-manager.ts b/packages/desktop/src/window/window-manager.ts index f5ee448a0..a56bb51c1 100644 --- a/packages/desktop/src/window/window-manager.ts +++ b/packages/desktop/src/window/window-manager.ts @@ -240,17 +240,19 @@ export function registerWindowManager(): void { } export function setupWindowResizeEvents(win: BrowserWindow): void { - win.on("resize", () => { + // A resize/fullscreen event can fire while the window is tearing down; sending + // to a destroyed webContents throws. Guard so multi-window close doesn't surface + // "Object has been destroyed" exceptions. + const notifyResized = () => { + if (win.isDestroyed() || win.webContents.isDestroyed()) { + return; + } win.webContents.send("paseo:window:resized", {}); - }); + }; - win.on("enter-full-screen", () => { - win.webContents.send("paseo:window:resized", {}); - }); - - win.on("leave-full-screen", () => { - win.webContents.send("paseo:window:resized", {}); - }); + win.on("resize", notifyResized); + win.on("enter-full-screen", notifyResized); + win.on("leave-full-screen", notifyResized); } /** diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 9d8acc8ae..565ca6999 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -835,7 +835,7 @@ describe("model merging", () => { ]); }); - test("built-in Claude profile models append to runtime models", async () => { + test("built-in Claude profile models replace runtime models (issue #1299)", async () => { mockState.runtimeModels.set("claude", [ { provider: "claude", @@ -872,11 +872,6 @@ describe("model merging", () => { }); expect(models).toEqual([ - { - provider: "claude", - id: "runtime-model", - label: "Runtime Model", - }, { provider: "claude", id: "shared-model", @@ -1129,4 +1124,32 @@ describe("model merging", () => { const defaultModel = models.find((model) => model.isDefault) ?? models[0]; expect(defaultModel?.id).toBe("profile-default"); }); + + test("built-in Claude models override replaces hardcoded first-party models (issue #1299)", async () => { + mockState.runtimeModels.set("claude", [ + { provider: "claude", id: "claude-opus-4-8", label: "Opus 4.8", isDefault: true }, + { provider: "claude", id: "claude-opus-4-7", label: "Opus 4.7" }, + { provider: "claude", id: "claude-sonnet-4-6", label: "Sonnet 4.6" }, + { provider: "claude", id: "claude-haiku-4-5", label: "Haiku 4.5" }, + ]); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + models: [ + { id: "MiniMax-M2.7", label: "MiniMax-M2.7" }, + { id: "MiniMax-M3", label: "MiniMax-M3", isDefault: true }, + ], + }, + }, + }); + + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, + }); + + expect(models.map((model) => model.id)).toEqual(["MiniMax-M2.7", "MiniMax-M3"]); + expect(models.find((model) => model.isDefault)?.id).toBe("MiniMax-M3"); + }); }); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index b474047fe..823ea6546 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -523,7 +523,7 @@ function buildResolvedBuiltinProviders( runtimeSettings: mergedRuntimeSettings, profileModels: override?.models ?? [], additionalModels: override?.additionalModels ?? [], - profileModelsAreAdditive: definition.id === "claude", + profileModelsAreAdditive: false, enabled: override?.enabled !== false, derivedFromProviderId: null, createBaseClient: (logger) => diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index b28accd07..6939089b6 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -156,6 +156,90 @@ describe("ProviderSnapshotManager public surface", () => { } }); + test("refreshTimeoutMs option overrides the default and yields a timeout error", async () => { + // never-resolving isAvailable forces the timeout path + const isAvailable = vi.fn(() => new Promise(() => {})); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + refreshTimeoutMs: 1, + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { codex: createExtraClient("codex", { isAvailable }) }, + }); + try { + const entry = await manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + expect(entry.provider).toBe("codex"); + expect(entry.status).toBe("error"); + expect(entry.error).toMatch(/after 1ms/); + } finally { + manager.destroy(); + } + }); + + test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is honored when no option is given", async () => { + vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1"); + const isAvailable = vi.fn(() => new Promise(() => {})); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { codex: createExtraClient("codex", { isAvailable }) }, + }); + try { + const entry = await manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + expect(entry.status).toBe("error"); + expect(entry.error).toMatch(/after 1ms/); + } finally { + manager.destroy(); + vi.unstubAllEnvs(); + } + }); + + test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is ignored when option is provided", async () => { + vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1"); + const isAvailable = vi.fn(() => new Promise(() => {})); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + refreshTimeoutMs: 5, + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { codex: createExtraClient("codex", { isAvailable }) }, + }); + try { + const entry = await manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + expect(entry.status).toBe("error"); + // explicit option (5) wins over env var (1) + expect(entry.error).toMatch(/after 5ms/); + } finally { + manager.destroy(); + vi.unstubAllEnvs(); + } + }); + test("listProviders returns an entry per registered provider", async () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 08a51c2c5..0acbc3fe8 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -29,6 +29,25 @@ import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js import type { MutableDaemonConfig } from "../daemon-config-store.js"; const DEFAULT_REFRESH_TIMEOUT_MS = 30_000; +const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS"; + +// Provider refresh probes can be slow on cold starts (e.g. Copilot's first +// `copilot --acp` invocation, OpenCode workspace probes with many MCP servers). +// Allow operators to bump the ceiling via env var without rebuilding. +function resolveRefreshTimeoutMs(option: number | undefined): number { + if (typeof option === "number" && Number.isFinite(option) && option > 0) { + return option; + } + const fromEnv = process.env[REFRESH_TIMEOUT_ENV_VAR]; + if (fromEnv) { + // Number() handles scientific notation (e.g. "6e4") which parseInt would silently truncate. + const parsed = Number(fromEnv); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return DEFAULT_REFRESH_TIMEOUT_MS; +} type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void; @@ -124,7 +143,7 @@ export class ProviderSnapshotManager { this.runtimeSettings = options.runtimeSettings; this.providerOverrides = options.providerOverrides; this.baseProviderOverrides = options.providerOverrides; - this.refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS; + this.refreshTimeoutMs = resolveRefreshTimeoutMs(options.refreshTimeoutMs); this.providerRegistry = this.buildRegistry(); this.providerClients = { ...this.extraClients } as Record; } diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index be905bf9e..382885e8e 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -397,28 +397,37 @@ describe("ClaudeAgentClient.listModels", () => { const logger = createTestLogger(); test("returns hardcoded claude models", async () => { - const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); - const models = await client.listModels({ cwd: "/tmp/claude-models", force: false }); + const emptyConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-empty-")); + try { + const client = new ClaudeAgentClient({ + logger, + resolveBinary: async () => "/test/claude/bin", + configDir: emptyConfigDir, + }); + const models = await client.listModels({ cwd: "/tmp/claude-models", force: false }); - expect(models.map((m) => m.id)).toEqual([ - "claude-opus-4-8[1m]", - "claude-opus-4-8", - "claude-opus-4-7[1m]", - "claude-opus-4-7", - "claude-opus-4-6[1m]", - "claude-opus-4-6", - "claude-sonnet-4-6[1m]", - "claude-sonnet-4-6", - "claude-haiku-4-5", - ]); + expect(models.map((m) => m.id)).toEqual([ + "claude-opus-4-8[1m]", + "claude-opus-4-8", + "claude-opus-4-7[1m]", + "claude-opus-4-7", + "claude-opus-4-6[1m]", + "claude-opus-4-6", + "claude-sonnet-4-6[1m]", + "claude-sonnet-4-6", + "claude-haiku-4-5", + ]); - for (const model of models) { - expect(model.provider).toBe("claude"); - expect(model.label.length).toBeGreaterThan(0); + for (const model of models) { + expect(model.provider).toBe("claude"); + expect(model.label.length).toBeGreaterThan(0); + } + + const defaultModel = models.find((m) => m.isDefault); + expect(defaultModel?.id).toBe("claude-opus-4-8"); + } finally { + await fs.rm(emptyConfigDir, { recursive: true, force: true }); } - - const defaultModel = models.find((m) => m.isDefault); - expect(defaultModel?.id).toBe("claude-opus-4-8"); }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index b6473c699..60bbf0ee5 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -279,6 +279,7 @@ interface ClaudeAgentClientOptions { runtimeSettings?: ProviderRuntimeSettings; queryFactory?: ClaudeQueryFactory; resolveBinary?: () => Promise; + configDir?: string; } interface ClaudeAgentSessionOptions { @@ -1277,6 +1278,7 @@ export class ClaudeAgentClient implements AgentClient { private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise; + private readonly configDir?: string; constructor(options: ClaudeAgentClientOptions) { this.defaults = options.defaults; @@ -1284,6 +1286,7 @@ export class ClaudeAgentClient implements AgentClient { this.runtimeSettings = options.runtimeSettings; this.queryFactory = options.queryFactory; this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings)); + this.configDir = options.configDir; } async createSession( @@ -1334,7 +1337,7 @@ export class ClaudeAgentClient implements AgentClient { async listModels(_options: ListModelsOptions): Promise { // Claude exposes a global catalog here; cwd/force are intentionally irrelevant. - return await getClaudeModelsWithSettings(this.logger); + return await getClaudeModelsWithSettings(this.logger, this.configDir); } async listFeatures(config: AgentSessionConfig): Promise { diff --git a/packages/server/src/server/agent/providers/claude/models.ts b/packages/server/src/server/agent/providers/claude/models.ts index 644d26ff6..31c2cc77f 100644 --- a/packages/server/src/server/agent/providers/claude/models.ts +++ b/packages/server/src/server/agent/providers/claude/models.ts @@ -98,9 +98,12 @@ export function getClaudeModels(): AgentModelDefinition[] { return CLAUDE_MODELS.map((model) => ({ ...model })); } -export async function getClaudeModelsWithSettings(logger: Logger): Promise { +export async function getClaudeModelsWithSettings( + logger: Logger, + configDir?: string, +): Promise { const hardcodedModels = getClaudeModels(); - const settingsModels = await readClaudeSettingsModels(logger); + const settingsModels = await readClaudeSettingsModels(logger, configDir); if (settingsModels.length === 0) { return hardcodedModels; } @@ -119,8 +122,11 @@ export async function getClaudeModelsWithSettings(logger: Logger): Promise { - const settingsPath = path.join(resolveClaudeConfigDir(), "settings.json"); +async function readClaudeSettingsModels( + logger: Logger, + configDir?: string, +): Promise { + const settingsPath = path.join(resolveClaudeConfigDir(configDir), "settings.json"); let parsed: unknown; try { @@ -155,8 +161,8 @@ async function readClaudeSettingsModels(logger: Logger): Promise { expect(extractWsBearerToken(protocol)).toBe("secret.with.dots"); expect(extractWsBearerToken("paseo.other.secret")).toBeNull(); }); + + test("bypasses bearer auth for preflight, liveness, and capability-token routes", () => { + // Preflight is always bypassed regardless of path. + expect(shouldBypassBearerAuth("OPTIONS", "/api/status")).toBe(true); + // Unauthenticated liveness probe. + expect(shouldBypassBearerAuth("GET", "/api/health")).toBe(true); + // Guarded by its own single-use download token, not the daemon password. + expect(shouldBypassBearerAuth("GET", "/api/files/download")).toBe(true); + // Everything else stays behind the daemon password. + expect(shouldBypassBearerAuth("GET", "/api/status")).toBe(false); + expect(shouldBypassBearerAuth("POST", "/api/files/upload")).toBe(false); + }); }); diff --git a/packages/server/src/server/auth.ts b/packages/server/src/server/auth.ts index 2247597f9..4e2e7ee7f 100644 --- a/packages/server/src/server/auth.ts +++ b/packages/server/src/server/auth.ts @@ -118,9 +118,25 @@ export function createRequireBearerMiddleware( }; } +// Routes that authenticate via their own capability and therefore must not be +// gated a second time behind the daemon password. +const BEARER_AUTH_BYPASS_PATHS = new Set([ + // Unauthenticated liveness probe. + "/api/health", + // Guarded by a single-use download token (crypto-random UUID, 60s TTL, + // consumed on first use) that is only ever issued over the + // already-authenticated WebSocket. The token IS the capability for this + // route. Requiring the daemon password on top of it breaks browser and + // Electron downloads: those trigger the download via an anchor navigation, + // which cannot attach an `Authorization` header. The download endpoint still + // rejects requests without a valid token (400/403), so dropping the bearer + // here does not make the route unauthenticated. + "/api/files/download", +]); + export function shouldBypassBearerAuth(method: string, path: string): boolean { if (method === "OPTIONS") { return true; } - return path === "/api/health"; + return BEARER_AUTH_BYPASS_PATHS.has(path); } diff --git a/packages/server/src/server/bootstrap-auth.test.ts b/packages/server/src/server/bootstrap-auth.test.ts index a8d98ef4a..2fa7c860a 100644 --- a/packages/server/src/server/bootstrap-auth.test.ts +++ b/packages/server/src/server/bootstrap-auth.test.ts @@ -65,18 +65,39 @@ describe("daemon bearer auth", () => { auth: { password: CORRECT_PASSWORD_HASH }, }); try { - const missing = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`); + const missing = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`); expect(missing.status).toBe(401); - const wrong = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, { + const wrong = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`, { headers: { Authorization: "Bearer wrong-password" }, }); expect(wrong.status).toBe(401); - const correct = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, { + const correct = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`, { headers: { Authorization: "Bearer correct-password" }, }); - expect(correct.status).toBe(400); + expect(correct.status).toBe(200); + } finally { + await daemonHandle.close(); + } + }); + + test("allows file downloads with only a capability token when password is configured", async () => { + const daemonHandle = await createTestPaseoDaemon({ + auth: { password: CORRECT_PASSWORD_HASH }, + }); + try { + // No bearer at all: the route is reachable, but the download token store + // rejects the request because no token was supplied (400, not 401). + const missingToken = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`); + expect(missingToken.status).toBe(400); + + // An invalid token is rejected by the token store (403, not 401) — proving + // the token, not the daemon password, is what guards this route. + const invalidToken = await fetch( + `http://127.0.0.1:${daemonHandle.port}/api/files/download?token=invalid-token`, + ); + expect(invalidToken.status).toBe(403); } finally { await daemonHandle.close(); } diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 1fec0afc9..d18cab97f 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -93,6 +93,37 @@ describe("searchHomeDirectories", () => { expect(exactIndex).toBeLessThan(prefixIndex); }); + it("does not let Python virtual environments crowd out top-level project matches", async () => { + const projectPath = path.join(homeDir, "django-po-merge"); + mkdirSync(projectPath, { recursive: true }); + const dependencyPaths = ["venv", "env", "virtualenv"].map((environmentDirectoryName) => + path.join( + homeDir, + `${environmentDirectoryName}-project`, + environmentDirectoryName, + "Lib", + "site-packages", + "django", + ), + ); + for (const dependencyPath of dependencyPaths) { + mkdirSync(dependencyPath, { recursive: true }); + } + + const results = await searchHomeDirectories({ + homeDir, + query: "~/django", + limit: 30, + }); + + const resolvedResults = results.map((result) => realpathSync.native(result)); + const projectIndex = resolvedResults.indexOf(realpathSync.native(projectPath)); + expect(projectIndex).toBeGreaterThanOrEqual(0); + for (const dependencyPath of dependencyPaths) { + expect(resolvedResults).not.toContain(realpathSync.native(dependencyPath)); + } + }); + it("prioritizes partial matches that appear earlier in the path", async () => { const earlierPath = path.join(homeDir, "farofoo"); const laterPath = path.join(homeDir, "x", "y", "farofoo"); diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index b889b8f08..c244535a2 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -78,8 +78,11 @@ const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER; const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER; const NO_FUZZY_SCORE = Number.MAX_SAFE_INTEGER; const NO_WORKSPACE_MATCH_TIER = 5; -const WORKSPACE_IGNORED_DIRECTORY_NAMES = new Set([ +const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([ "node_modules", + "venv", + "env", + "virtualenv", "dist", "build", "target", @@ -824,7 +827,9 @@ async function listChildDirectories(input: { ); const candidates = dirents.filter( (dirent) => - !isHiddenDirectoryName(dirent.name) && (dirent.isDirectory() || dirent.isSymbolicLink()), + !isHiddenDirectoryName(dirent.name) && + !isIgnoredSuggestionDirectoryName(dirent.name) && + (dirent.isDirectory() || dirent.isSymbolicLink()), ); const resolved = await Promise.all( candidates.map(async (dirent) => { @@ -864,7 +869,7 @@ async function listWorkspaceChildEntries(input: { ); const candidates = dirents.filter( (dirent) => - !isHiddenDirectoryName(dirent.name) && !isIgnoredWorkspaceDirectoryName(dirent.name), + !isHiddenDirectoryName(dirent.name) && !isIgnoredSuggestionDirectoryName(dirent.name), ); const resolved = await Promise.all( candidates.map(async (dirent) => { @@ -955,8 +960,8 @@ function isHiddenDirectoryName(name: string): boolean { return name.startsWith("."); } -function isIgnoredWorkspaceDirectoryName(name: string): boolean { - return WORKSPACE_IGNORED_DIRECTORY_NAMES.has(name); +function isIgnoredSuggestionDirectoryName(name: string): boolean { + return IGNORED_SUGGESTION_DIRECTORY_NAMES.has(name); } function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {