diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e782cd38c..664e09036 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,7 @@ jobs: run: > npx vitest run src/utils/executable.test.ts + src/utils/spawn.launch-regression.test.ts src/utils/spawn.test.ts src/utils/run-git-command.test.ts src/utils/checkout-git-rev-parse.test.ts @@ -112,6 +113,7 @@ jobs: src/server/agent/provider-launch-config.test.ts src/server/agent/provider-snapshot-manager.test.ts src/server/agent/providers/claude-agent.spawn.test.ts + src/server/agent/providers/provider-windows-launch.test.ts src/server/agent/providers/provider-availability.test.ts src/server/workspace-registry-model.test.ts src/server/persisted-config.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1774840e5..a2b7bf767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.1.58 - 2026-04-16 + +### Added +- Markdown files render as formatted markdown in the file pane. ([#427](https://github.com/getpaseo/paseo/pull/427) by [@aaronflorey](https://github.com/aaronflorey)) +- Cmd+L (Ctrl+L on Windows/Linux) focuses the agent message input. +- Provider models refresh on a freshness TTL; Settings shows last-updated time and any fetch errors. ([#426](https://github.com/getpaseo/paseo/pull/426)) +- `disallowedTools` option in provider config to block specific tools from an agent. + +### Improved +- Windows: agents launch reliably from npm `.cmd` shims, paths with spaces, and JSON config args — fixes `spawn EINVAL` startup errors. ([#454](https://github.com/getpaseo/paseo/pull/454)) +- OpenCode permission prompts include the requesting tool's context. ([#398](https://github.com/getpaseo/paseo/pull/398) by [@aaronflorey](https://github.com/aaronflorey)) +- OpenCode todo and compaction events render in the timeline. ([#429](https://github.com/getpaseo/paseo/pull/429) by [@aaronflorey](https://github.com/aaronflorey)) +- OpenCode sessions archive cleanly when closed. ([#408](https://github.com/getpaseo/paseo/pull/408) by [@aaronflorey](https://github.com/aaronflorey)) +- OpenCode slash commands recover from SSE timeouts. ([#407](https://github.com/getpaseo/paseo/pull/407) by [@aaronflorey](https://github.com/aaronflorey)) +- Paseo MCP tools work against archived agents, matching the CLI. ([#423](https://github.com/getpaseo/paseo/pull/423)) +- Native scrollbars match the active theme across all web views. ([#399](https://github.com/getpaseo/paseo/pull/399) by [@ethersh](https://github.com/ethersh)) + +### Fixed +- Code file previews can be selected and copied on iOS. ([#447](https://github.com/getpaseo/paseo/pull/447) by [@muzhi1991](https://github.com/muzhi1991)) +- File preview no longer shows stale content when reopening the same file. ([#411](https://github.com/getpaseo/paseo/pull/411) by [@muzhi1991](https://github.com/muzhi1991)) +- File explorer reinitialises when the client reconnects after a page refresh. ([#442](https://github.com/getpaseo/paseo/pull/442) by [@1996fanrui](https://github.com/1996fanrui)) +- Generic ACP providers no longer receive duplicated command arguments. ([#444](https://github.com/getpaseo/paseo/pull/444) by [@edvardchen](https://github.com/edvardchen)) +- Workspace headers no longer show a branch icon for non-git workspaces. +- Branch switcher layout is stable on mobile. +- Model names no longer truncate mid-word in the picker rows. +- Messages appear in the correct order after reconnecting on mobile. +- Clearing agent attention no longer throws on timeout. + ## 0.1.56 - 2026-04-14 ### Fixed diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 54e288c70..6a838f66e 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -139,6 +139,16 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de - **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix. - **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them. +## Changelog conciseness + +Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list. + +- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long. +- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change. +- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it. +- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…". +- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets. + ## Changelog attribution Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs. diff --git a/nix/package.nix b/nix/package.nix index 82309bdad..2451454f2 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-Wn7g+VXuUe0LI733MUj+WRX/fJQHOO/HFEkqyHrWTf0="; + npmDepsHash = "sha256-rpXng5y99bDt7a3f6vI+EWrVahlXINdHzCf7ml+ZnFs="; # 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 8bb3d284d..e5b4a4e9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.57-rc.1", + "version": "0.1.58", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.57-rc.1", + "version": "0.1.58", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34854,16 +34854,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.57-rc.1", + "version": "0.1.58", "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.57-rc.1", - "@getpaseo/highlight": "0.1.57-rc.1", - "@getpaseo/server": "0.1.57-rc.1", + "@getpaseo/expo-two-way-audio": "0.1.58", + "@getpaseo/highlight": "0.1.58", + "@getpaseo/server": "0.1.58", "@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.57-rc.1", + "version": "0.1.58", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.57-rc.1", - "@getpaseo/server": "0.1.57-rc.1", + "@getpaseo/relay": "0.1.58", + "@getpaseo/server": "0.1.58", "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.57-rc.1", + "version": "0.1.58", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.57-rc.1", - "@getpaseo/server": "0.1.57-rc.1", + "@getpaseo/cli": "0.1.58", + "@getpaseo/server": "0.1.58", "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.57-rc.1", + "version": "0.1.58", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35288,7 +35288,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.57-rc.1", + "version": "0.1.58", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35314,7 +35314,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.57-rc.1", + "version": "0.1.58", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35330,14 +35330,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.57-rc.1", + "version": "0.1.58", "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.57-rc.1", - "@getpaseo/relay": "0.1.57-rc.1", + "@getpaseo/highlight": "0.1.58", + "@getpaseo/relay": "0.1.58", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35790,7 +35790,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.57-rc.1", + "version": "0.1.58", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index 0a8b24903..8b2d5eaec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.57-rc.1", + "version": "0.1.58", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index 94e0f700d..da4765e58 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.57-rc.1", + "version": "0.1.58", "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.57-rc.1", - "@getpaseo/highlight": "0.1.57-rc.1", - "@getpaseo/server": "0.1.57-rc.1", + "@getpaseo/expo-two-way-audio": "0.1.58", + "@getpaseo/highlight": "0.1.58", + "@getpaseo/server": "0.1.58", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index f4056bfc0..a17ca5fa4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.57-rc.1", + "version": "0.1.58", "description": "Paseo CLI - control your AI coding agents from the command line", "type": "module", "files": [ @@ -24,8 +24,8 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.57-rc.1", - "@getpaseo/server": "0.1.57-rc.1", + "@getpaseo/relay": "0.1.58", + "@getpaseo/server": "0.1.58", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 36843f596..69694482e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.57-rc.1", + "version": "0.1.58", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -13,8 +13,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.57-rc.1", - "@getpaseo/server": "0.1.57-rc.1", + "@getpaseo/cli": "0.1.58", + "@getpaseo/server": "0.1.58", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 0a7d355ec..13b8c2304 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.57-rc.1", + "version": "0.1.58", "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 d3f4892ca..296b054a7 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.57-rc.1", + "version": "0.1.58", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index 00ca118e3..d636fd5eb 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.57-rc.1", + "version": "0.1.58", "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 95db33947..52e5c5a94 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.57-rc.1", + "version": "0.1.58", "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.57-rc.1", - "@getpaseo/relay": "0.1.57-rc.1", + "@getpaseo/highlight": "0.1.58", + "@getpaseo/relay": "0.1.58", "@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 d786a92fa..1d1843c24 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -2849,7 +2849,7 @@ export class DaemonClient { providers: options?.providers, }, responseType: "refresh_providers_snapshot_response", - timeout: 5000, + timeout: 60000, }); } diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index e463c7662..9d14baa8f 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -95,7 +95,7 @@ export function toAgentPayload( model: agent.config.model ?? null, thinkingOptionId, effectiveThinkingOptionId, - runtimeInfo, + ...(runtimeInfo ? { runtimeInfo } : {}), createdAt: agent.createdAt.toISOString(), updatedAt: agent.updatedAt.toISOString(), lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null, @@ -193,8 +193,6 @@ export function buildStoredAgentPayload( availableModes: [], pendingPermissions: [], persistence: toAgentPersistenceHandle(logger, providerRegistry, record.persistence), - lastUsage: undefined, - lastError: undefined, title: record.title ?? record.config?.title ?? null, requiresAttention: record.requiresAttention ?? false, attentionReason: record.attentionReason ?? null, diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 007068afe..6b01c945f 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -2,12 +2,14 @@ import { describe, expect, it, vi } from "vitest"; import { mkdtemp, mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { z } from "zod"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { createAgentMcpServer } from "./mcp-server.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import type { ProviderDefinition } from "./provider-registry.js"; +import { AgentSnapshotPayloadSchema } from "../../shared/messages.js"; type TestDeps = { agentManager: AgentManager; @@ -702,6 +704,59 @@ describe("agent snapshot MCP serialization", () => { ]); }); + it("emits list_agents payloads that satisfy the declared output schema", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const liveAgent = { + id: "live-agent", + provider: "claude", + cwd: "/tmp/live-project", + config: {}, + runtimeInfo: undefined, + createdAt: new Date("2026-04-11T00:00:00.000Z"), + updatedAt: new Date("2026-04-11T00:00:00.000Z"), + lastUserMessageAt: null, + lifecycle: "idle", + capabilities: { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: true, + supportsReasoningStream: false, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + features: [], + pendingPermissions: new Map(), + persistence: null, + labels: {}, + attention: { requiresAttention: false }, + } as unknown as ManagedAgent; + spies.agentManager.listAgents.mockReturnValue([liveAgent]); + spies.agentStorage.list.mockResolvedValue([ + createStoredRecord({ id: "stored-non-archived", archivedAt: null }), + createStoredRecord({ id: "stored-archived", archivedAt: "2026-04-12T00:00:00.000Z" }), + ]); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + logger, + providerRegistry: { + claude: createProviderDefinition({}), + } as any, + }); + const tool = (server as any)._registeredTools["list_agents"]; + const response = await tool.callback({ includeArchived: true }); + + const parsed = z.array(AgentSnapshotPayloadSchema).safeParse(response.structuredContent.agents); + if (!parsed.success) { + throw new Error( + `list_agents response failed AgentSnapshotPayloadSchema: ${JSON.stringify(parsed.error.issues, null, 2)}`, + ); + } + }); + it("loads archived agents before reading get_agent_activity", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); const record = createStoredRecord({ id: "archived-activity-agent" }); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index ecdaedaba..8b7eda2ca 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -40,6 +40,7 @@ import { parseDurationString, resolveProviderAndModel, sanitizePermissionRequest, + sendPromptToAgent, setupFinishNotification, serializeSnapshotWithMetadata, startAgentRun, @@ -772,30 +773,20 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, }, async ({ agentId, prompt, sessionMode, background = false, notifyOnFinish = false }) => { - const snapshot = agentManager.getAgent(agentId); - if (!snapshot) { - throw new Error(`Agent ${agentId} not found`); - } - if (agentManager.hasInFlightRun(agentId)) { waitTracker.cancel(agentId, "Agent run interrupted by new prompt"); } - if (sessionMode) { - await agentManager.setAgentMode(agentId, sessionMode); - } - - try { - agentManager.recordUserMessage(agentId, prompt, { - emitState: false, - }); - } catch (error) { - childLogger.error({ err: error, agentId }, "Failed to record user message"); - } - - startAgentRun(agentManager, agentId, prompt, childLogger, { - replaceRunning: true, + await sendPromptToAgent({ + agentManager, + agentStorage, + agentId, + userMessageText: prompt, + prompt, + sessionMode, + logger: childLogger, }); + if (notifyOnFinish && callerAgentId) { setupFinishNotification({ agentManager, diff --git a/packages/server/src/server/agent/mcp-shared.ts b/packages/server/src/server/agent/mcp-shared.ts index 3edf8f6c7..e3e959985 100644 --- a/packages/server/src/server/agent/mcp-shared.ts +++ b/packages/server/src/server/agent/mcp-shared.ts @@ -1,10 +1,15 @@ import { z } from "zod"; import type { Logger } from "pino"; -import type { AgentPromptInput, AgentPermissionRequest } from "./agent-sdk-types.js"; +import type { + AgentPromptInput, + AgentPermissionRequest, + AgentRunOptions, +} from "./agent-sdk-types.js"; import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js"; import { curateAgentActivity } from "./activity-curator.js"; import type { AgentStorage } from "./agent-storage.js"; +import { ensureAgentLoaded } from "./agent-loading.js"; import { serializeAgentSnapshot } from "../messages.js"; import { StoredScheduleSchema } from "../schedule/types.js"; import type { AgentProvider } from "./agent-sdk-types.js"; @@ -94,6 +99,7 @@ export function resolveProviderAndModel(params: { export type StartAgentRunOptions = { replaceRunning?: boolean; + runOptions?: AgentRunOptions; }; /** @@ -171,9 +177,10 @@ export function startAgentRun( options?: StartAgentRunOptions, ): void { const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId)); + const runOptions = options?.runOptions; const iterator = shouldReplace - ? agentManager.replaceAgentRun(agentId, prompt) - : agentManager.streamAgent(agentId, prompt); + ? agentManager.replaceAgentRun(agentId, prompt, runOptions) + : agentManager.streamAgent(agentId, prompt, runOptions); void (async () => { try { for await (const _ of iterator) { @@ -185,6 +192,92 @@ export function startAgentRun( })(); } +/** + * Clear the archived flag from a stored agent record. + * Shared across Session (app/WS), MCP, and CLI so every surface that acts on + * an archived agent unarchives it the same way. + */ +export async function unarchiveAgentState( + agentStorage: AgentStorage, + agentManager: AgentManager, + agentId: string, +): Promise { + const record = await agentStorage.get(agentId); + if (!record || !record.archivedAt) { + return false; + } + const updatedAt = new Date().toISOString(); + await agentStorage.upsert({ + ...record, + archivedAt: null, + updatedAt, + }); + agentManager.notifyAgentState(agentId); + return true; +} + +export interface SendPromptToAgentParams { + agentManager: AgentManager; + agentStorage: AgentStorage; + agentId: string; + /** Raw user text to record in the timeline. */ + userMessageText: string; + /** Prompt to dispatch to the provider (may include image blocks or wrapped text). */ + prompt: AgentPromptInput; + messageId?: string; + runOptions?: AgentRunOptions; + /** Optional mode to set on the agent before the run starts. */ + sessionMode?: string; + logger: Logger; +} + +/** + * Full send-prompt orchestration: unarchive → load → (optional mode change) → + * record user message → start run. + * + * Every surface that sends a prompt to an agent (Session/WS, MCP, CLI-through-MCP) + * MUST go through this so behavior can never drift between them. + */ +export async function sendPromptToAgent(params: SendPromptToAgentParams): Promise { + const { + agentManager, + agentStorage, + agentId, + userMessageText, + prompt, + messageId, + runOptions, + sessionMode, + logger, + } = params; + + await unarchiveAgentState(agentStorage, agentManager, agentId); + + await ensureAgentLoaded(agentId, { + agentManager, + agentStorage, + logger, + }); + + if (sessionMode) { + await agentManager.setAgentMode(agentId, sessionMode); + } + + try { + agentManager.recordUserMessage(agentId, userMessageText, { + messageId, + emitState: false, + }); + } catch (error) { + logger.error({ err: error, agentId }, "Failed to record user message"); + } + + startAgentRun(agentManager, agentId, prompt, logger, { + replaceRunning: true, + runOptions, + }); +} + interface SetupFinishNotificationParams { agentManager: AgentManager; agentStorage: AgentStorage; diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index f334aaa22..ab4b73807 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1145,6 +1145,7 @@ export class ClaudeAgentClient implements AgentClient { const resolvedBinary = (await findExecutable("claude")) ?? "not found"; const available = await this.isAvailable(); const version = await resolveClaudeVersion(this.runtimeSettings); + const auth = available ? await resolveClaudeAuth(this.runtimeSettings) : null; let modelsValue = "Not checked"; let status = formatDiagnosticStatus(available); @@ -1165,6 +1166,7 @@ export class ClaudeAgentClient implements AgentClient { diagnostic: formatProviderDiagnostic("Claude Code", [ { label: "Binary", value: resolvedBinary }, ...(version ? [{ label: "Version", value: version }] : []), + ...(auth ? [{ label: "Auth", value: auth }] : []), { label: "Models", value: modelsValue }, { label: "Status", value: status }, ]), @@ -1211,6 +1213,48 @@ async function resolveClaudeVersion( } } +async function resolveClaudeAuth( + runtimeSettings?: ProviderRuntimeSettings, +): Promise { + const command = runtimeSettings?.command; + + const run = async ( + executable: string, + args: string[], + ): Promise<{ stdout: string; stderr: string }> => { + try { + return await execCommand(executable, args, { timeout: 5_000 }); + } catch (error) { + const err = error as { stdout?: string; stderr?: string; message?: string }; + return { + stdout: err.stdout ?? "", + stderr: err.stderr ?? err.message ?? "", + }; + } + }; + + try { + let result: { stdout: string; stderr: string }; + if (command?.mode === "replace") { + result = await run(command.argv[0]!, [...command.argv.slice(1), "auth", "status"]); + } else { + const executable = await findExecutable("claude"); + if (!executable) { + return null; + } + result = await run(executable, ["auth", "status"]); + } + + const combined = [result.stdout, result.stderr] + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .join("\n"); + return combined || null; + } catch { + return null; + } +} + function extractContextWindowSize(modelUsage: unknown): number | undefined { if (!modelUsage || typeof modelUsage !== "object") { return undefined; diff --git a/packages/server/src/server/agent/providers/provider-windows-launch.test.ts b/packages/server/src/server/agent/providers/provider-windows-launch.test.ts new file mode 100644 index 000000000..c099b2561 --- /dev/null +++ b/packages/server/src/server/agent/providers/provider-windows-launch.test.ts @@ -0,0 +1,217 @@ +import type { ChildProcess } from "node:child_process"; +import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; + +import { findExecutable } from "../../../utils/executable.js"; +import { spawnProcess } from "../../../utils/spawn.js"; + +type SpawnResult = { + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + error: Error | null; +}; + +type ProviderLaunchCase = { + provider: "claude" | "codex" | "opencode" | "generic-acp"; + binaryName: string; + args: string[]; + shell?: boolean; +}; + +const JSON_ARG = '{"mcpServers":{"paseo":{"type":"http","url":"http://127.0.0.1:6767/mcp"}}}'; +const tempDirs: string[] = []; + +function makeFixture(binaryName: string, expectedArgs: string[]) { + const root = mkdtempSync(path.join(tmpdir(), `paseo ${binaryName} launch `)); + tempDirs.push(root); + + const fakeDaemonNode = path.join(root, "Fake Paseo.exe"); + copyFileSync(process.execPath, fakeDaemonNode); + + const assertScript = path.join(root, "assert-argv.js"); + writeFileSync( + assertScript, + ` +if (process.argv.includes("--version")) { + console.log("fake-provider 1.0.0"); + process.exit(0); +} + +const expected = JSON.parse(process.env.PASEO_EXPECTED_ARGV_JSON); +const actual = process.argv.slice(2); +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + console.error("ARGV_MISMATCH"); + console.error(JSON.stringify({ expected, actual })); + process.exit(42); +} +console.log("ARGV_OK"); +`, + ); + + const shim = path.join(root, `${binaryName}.cmd`); + writeFileSync( + shim, + ["@echo off", "setlocal", `\"${fakeDaemonNode}\" \"${assertScript}\" %*`, ""].join("\r\n"), + ); + + return { root, shim, expectedArgs }; +} + +function collectChild(child: ChildProcess, timeoutMs = 10_000): Promise { + return new Promise((resolve) => { + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let error: Error | null = null; + let settled = false; + + const settle = (result: Pick) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve({ + ...result, + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + error, + }); + }; + + const timer = setTimeout(() => { + child.kill("SIGKILL"); + settle({ code: null, signal: "SIGKILL" }); + }, timeoutMs); + timer.unref?.(); + + child.stdout?.on("data", (chunk: Buffer | string) => { + stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once("error", (err) => { + error = err; + settle({ code: null, signal: null }); + }); + child.once("exit", (code, signal) => { + settle({ code, signal }); + }); + }); +} + +async function runProviderFixture(params: { + command: string; + args: string[]; + expectedArgs: string[]; + shell?: boolean; +}): Promise { + const child = spawnProcess(params.command, params.args, { + env: { + ...process.env, + PASEO_EXPECTED_ARGV_JSON: JSON.stringify(params.expectedArgs), + }, + stdio: ["ignore", "pipe", "pipe"], + ...(params.shell === undefined ? {} : { shell: params.shell }), + }); + return collectChild(child); +} + +function withPathEntry(dir: string, run: () => Promise): Promise { + const pathKey = + process.platform === "win32" + ? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path") + : "PATH"; + const previousPath = process.env[pathKey]; + process.env[pathKey] = previousPath ? `${dir}${path.delimiter}${previousPath}` : dir; + + return run().finally(() => { + if (previousPath === undefined) { + delete process.env[pathKey]; + } else { + process.env[pathKey] = previousPath; + } + }); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +const providerLaunchCases: ProviderLaunchCase[] = [ + { + provider: "claude", + binaryName: "claude", + args: ["--mcp-config", JSON_ARG], + shell: false, + }, + { + provider: "codex", + binaryName: "codex", + args: ["app-server", "--config", JSON_ARG], + }, + { + provider: "opencode", + binaryName: "opencode", + args: ["serve", "--port", "49271", "--config", JSON_ARG], + }, + { + provider: "generic-acp", + binaryName: "generic-acp", + args: ["--mcp-config", JSON_ARG], + }, +]; + +describe.runIf(process.platform === "win32")("Windows provider launch parity", () => { + test("detected claude.cmd can be launched with Claude's JSON-safe spawn shape", async () => { + const args = ["--mcp-config", JSON_ARG]; + const fixture = makeFixture("claude", args); + + await withPathEntry(fixture.root, async () => { + const detected = await findExecutable("claude"); + expect(detected?.toLowerCase()).toBe(fixture.shim.toLowerCase()); + + const result = await runProviderFixture({ + command: detected!, + args, + expectedArgs: args, + shell: false, + }); + + expect(result.error).toBeNull(); + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("ARGV_OK"); + }); + }); + + test.each( + providerLaunchCases, + )("$provider launches a cmd shim from a path with spaces through spawnProcess", async ({ + binaryName, + args, + shell, + }) => { + const fixture = makeFixture(binaryName, args); + + const result = await runProviderFixture({ + command: fixture.shim, + args: fixture.expectedArgs, + expectedArgs: fixture.expectedArgs, + shell, + }); + + expect(result.error).toBeNull(); + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("ARGV_OK"); + }); +}); diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index d7a61025d..bf1085d6b 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -170,7 +170,7 @@ export function toAgentPersistenceHandle( return { provider, sessionId: handle.sessionId, - nativeHandle: handle.nativeHandle, - metadata: handle.metadata, + ...(handle.nativeHandle !== undefined ? { nativeHandle: handle.nativeHandle } : {}), + ...(handle.metadata !== undefined ? { metadata: handle.metadata } : {}), } satisfies AgentPersistenceHandle; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 05dc7c0aa..35e79e753 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -68,6 +68,7 @@ import { toAgentPersistenceHandle, } from "./persistence-hooks.js"; import { ensureAgentLoaded } from "./agent/agent-loading.js"; +import { sendPromptToAgent, unarchiveAgentState } from "./agent/mcp-shared.js"; import { experimental_createMCPClient } from "ai"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; @@ -298,7 +299,7 @@ type ProcessingPhase = "idle" | "transcribing"; type WorkspaceGitWatchTarget = { cwd: string; watchers: FSWatcher[]; - debounceTimer: NodeJS.Timeout | null; + debounceTimer: ReturnType | null; refreshPromise: Promise | null; refreshQueued: boolean; latestFingerprint: string | null; @@ -600,7 +601,7 @@ export class Session { private isVoiceMode = false; private speechInProgress = false; private pendingVoiceSpeechStartAt: number | null = null; - private pendingVoiceSpeechTimer: NodeJS.Timeout | null = null; + private pendingVoiceSpeechTimer: ReturnType | null = null; private readonly dictationStreamManager: DictationStreamManager; private readonly resolveVoiceTurnDetection: () => TurnDetectionProvider | null; @@ -611,7 +612,7 @@ export class Session { // Audio buffering for interruption handling private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = []; - private bufferTimeout: NodeJS.Timeout | null = null; + private bufferTimeout: ReturnType | null = null; private audioBuffer: AudioBufferState | null = null; // Optional TTS debug capture (persisted per utterance) @@ -2224,12 +2225,17 @@ export class Session { }); } - private async unarchiveAgentState(agentId: string): Promise { - return this.agentManager.unarchiveSnapshot(agentId); - } - private async unarchiveAgentByHandle(handle: AgentPersistenceHandle): Promise { - await this.agentManager.unarchiveSnapshotByHandle(handle); + const records = await this.agentStorage.list(); + const matched = records.find( + (record) => + record.persistence?.provider === handle.provider && + record.persistence?.sessionId === handle.sessionId, + ); + if (!matched) { + return; + } + await unarchiveAgentState(this.agentStorage, this.agentManager, matched.id); } private async handleUpdateAgentRequest( @@ -2723,38 +2729,28 @@ export class Session { }`, ); - await this.unarchiveAgentState(agentId); + const promptText = options?.spokenInput ? wrapSpokenInput(text) : text; + const prompt = this.buildAgentPrompt(promptText, images, attachments); try { - await ensureAgentLoaded(agentId, { + await sendPromptToAgent({ agentManager: this.agentManager, agentStorage: this.agentStorage, + agentId, + userMessageText: text, + prompt, + messageId, + runOptions, logger: this.sessionLogger, }); + return { ok: true }; } catch (error) { - this.handleAgentRunError(agentId, error, "Failed to initialize agent before sending prompt"); + this.handleAgentRunError(agentId, error, "Failed to send agent message"); return { ok: false, error: error instanceof Error ? error.message : String(error), }; } - - try { - this.agentManager.recordUserMessage(agentId, text, { - messageId, - emitState: false, - }); - } catch (error) { - this.sessionLogger.error( - { err: error, agentId }, - `Failed to record user message for agent ${agentId}`, - ); - } - - const promptText = options?.spokenInput ? wrapSpokenInput(text) : text; - const prompt = this.buildAgentPrompt(promptText, images, attachments); - - return this.startAgentStream(agentId, prompt, runOptions); } /** @@ -2931,7 +2927,7 @@ export class Session { try { await this.unarchiveAgentByHandle(handle); const snapshot = await this.agentManager.resumeAgentFromPersistence(handle, overrides); - await this.unarchiveAgentState(snapshot.id); + await unarchiveAgentState(this.agentStorage, this.agentManager, snapshot.id); await this.agentManager.hydrateTimelineFromProvider(snapshot.id); await this.forwardAgentUpdate(snapshot); const timelineSize = this.agentManager.getTimeline(snapshot.id).length; @@ -2969,7 +2965,7 @@ export class Session { this.sessionLogger.info({ agentId }, `Refreshing agent ${agentId} from persistence`); try { - await this.unarchiveAgentState(agentId); + await unarchiveAgentState(this.agentStorage, this.agentManager, agentId); let snapshot: ManagedAgent; const existing = this.agentManager.getAgent(agentId); if (existing) { @@ -6751,44 +6747,32 @@ export class Session { try { const agentId = resolved.agentId; - await this.unarchiveAgentState(agentId); - - await ensureAgentLoaded(agentId, { - agentManager: this.agentManager, - agentStorage: this.agentStorage, - logger: this.sessionLogger, - }); - - this.sessionLogger.trace( - { agentId, messageId: msg.messageId, textPrefix: msg.text.slice(0, 80) }, - "send_agent_message_request: recording user message", - ); - try { - this.agentManager.recordUserMessage(agentId, msg.text, { - messageId: msg.messageId, - emitState: false, - }); - } catch (error) { - this.sessionLogger.error( - { err: error, agentId }, - "Failed to record user message for send_agent_message_request", - ); - } const prompt = this.buildAgentPrompt(msg.text, msg.images, msg.attachments); this.sessionLogger.trace( - { agentId, messageId: msg.messageId }, - "send_agent_message_request: starting agent stream", + { agentId, messageId: msg.messageId, textPrefix: msg.text.slice(0, 80) }, + "send_agent_message_request: dispatching shared sendPromptToAgent", ); - const started = this.startAgentStream(agentId, prompt); - if (!started.ok) { + try { + await sendPromptToAgent({ + agentManager: this.agentManager, + agentStorage: this.agentStorage, + agentId, + userMessageText: msg.text, + prompt, + messageId: msg.messageId, + logger: this.sessionLogger, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.handleAgentRunError(agentId, error, "Failed to send agent message"); this.emit({ type: "send_agent_message_response", payload: { requestId: msg.requestId, agentId, accepted: false, - error: started.error, + error: message, }, }); return; @@ -7681,9 +7665,9 @@ export class Session { this.workspaceGitSubscriptions.clear(); } - // ============================================================================ + // ---------------------------------------------------------------------------- // Terminal Handlers - // ============================================================================ + // ---------------------------------------------------------------------------- private ensureTerminalExitSubscription(terminal: TerminalSession): void { if (this.terminalExitSubscriptions.has(terminal.id)) { diff --git a/packages/server/src/utils/executable.test.ts b/packages/server/src/utils/executable.test.ts index b24dc2c1a..9a6d1e0fb 100644 --- a/packages/server/src/utils/executable.test.ts +++ b/packages/server/src/utils/executable.test.ts @@ -138,7 +138,7 @@ describe("executableExists", () => { expect(executableExists("/usr/local/bin/codex", exists)).toBe("/usr/local/bin/codex"); }); - test("on Windows, falls back to .exe, .cmd, then .ps1 for extensionless paths", () => { + test("on Windows, falls back to .exe, then .cmd for extensionless paths", () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "win32", writable: true }); try { @@ -150,6 +150,18 @@ describe("executableExists", () => { } }); + test("on Windows, ignores PowerShell scripts for extensionless paths", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", writable: true }); + try { + const exists = (candidate: string) => candidate === "C:\\tools\\codex.ps1"; + + expect(executableExists("C:\\tools\\codex", exists)).toBeNull(); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, writable: true }); + } + }); + test("returns null when no matching path exists", () => { expect(executableExists("/missing/codex", () => false)).toBeNull(); }); diff --git a/packages/server/src/utils/executable.ts b/packages/server/src/utils/executable.ts index 051a4994b..f06786b3f 100644 --- a/packages/server/src/utils/executable.ts +++ b/packages/server/src/utils/executable.ts @@ -35,7 +35,7 @@ async function enumerateCandidates(name: string): Promise { }); } -function isWindowsCommandScript(executablePath: string): boolean { +export function isWindowsCommandScript(executablePath: string): boolean { const extension = extname(executablePath).toLowerCase(); return process.platform === "win32" && (extension === ".cmd" || extension === ".bat"); } @@ -102,7 +102,7 @@ export function executableExists( ): string | null { if (exists(executablePath)) return executablePath; if (process.platform === "win32" && !extname(executablePath)) { - for (const ext of [".exe", ".cmd", ".ps1"]) { + for (const ext of [".exe", ".cmd"]) { const candidate = executablePath + ext; if (exists(candidate)) return candidate; } @@ -141,8 +141,11 @@ function escapeWindowsCmdValue(value: string): string { const unquoted = isQuoted ? value.slice(1, -1) : value; const escaped = unquoted.replace(/%/g, "%%").replace(/([&|^<>()!])/g, "^$1"); - if (isQuoted || escaped.includes(" ")) { - return `"${escaped}"`; + if (isQuoted || /[\s"]/u.test(unquoted)) { + const quoted = escaped + .replace(/(\\*)"/g, (_match, slashes: string) => `${slashes}${slashes}\\"`) + .replace(/\\+$/u, (slashes) => `${slashes}${slashes}`); + return `"${quoted}"`; } return escaped; diff --git a/packages/server/src/utils/spawn.launch-regression.test.ts b/packages/server/src/utils/spawn.launch-regression.test.ts new file mode 100644 index 000000000..c1087962b --- /dev/null +++ b/packages/server/src/utils/spawn.launch-regression.test.ts @@ -0,0 +1,244 @@ +import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { ChildProcess } from "node:child_process"; +import { afterEach, describe, expect, test } from "vitest"; + +import { findExecutable } from "./executable.js"; +import { spawnProcess } from "./spawn.js"; + +type SpawnResult = { + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + error: Error | null; +}; + +const tempDirs: string[] = []; +const JSON_ARG = '{"key":"value with spaces","nested":{"quote":"\\"yes\\""}}'; + +function makeFixture(options?: { includeCmdShim?: boolean }): { + root: string; + fakeDaemonNode: string; + shim: string; + powerShellShim: string; + assertScript: string; + expectedArgs: string[]; +} { + const root = mkdtempSync(path.join(tmpdir(), "paseo spawn regression ")); + tempDirs.push(root); + + const fakeDaemonNode = path.join(root, "Fake Paseo.exe"); + copyFileSync(process.execPath, fakeDaemonNode); + + const expectedArgs = ["--config", JSON_ARG]; + const assertScript = path.join(root, "assert-argv.js"); + writeFileSync( + assertScript, + ` +if (process.argv.includes("--version")) { + console.log("fake-provider 1.0.0"); + process.exit(0); +} + +const expected = JSON.parse(process.env.PASEO_EXPECTED_ARGV_JSON); +const actual = process.argv.slice(2); +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + console.error("ARGV_MISMATCH"); + console.error(JSON.stringify({ expected, actual })); + process.exit(42); +} +console.log("ARGV_OK"); +`, + ); + + const shim = path.join(root, "claude.cmd"); + if (options?.includeCmdShim !== false) { + writeFileSync( + shim, + ["@echo off", "setlocal", `\"${fakeDaemonNode}\" \"${assertScript}\" %*`, ""].join("\r\n"), + ); + } + + const powerShellShim = path.join(root, "claude.ps1"); + writeFileSync( + powerShellShim, + [ + "$ErrorActionPreference = 'Stop'", + `& '${fakeDaemonNode.replace(/'/g, "''")}' '${assertScript.replace(/'/g, "''")}' @args`, + "exit $LASTEXITCODE", + "", + ].join("\r\n"), + ); + + return { root, fakeDaemonNode, shim, powerShellShim, assertScript, expectedArgs }; +} + +function collectChild(child: ChildProcess, timeoutMs = 10_000): Promise { + return new Promise((resolve) => { + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let error: Error | null = null; + let settled = false; + + const settle = (result: Pick) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve({ + ...result, + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + error, + }); + }; + + const timer = setTimeout(() => { + child.kill("SIGKILL"); + settle({ code: null, signal: "SIGKILL" }); + }, timeoutMs); + timer.unref?.(); + + child.stdout?.on("data", (chunk: Buffer | string) => { + stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once("error", (err) => { + error = err; + settle({ code: null, signal: null }); + }); + child.once("exit", (code, signal) => { + settle({ code, signal }); + }); + }); +} + +async function runFixture(params: { + command: string; + args: string[]; + shell?: boolean; +}): Promise { + const child = spawnProcess(params.command, params.args, { + env: { + ...process.env, + PASEO_EXPECTED_ARGV_JSON: JSON.stringify(["--config", JSON_ARG]), + }, + stdio: ["ignore", "pipe", "pipe"], + ...(params.shell === undefined ? {} : { shell: params.shell }), + }); + return collectChild(child); +} + +function withWindowsPathEntry(dir: string, run: () => Promise): Promise { + const pathKey = + process.platform === "win32" + ? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path") + : "PATH"; + const previousPath = process.env[pathKey]; + const previousPathExt = process.env.PATHEXT; + + process.env[pathKey] = previousPath ? `${dir}${path.delimiter}${previousPath}` : dir; + process.env.PATHEXT = previousPathExt?.toLowerCase().includes(".ps1") + ? previousPathExt + : `${previousPathExt ?? ""};.PS1`; + + return run().finally(() => { + if (previousPath === undefined) { + delete process.env[pathKey]; + } else { + process.env[pathKey] = previousPath; + } + + if (previousPathExt === undefined) { + delete process.env.PATHEXT; + } else { + process.env.PATHEXT = previousPathExt; + } + }); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe.runIf(process.platform === "win32")("Windows spawn launch regression", () => { + test("launches a cmd shim from a path with spaces without corrupting JSON args", async () => { + const fixture = makeFixture(); + + const result = await runFixture({ + command: fixture.shim, + args: fixture.expectedArgs, + }); + + expect(result.error).toBeNull(); + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("ARGV_OK"); + }); + + test("launches a cmd shim even when the caller explicitly disables shell", async () => { + const fixture = makeFixture(); + + const result = await runFixture({ + command: fixture.shim, + args: fixture.expectedArgs, + shell: false, + }); + + expect(result.error).toBeNull(); + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("ARGV_OK"); + }); + + test("direct launch with a space-containing executable preserves JSON args", async () => { + const fixture = makeFixture(); + + const result = await runFixture({ + command: fixture.fakeDaemonNode, + args: [fixture.assertScript, ...fixture.expectedArgs], + shell: false, + }); + + expect(result.error).toBeNull(); + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("ARGV_OK"); + }); + + test("does not detect a PowerShell shim from PATH", async () => { + const fixture = makeFixture({ includeCmdShim: false }); + + await withWindowsPathEntry(fixture.root, async () => { + const detected = await findExecutable("claude"); + expect(detected).toBeNull(); + }); + }); +}); + +describe.skipIf(process.platform === "win32")("spawn launch regression smoke", () => { + test("direct launch with a space-containing executable works on this platform", async () => { + const fixture = makeFixture(); + + const result = await runFixture({ + command: fixture.fakeDaemonNode, + args: [fixture.assertScript, ...fixture.expectedArgs], + shell: false, + }); + + expect(result.error).toBeNull(); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("ARGV_OK"); + }); +}); diff --git a/packages/server/src/utils/spawn.ts b/packages/server/src/utils/spawn.ts index bab7b047a..f7a0b0881 100644 --- a/packages/server/src/utils/spawn.ts +++ b/packages/server/src/utils/spawn.ts @@ -1,7 +1,7 @@ import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; import { promisify } from "node:util"; -import { quoteWindowsArgument, quoteWindowsCommand } from "./executable.js"; +import { isWindowsCommandScript, quoteWindowsArgument, quoteWindowsCommand } from "./executable.js"; const execFileAsync = promisify(execFile); @@ -24,13 +24,15 @@ export function spawnProcess( options?: SpawnOptions, ): ChildProcess { const isWindows = process.platform === "win32"; + const shell = isWindowsCommandScript(command) ? true : (options?.shell ?? isWindows); - const resolvedCommand = isWindows ? quoteWindowsCommand(command) : command; - const resolvedArgs = isWindows ? args.map(quoteWindowsArgument) : args; + const shouldQuoteForShell = isWindows && shell !== false; + const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; + const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; return spawn(resolvedCommand, resolvedArgs, { ...options, - shell: options?.shell ?? isWindows, + shell, windowsHide: true, }); } @@ -41,8 +43,10 @@ export async function execCommand( options?: ExecCommandOptions, ): Promise { const isWindows = process.platform === "win32"; - const resolvedCommand = isWindows ? quoteWindowsCommand(command) : command; - const resolvedArgs = isWindows ? args.map(quoteWindowsArgument) : args; + const shell = isWindowsCommandScript(command) ? true : isWindows; + const shouldQuoteForShell = isWindows && shell !== false; + const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; + const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; return execFileAsync(resolvedCommand, resolvedArgs, { cwd: options?.cwd, @@ -50,7 +54,7 @@ export async function execCommand( encoding: options?.encoding ?? "utf8", timeout: options?.timeout, maxBuffer: options?.maxBuffer, - shell: isWindows, + shell, windowsHide: true, }) as Promise; } diff --git a/packages/website/package.json b/packages/website/package.json index d2b5bc0b0..2cb764d0e 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.57-rc.1", + "version": "0.1.58", "private": true, "type": "module", "scripts": {