fix: centralize Windows exec handling and async migrations (#318)

* Add Windows exec foundation helpers

* server: centralize windows exec handling phase 2

* migrate cli daemon status to execCommand

* refactor: complete phase 4 async exec migrations

* test: cover windows exec helpers

* fix: use beforeEach for context.skip in test setup
This commit is contained in:
Mohamed Boudra
2026-04-13 16:20:59 +08:00
committed by GitHub
parent f0c59ec534
commit bf76915884
44 changed files with 3048 additions and 2781 deletions
@@ -1,5 +1,5 @@
import { spawnSync } from "node:child_process";
import { platform } from "node:os"; import { platform } from "node:os";
import { execCommand } from "@getpaseo/server";
export interface NodePathFromPidResult { export interface NodePathFromPidResult {
nodePath: string | null; nodePath: string | null;
@@ -13,63 +13,37 @@ function normalizeError(error: unknown): string {
return String(error); return String(error);
} }
function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult { async function resolveNodePathFromPidUnix(pid: number): Promise<NodePathFromPidResult> {
const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], { try {
encoding: "utf8", const { stdout } = await execCommand("ps", ["-o", "comm=", "-p", String(pid)]);
stdio: ["ignore", "pipe", "pipe"], const resolved = stdout.trim();
});
if (result.error) {
return { nodePath: null, error: `ps failed: ${normalizeError(result.error)}` };
}
if ((result.status ?? 1) !== 0) {
const details = result.stderr?.trim();
return {
nodePath: null,
error: details ? `ps failed: ${details}` : `ps exited with code ${result.status ?? 1}`,
};
}
const resolved = result.stdout.trim();
return resolved return resolved
? { nodePath: resolved } ? { nodePath: resolved }
: { nodePath: null, error: "ps returned an empty command path" }; : { nodePath: null, error: "ps returned an empty command path" };
} catch (error) {
return { nodePath: null, error: `ps failed: ${normalizeError(error)}` };
}
} }
function runProcessProbe( async function runProcessProbe(
command: string, command: string,
args: string[], args: string[],
): { ): Promise<{
resolved: string | null; resolved: string | null;
error?: string; error?: string;
} { }> {
const result = spawnSync(command, args, { try {
encoding: "utf8", const { stdout } = await execCommand(command, args);
stdio: ["ignore", "pipe", "pipe"], const resolved = stdout.trim();
});
if (result.error) {
return { resolved: null, error: `${command} failed: ${normalizeError(result.error)}` };
}
if ((result.status ?? 1) !== 0) {
const details = result.stderr?.trim();
return {
resolved: null,
error: details
? `${command} failed: ${details}`
: `${command} exited with code ${result.status ?? 1}`,
};
}
const resolved = result.stdout.trim();
return resolved return resolved
? { resolved } ? { resolved }
: { resolved: null, error: `${command} returned no executable path` }; : { resolved: null, error: `${command} returned no executable path` };
} catch (error) {
return { resolved: null, error: `${command} failed: ${normalizeError(error)}` };
}
} }
function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult { async function resolveNodePathFromPidWindows(pid: number): Promise<NodePathFromPidResult> {
const probes: Array<{ const probes: Array<{
label: string; label: string;
command: string; command: string;
@@ -103,7 +77,7 @@ function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
const errors: string[] = []; const errors: string[] = [];
for (const probe of probes) { for (const probe of probes) {
const result = runProcessProbe(probe.command, probe.args); const result = await runProcessProbe(probe.command, probe.args);
if (result.resolved) { if (result.resolved) {
const resolved = probe.parseValue ? probe.parseValue(result.resolved) : result.resolved; const resolved = probe.parseValue ? probe.parseValue(result.resolved) : result.resolved;
if (resolved) { if (resolved) {
@@ -123,8 +97,8 @@ function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
}; };
} }
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult { export async function resolveNodePathFromPid(pid: number): Promise<NodePathFromPidResult> {
return platform() === "win32" return platform() === "win32"
? resolveNodePathFromPidWindows(pid) ? await resolveNodePathFromPidWindows(pid)
: resolveNodePathFromPidUnix(pid); : await resolveNodePathFromPidUnix(pid);
} }
+8 -9
View File
@@ -1,10 +1,11 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { execFile } from "node:child_process";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { promisify } from "node:util"; import {
import { getOrCreateServerId, findExecutable, applyProviderEnv } from "@getpaseo/server"; getOrCreateServerId,
findExecutable,
const execFileAsync = promisify(execFile); applyProviderEnv,
execCommand,
} from "@getpaseo/server";
import { tryConnectToDaemon } from "../../utils/client.js"; import { tryConnectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
@@ -178,11 +179,9 @@ async function checkProviderBinary(
} }
const env = applyProviderEnv(process.env); const env = applyProviderEnv(process.env);
try { try {
const { stdout } = await execFileAsync(binaryPath, ["--version"], { const { stdout } = await execCommand(binaryPath, ["--version"], {
encoding: "utf8",
timeout: 5000, timeout: 5000,
env, env,
windowsHide: true,
}); });
return { path: binaryPath, version: stdout.trim() || null }; return { path: binaryPath, version: stdout.trim() || null };
} catch { } catch {
@@ -224,7 +223,7 @@ export async function runStatusCommand(
if (!state.running) { if (!state.running) {
daemonNode = "-"; daemonNode = "-";
} else if (state.pidInfo?.pid) { } else if (state.pidInfo?.pid) {
const fromPid = resolveNodePathFromPid(state.pidInfo.pid); const fromPid = await resolveNodePathFromPid(state.pidInfo.pid);
daemonNode = fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`; daemonNode = fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
} else { } else {
daemonNode = "unknown (no PID available)"; daemonNode = "unknown (no PID available)";
+5 -3
View File
@@ -123,11 +123,13 @@ async function verifyInjectedMcpForProvider(
} }
async function main(): Promise<void> { async function main(): Promise<void> {
if (!isProviderAvailable("claude")) { const claudeAvailable = await isProviderAvailable("claude");
if (!claudeAvailable) {
throw new Error( throw new Error(
"Claude is not available in this environment. Ensure the `claude` binary and credentials are configured.", "Claude is not available in this environment. Ensure the `claude` binary and credentials are configured.",
); );
} }
const codexAvailable = await isProviderAvailable("codex");
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const rootCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-mcp-inject-real-")); const rootCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-mcp-inject-real-"));
@@ -136,7 +138,7 @@ async function main(): Promise<void> {
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
agentClients: { agentClients: {
claude: new ClaudeAgentClient({ logger }), claude: new ClaudeAgentClient({ logger }),
...(isProviderAvailable("codex") ? { codex: new CodexAppServerAgentClient(logger) } : {}), ...(codexAvailable ? { codex: new CodexAppServerAgentClient(logger) } : {}),
}, },
logger, logger,
}); });
@@ -159,7 +161,7 @@ async function main(): Promise<void> {
results.push(claudeResult); results.push(claudeResult);
console.log(`[PASS] Claude MCP injection verified for agent ${claudeResult.agentId}`); console.log(`[PASS] Claude MCP injection verified for agent ${claudeResult.agentId}`);
if (isProviderAvailable("codex")) { if (codexAvailable) {
const codexResult = await verifyInjectedMcpForProvider(client, "codex", codexCwd); const codexResult = await verifyInjectedMcpForProvider(client, "codex", codexCwd);
createdAgentIds.push(codexResult.agentId); createdAgentIds.push(codexResult.agentId);
results.push(codexResult); results.push(codexResult);
@@ -15,13 +15,12 @@
* This pattern is used in claude-agent.ts listModels(). * This pattern is used in claude-agent.ts listModels().
*/ */
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { isCommandAvailableSync } from "../utils/executable.js"; import { isCommandAvailable } from "../utils/executable.js";
const hasClaudeCredentials = const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials;
// Pattern from claude-agent.ts listModels(): // Pattern from claude-agent.ts listModels():
// Use an empty async generator when you just need control methods // Use an empty async generator when you just need control methods
@@ -30,10 +29,20 @@ function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
} }
describe("Claude Agent SDK Commands POC", () => { describe("Claude Agent SDK Commands POC", () => {
let canRunClaudeIntegration = false;
beforeAll(async () => {
canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials;
});
beforeEach((context) => {
if (!canRunClaudeIntegration) {
context.skip();
}
});
describe("supportedCommands() API", () => { describe("supportedCommands() API", () => {
test.runIf(canRunClaudeIntegration)( test("should return an array of SlashCommand objects", async () => {
"should return an array of SlashCommand objects",
async () => {
// Use the pattern from claude-agent.ts: // Use the pattern from claude-agent.ts:
// Create a query with empty prompt generator for control methods // Create a query with empty prompt generator for control methods
const emptyPrompt = createEmptyPrompt(); const emptyPrompt = createEmptyPrompt();
@@ -72,13 +81,9 @@ describe("Claude Agent SDK Commands POC", () => {
} }
} }
} }
}, }, 30000);
30000,
);
test.runIf(canRunClaudeIntegration)( test("should have valid SlashCommand structure for all commands", async () => {
"should have valid SlashCommand structure for all commands",
async () => {
const emptyPrompt = createEmptyPrompt(); const emptyPrompt = createEmptyPrompt();
const claudeQuery = query({ const claudeQuery = query({
@@ -109,9 +114,7 @@ describe("Claude Agent SDK Commands POC", () => {
} finally { } finally {
await claudeQuery.return?.(); await claudeQuery.return?.();
} }
}, }, 30000);
30000,
);
}); });
describe("Command Execution", () => { describe("Command Execution", () => {
@@ -10,14 +10,14 @@
* CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment. * CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment.
* They are skipped automatically when credentials are unavailable. * They are skipped automatically when credentials are unavailable.
*/ */
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import pino from "pino"; import pino from "pino";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js"; import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import { isCommandAvailableSync } from "../../../../utils/executable.js"; import { isCommandAvailable } from "../../../../utils/executable.js";
import { ClaudeAgentClient } from "../claude-agent.js"; import { ClaudeAgentClient } from "../claude-agent.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -28,7 +28,6 @@ const logger = pino({ level: "silent" });
const client = new ClaudeAgentClient({ logger }); const client = new ClaudeAgentClient({ logger });
const hasClaudeCredentials = const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
const canRun = isCommandAvailableSync("claude") && hasClaudeCredentials;
function tmpCwd(prefix: string): string { function tmpCwd(prefix: string): string {
return mkdtempSync(path.join(tmpdir(), prefix)); return mkdtempSync(path.join(tmpdir(), prefix));
@@ -194,9 +193,19 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("Agent event stream redesign — integration", () => { describe("Agent event stream redesign — integration", () => {
test.skipIf(!canRun)( let canRun = false;
"Test 1: Basic foreground turn",
async () => { beforeAll(async () => {
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("Test 1: Basic foreground turn", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-basic-" }); const handle = await createSession({ cwdPrefix: "event-stream-basic-" });
try { try {
@@ -219,13 +228,9 @@ describe("Agent event stream redesign — integration", () => {
} finally { } finally {
await cleanupSession(handle); await cleanupSession(handle);
} }
}, }, 60_000);
60_000,
);
test.skipIf(!canRun)( test("Test 2: No duplicate user_messages — THE BUG", async () => {
"Test 2: No duplicate user_messages — THE BUG",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-dedup-" }); const handle = await createSession({ cwdPrefix: "event-stream-dedup-" });
try { try {
@@ -248,13 +253,9 @@ describe("Agent event stream redesign — integration", () => {
} finally { } finally {
await cleanupSession(handle); await cleanupSession(handle);
} }
}, }, 60_000);
60_000,
);
test.skipIf(!canRun)( test("Test 3: Lifecycle doesn't get stuck in running", async () => {
"Test 3: Lifecycle doesn't get stuck in running",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" }); const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" });
try { try {
@@ -283,13 +284,9 @@ describe("Agent event stream redesign — integration", () => {
} finally { } finally {
await cleanupSession(handle); await cleanupSession(handle);
} }
}, }, 60_000);
60_000,
);
test.skipIf(!canRun)( test("Test 4: Autonomous run", async () => {
"Test 4: Autonomous run",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" }); const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" });
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
@@ -328,22 +325,16 @@ describe("Agent event stream redesign — integration", () => {
// Autonomous turn reaches terminal // Autonomous turn reaches terminal
expect( expect(
afterForeground.find( afterForeground.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId),
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId,
),
).toBeDefined(); ).toBeDefined();
assertInvariants(events, [fgTurnId]); assertInvariants(events, [fgTurnId]);
} finally { } finally {
await cleanupSession(handle); await cleanupSession(handle);
} }
}, }, 90_000);
90_000,
);
test.skipIf(!canRun)( test("Test 5: Interruption", async () => {
"Test 5: Interruption",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" }); const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
try { try {
@@ -426,13 +417,9 @@ describe("Agent event stream redesign — integration", () => {
} finally { } finally {
await cleanupSession(handle); await cleanupSession(handle);
} }
}, }, 60_000);
60_000,
);
test.skipIf(!canRun)( test("Test 6: Sequential foreground turns", async () => {
"Test 6: Sequential foreground turns",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-sequential-" }); const handle = await createSession({ cwdPrefix: "event-stream-sequential-" });
try { try {
@@ -463,13 +450,9 @@ describe("Agent event stream redesign — integration", () => {
} finally { } finally {
await cleanupSession(handle); await cleanupSession(handle);
} }
}, }, 90_000);
90_000,
);
test.skipIf(!canRun)( test("Test 7: Fast-fail", async () => {
"Test 7: Fast-fail",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" }); const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" });
try { try {
@@ -491,21 +474,15 @@ describe("Agent event stream redesign — integration", () => {
// No stale turn_started after terminal // No stale turn_started after terminal
const terminalIdx = events.indexOf(terminal!); const terminalIdx = events.indexOf(terminal!);
expect(events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length).toBe( expect(events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length).toBe(0);
0,
);
assertInvariants(events, [turnId]); assertInvariants(events, [turnId]);
} finally { } finally {
await cleanupSession(handle); await cleanupSession(handle);
} }
}, }, 60_000);
60_000,
);
test.skipIf(!canRun)( test("Test 8: User message dedup by text", async () => {
"Test 8: User message dedup by text",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" }); const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" });
try { try {
@@ -519,7 +496,5 @@ describe("Agent event stream redesign — integration", () => {
} finally { } finally {
await cleanupSession(handle); await cleanupSession(handle);
} }
}, }, 60_000);
60_000,
);
}); });
@@ -1,14 +1,26 @@
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino"; import pino from "pino";
import type { AgentSlashCommand } from "../agent-sdk-types.js"; import type { AgentSlashCommand } from "../agent-sdk-types.js";
import { isCommandAvailableSync } from "../../../utils/executable.js"; import { isCommandAvailable } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./claude-agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
// Real-Claude contract coverage: validates slash command shape from a live Claude CLI session. // Real-Claude contract coverage: validates slash command shape from a live Claude CLI session.
describe("claude agent commands contract (real)", () => { describe("claude agent commands contract (real)", () => {
let canRun = false;
beforeAll(async () => {
canRun = await isCommandAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("lists slash commands with the expected contract", async () => { test("lists slash commands with the expected contract", async () => {
expect(isCommandAvailableSync("claude")).toBe(true); expect(await isCommandAvailable("claude")).toBe(true);
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }), logger: pino({ level: "silent" }),
@@ -1,4 +1,4 @@
import { describe, expect, test, beforeAll } from "vitest"; import { describe, expect, test, beforeAll, beforeEach } from "vitest";
import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -6,7 +6,7 @@ import pino from "pino";
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js"; import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
import { isCommandAvailableSync } from "../../../utils/executable.js"; import { isCommandAvailable } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./claude-agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js"; import { streamSession } from "./test-utils/session-stream-adapter.js";
@@ -174,17 +174,22 @@ async function cleanupSession(handle: { cwd: string; session: AgentSession }): P
} }
describe("ClaudeAgentSession integration", () => { describe("ClaudeAgentSession integration", () => {
const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials; let canRunClaudeIntegration = false;
beforeAll(() => { beforeAll(async () => {
canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials;
if (canRunClaudeIntegration) { if (canRunClaudeIntegration) {
expect(isCommandAvailableSync("claude")).toBe(true); expect(await isCommandAvailable("claude")).toBe(true);
} }
}); });
test.runIf(canRunClaudeIntegration)( beforeEach((context) => {
"streams a basic response turn end-to-end", if (!canRunClaudeIntegration) {
async () => { context.skip();
}
});
test("streams a basic response turn end-to-end", async () => {
const handle = await createSession({ const handle = await createSession({
cwdPrefix: "claude-agent-basic-response-", cwdPrefix: "claude-agent-basic-response-",
}); });
@@ -213,13 +218,9 @@ describe("ClaudeAgentSession integration", () => {
} finally { } finally {
await cleanupSession(handle); await cleanupSession(handle);
} }
}, }, 60_000);
60_000,
);
test.runIf(canRunClaudeIntegration)( test("keeps bypassPermissions available after a thinking-option restart", async () => {
"keeps bypassPermissions available after a thinking-option restart",
async () => {
const handle = await createSession({ const handle = await createSession({
cwdPrefix: "claude-agent-bypass-restart-", cwdPrefix: "claude-agent-bypass-restart-",
modeId: "bypassPermissions", modeId: "bypassPermissions",
@@ -232,13 +233,9 @@ describe("ClaudeAgentSession integration", () => {
} finally { } finally {
await cleanupSession(handle); await cleanupSession(handle);
} }
}, }, 60_000);
60_000,
);
test.runIf(canRunClaudeIntegration)( test("supportedModels returns the current abstract Claude SDK model shape", async () => {
"supportedModels returns the current abstract Claude SDK model shape",
async () => {
const claudeQuery = query({ const claudeQuery = query({
prompt: createEmptyPrompt(), prompt: createEmptyPrompt(),
options: { options: {
@@ -276,9 +273,7 @@ describe("ClaudeAgentSession integration", () => {
} finally { } finally {
await claudeQuery.return?.(); await claudeQuery.return?.();
} }
}, }, 60_000);
60_000,
);
test.runIf(canRunClaudeIntegration)( test.runIf(canRunClaudeIntegration)(
"runs a real Bash tool call and completes it", "runs a real Bash tool call and completes it",
@@ -1,8 +1,8 @@
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino"; import pino from "pino";
import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js"; import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js";
import { isCommandAvailableSync } from "../../../utils/executable.js"; import { isCommandAvailable } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./claude-agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js"; import { streamSession } from "./test-utils/session-stream-adapter.js";
@@ -29,9 +29,19 @@ async function collectUntilTerminal(session: AgentSession): Promise<AgentStreamE
} }
describe("Claude max effort availability (real)", () => { describe("Claude max effort availability (real)", () => {
test.runIf(isCommandAvailableSync("claude") && hasClaudeCredentials)( let canRun = false;
"surfaces the Claude stderr diagnostic when bypassPermissions + max effort is unavailable",
async () => { beforeAll(async () => {
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("surfaces the Claude stderr diagnostic when bypassPermissions + max effort is unavailable", async () => {
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }), logger: pino({ level: "silent" }),
}); });
@@ -57,7 +67,5 @@ describe("Claude max effort availability (real)", () => {
} finally { } finally {
await session.close().catch(() => undefined); await session.close().catch(() => undefined);
} }
}, }, 30_000);
30_000,
);
}); });
@@ -1,5 +1,4 @@
import { execFile, type ChildProcessWithoutNullStreams } from "node:child_process"; import { type ChildProcessWithoutNullStreams } from "node:child_process";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import { promises } from "node:fs"; import { promises } from "node:fs";
@@ -73,12 +72,11 @@ import type {
PersistedAgentDescriptor, PersistedAgentDescriptor,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js"; import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js"; import { executableExists, findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js"; import { execCommand, spawnProcess } from "../../../utils/spawn.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
const fsPromises = promises; const fsPromises = promises;
const execFileAsync = promisify(execFile);
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"]; const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"];
type TurnState = "idle" | "foreground" | "autonomous"; type TurnState = "idle" | "foreground" | "autonomous";
@@ -1130,7 +1128,7 @@ export class ClaudeAgentClient implements AgentClient {
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
const command = this.runtimeSettings?.command; const command = this.runtimeSettings?.command;
if (command?.mode === "replace") { if (command?.mode === "replace") {
return fs.existsSync(command.argv[0]); return executableExists(command.argv[0]) !== null;
} }
return true; return true;
} }
@@ -1186,14 +1184,10 @@ async function resolveClaudeVersion(
try { try {
if (command?.mode === "replace") { if (command?.mode === "replace") {
const { stdout } = await execFileAsync( const { stdout } = await execCommand(
command.argv[0]!, command.argv[0]!,
[...command.argv.slice(1), "--version"], [...command.argv.slice(1), "--version"],
{ { timeout: 5_000 },
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
},
); );
return stdout.trim() || null; return stdout.trim() || null;
} }
@@ -1203,11 +1197,7 @@ async function resolveClaudeVersion(
return null; return null;
} }
const { stdout } = await execFileAsync(executable, ["--version"], { const { stdout } = await execCommand(executable, ["--version"], { timeout: 5_000 });
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
});
return stdout.trim() || null; return stdout.trim() || null;
} catch { } catch {
return null; return null;
@@ -4,9 +4,9 @@
import { mkdtempSync, rmSync, realpathSync } from "node:fs"; import { mkdtempSync, rmSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { beforeAll, describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { findExecutable, isCommandAvailableSync } from "../../../utils/executable.js"; import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
class Pushable<T> implements AsyncIterable<T> { class Pushable<T> implements AsyncIterable<T> {
private queue: T[] = []; private queue: T[] = [];
@@ -57,17 +57,22 @@ const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
describe("Claude SDK direct behavior", () => { describe("Claude SDK direct behavior", () => {
const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials; let canRunClaudeIntegration = false;
beforeAll(() => { beforeAll(async () => {
canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials;
if (canRunClaudeIntegration) { if (canRunClaudeIntegration) {
expect(isCommandAvailableSync("claude")).toBe(true); expect(await isCommandAvailable("claude")).toBe(true);
} }
}); });
test.runIf(canRunClaudeIntegration)( beforeEach((context) => {
"shows what happens after interrupt()", if (!canRunClaudeIntegration) {
async () => { context.skip();
}
});
test("shows what happens after interrupt()", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const input = new Pushable<SDKUserMessage>(); const input = new Pushable<SDKUserMessage>();
const claudeBinary = await findExecutable("claude"); const claudeBinary = await findExecutable("claude");
@@ -150,7 +155,5 @@ describe("Claude SDK direct behavior", () => {
input.end(); input.end();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 120000);
120000,
);
}); });
@@ -31,7 +31,7 @@ import type { Logger } from "pino";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { existsSync, Dirent } from "node:fs"; import { Dirent } from "node:fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@@ -47,7 +47,7 @@ import {
resolveProviderCommandPrefix, resolveProviderCommandPrefix,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../provider-launch-config.js"; } from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js"; import { executableExists, findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js"; import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
@@ -4203,7 +4203,7 @@ export class CodexAppServerAgentClient implements AgentClient {
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
const command = this.runtimeSettings?.command; const command = this.runtimeSettings?.command;
if (command?.mode === "replace") { if (command?.mode === "replace") {
return existsSync(command.argv[0]); return executableExists(command.argv[0]) !== null;
} }
return true; return true;
} }
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -12,9 +12,19 @@ function tmpCwd(): string {
} }
describe("Codex app-server provider (real) plan mode", () => { describe("Codex app-server provider (real) plan mode", () => {
test.runIf(isProviderAvailable("codex"))( let canRun = false;
"maps gpt-5.4 markdown plans to a plan tool call instead of todo items",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("codex");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("maps gpt-5.4 markdown plans to a plan tool call instead of todo items", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const client = new CodexAppServerAgentClient(createTestLogger()); const client = new CodexAppServerAgentClient(createTestLogger());
@@ -58,7 +68,5 @@ describe("Codex app-server provider (real) plan mode", () => {
} finally { } finally {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 240_000);
240_000,
);
}); });
@@ -1,9 +1,5 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { execCommand } from "../../../utils/spawn.js";
const execFileAsync = promisify(execFile);
type DiagnosticEntry = { type DiagnosticEntry = {
label: string; label: string;
@@ -49,11 +45,7 @@ export function toDiagnosticErrorMessage(error: unknown): string {
export async function resolveBinaryVersion(binaryPath: string): Promise<string> { export async function resolveBinaryVersion(binaryPath: string): Promise<string> {
try { try {
const { stdout } = await execFileAsync(binaryPath, ["--version"], { const { stdout } = await execCommand(binaryPath, ["--version"], { timeout: 5_000 });
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
});
return stdout.trim() || "unknown"; return stdout.trim() || "unknown";
} catch { } catch {
return "unknown"; return "unknown";
@@ -1,13 +1,25 @@
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino"; import pino from "pino";
import type { AgentSlashCommand } from "../agent-sdk-types.js"; import type { AgentSlashCommand } from "../agent-sdk-types.js";
import { isCommandAvailableSync } from "../../../utils/executable.js"; import { isCommandAvailable } from "../../../utils/executable.js";
import { OpenCodeAgentClient } from "./opencode-agent.js"; import { OpenCodeAgentClient } from "./opencode-agent.js";
describe("opencode agent commands contract (real)", () => { describe("opencode agent commands contract (real)", () => {
let canRun = false;
beforeAll(async () => {
canRun = await isCommandAvailable("opencode");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("lists slash commands with the expected contract", async () => { test("lists slash commands with the expected contract", async () => {
expect(isCommandAvailableSync("opencode")).toBe(true); expect(await isCommandAvailable("opencode")).toBe(true);
const client = new OpenCodeAgentClient(pino({ level: "silent" })); const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({ const session = await client.createSession({
@@ -37,7 +49,7 @@ describe("opencode agent commands contract (real)", () => {
}, 60_000); }, 60_000);
test("executes a slash command without arguments", async () => { test("executes a slash command without arguments", async () => {
expect(isCommandAvailableSync("opencode")).toBe(true); expect(await isCommandAvailable("opencode")).toBe(true);
const client = new OpenCodeAgentClient(pino({ level: "silent" })); const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({ const session = await client.createSession({
@@ -1,8 +1,8 @@
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino"; import pino from "pino";
import type { AgentStreamEvent } from "../agent-sdk-types.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { isCommandAvailableSync } from "../../../utils/executable.js"; import { isCommandAvailable } from "../../../utils/executable.js";
import { OpenCodeAgentClient } from "./opencode-agent.js"; import { OpenCodeAgentClient } from "./opencode-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js"; import { streamSession } from "./test-utils/session-stream-adapter.js";
@@ -21,9 +21,19 @@ function isTerminalEvent(event: AgentStreamEvent): boolean {
* when models are invalid, auth fails, or provider API calls fail. * when models are invalid, auth fails, or provider API calls fail.
*/ */
describe("opencode agent error handling (real)", () => { describe("opencode agent error handling (real)", () => {
test.runIf(isCommandAvailableSync("opencode"))( let canRun = false;
"surfaces error for the exact opencode/<bad-model> path used by paseo run",
async () => { beforeAll(async () => {
canRun = await isCommandAvailable("opencode");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("surfaces error for the exact opencode/<bad-model> path used by paseo run", async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" })); const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
@@ -46,13 +56,9 @@ describe("opencode agent error handling (real)", () => {
} finally { } finally {
await session.close().catch(() => undefined); await session.close().catch(() => undefined);
} }
}, }, 45_000);
45_000,
);
test.runIf(isCommandAvailableSync("opencode"))( test("surfaces error for unknown provider model (fast path)", async () => {
"surfaces error for unknown provider model (fast path)",
async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" })); const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
@@ -75,13 +81,9 @@ describe("opencode agent error handling (real)", () => {
} finally { } finally {
await session.close().catch(() => undefined); await session.close().catch(() => undefined);
} }
}, }, 30_000);
30_000,
);
test.runIf(isCommandAvailableSync("opencode"))( test("sequential sessions: second session works after first errors", async () => {
"sequential sessions: second session works after first errors",
async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" })); const client = new OpenCodeAgentClient(pino({ level: "silent" }));
// Session 1: bogus model, will error quickly // Session 1: bogus model, will error quickly
@@ -114,13 +116,9 @@ describe("opencode agent error handling (real)", () => {
const terminal = events.find(isTerminalEvent); const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined(); expect(terminal).toBeDefined();
expect(terminal!.type).toBe("turn_failed"); expect(terminal!.type).toBe("turn_failed");
}, }, 30_000);
30_000,
);
test.runIf(isCommandAvailableSync("opencode"))( test("surfaces error for known provider with nonexistent model (retry path)", async () => {
"surfaces error for known provider with nonexistent model (retry path)",
async () => {
// When the provider is recognized (anthropic) but the model doesn't exist, // When the provider is recognized (anthropic) but the model doesn't exist,
// OpenCode retries before surfacing the error. This must not hang. // OpenCode retries before surfacing the error. This must not hang.
const client = new OpenCodeAgentClient(pino({ level: "silent" })); const client = new OpenCodeAgentClient(pino({ level: "silent" }));
@@ -148,13 +146,9 @@ describe("opencode agent error handling (real)", () => {
} finally { } finally {
await session.close().catch(() => undefined); await session.close().catch(() => undefined);
} }
}, }, 45_000);
45_000,
);
test.runIf(isCommandAvailableSync("opencode"))( test("surfaces fatal retry status from zai/glm-5.1 instead of hanging forever", async () => {
"surfaces fatal retry status from zai/glm-5.1 instead of hanging forever",
async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" })); const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
@@ -180,7 +174,5 @@ describe("opencode agent error handling (real)", () => {
} finally { } finally {
await session.close().catch(() => undefined); await session.close().catch(() => undefined);
} }
}, }, 45_000);
45_000,
);
}); });
@@ -1,5 +1,4 @@
import type { ChildProcess } from "node:child_process"; import type { ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { import {
createOpencodeClient, createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage, type AssistantMessage as OpenCodeAssistantMessage,
@@ -44,7 +43,7 @@ import {
resolveProviderCommandPrefix, resolveProviderCommandPrefix,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../provider-launch-config.js"; } from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js"; import { executableExists, findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js"; import { spawnProcess } from "../../../utils/spawn.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import { import {
@@ -917,7 +916,7 @@ export class OpenCodeAgentClient implements AgentClient {
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
const command = this.runtimeSettings?.command; const command = this.runtimeSettings?.command;
if (command?.mode === "replace") { if (command?.mode === "replace") {
return existsSync(command.argv[0]); return executableExists(command.argv[0]) !== null;
} }
return true; return true;
} }
@@ -1,4 +1,4 @@
import { describe, test, expect } from "vitest"; import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -9,9 +9,19 @@ import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js";
describe("OpenCode assistant message", () => { describe("OpenCode assistant message", () => {
test.runIf(isProviderAvailable("opencode"))( let canRun = false;
"assistant_message appears in live stream with opencode/big-pickle",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("assistant_message appears in live stream with opencode/big-pickle", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "opencode-msg-")); const cwd = mkdtempSync(path.join(tmpdir(), "opencode-msg-"));
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const client = new OpenCodeAgentClient(logger); const client = new OpenCodeAgentClient(logger);
@@ -32,13 +42,9 @@ describe("OpenCode assistant message", () => {
} finally { } finally {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 60_000);
60_000,
);
test.runIf(isProviderAvailable("opencode"))( test("streamHistory returns assistant_message after a completed turn", async () => {
"streamHistory returns assistant_message after a completed turn",
async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "opencode-history-")); const cwd = mkdtempSync(path.join(tmpdir(), "opencode-history-"));
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const client = new OpenCodeAgentClient(logger); const client = new OpenCodeAgentClient(logger);
@@ -64,7 +70,5 @@ describe("OpenCode assistant message", () => {
} finally { } finally {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 60_000);
60_000,
);
}); });
@@ -1,4 +1,4 @@
import { describe, test, expect } from "vitest"; import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -9,9 +9,19 @@ import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js";
describe("OpenCode reasoning dedup", () => { describe("OpenCode reasoning dedup", () => {
test.runIf(isProviderAvailable("opencode"))( let canRun = false;
"reasoning content is not duplicated as assistant_message",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("reasoning content is not duplicated as assistant_message", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "opencode-reasoning-dedup-")); const cwd = mkdtempSync(path.join(tmpdir(), "opencode-reasoning-dedup-"));
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const client = new OpenCodeAgentClient(logger); const client = new OpenCodeAgentClient(logger);
@@ -62,7 +72,5 @@ describe("OpenCode reasoning dedup", () => {
} finally { } finally {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 120_000);
120_000,
);
}); });
@@ -7,9 +7,8 @@ import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { homedir } from "node:os"; import { homedir } from "node:os";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { isCommandAvailableSync } from "../../utils/executable.js"; import { isCommandAvailable } from "../../utils/executable.js";
// Load .env.test eagerly so isProviderAvailable() has credentials at collection time.
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
dotenv.config({ path: resolve(serverRoot, ".env.test"), override: true }); dotenv.config({ path: resolve(serverRoot, ".env.test"), override: true });
@@ -64,6 +63,7 @@ export const agentConfigs = {
} as const satisfies Record<string, AgentTestConfig>; } as const satisfies Record<string, AgentTestConfig>;
export type AgentProvider = keyof typeof agentConfigs; export type AgentProvider = keyof typeof agentConfigs;
const providerAvailabilityCache = new Map<AgentProvider, Promise<boolean>>();
/** /**
* Get test config for creating an agent with full permissions (no prompts). * Get test config for creating an agent with full permissions (no prompts).
@@ -101,31 +101,42 @@ export function getAskModeConfig(provider: AgentProvider) {
* This MUST be a function (not a const) so process.env is read at call time, * This MUST be a function (not a const) so process.env is read at call time,
* after dotenv has injected the test credentials. * after dotenv has injected the test credentials.
*/ */
export function isProviderAvailable(provider: AgentProvider): boolean { export function isProviderAvailable(provider: AgentProvider): Promise<boolean> {
const cached = providerAvailabilityCache.get(provider);
if (cached) {
return cached;
}
const availability = (async (): Promise<boolean> => {
switch (provider) { switch (provider) {
case "claude": case "claude":
return ( return (
isCommandAvailableSync("claude") && (await isCommandAvailable("claude")) &&
(Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY)) (Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY))
); );
case "codex": case "codex":
return ( return (
isCommandAvailableSync("codex") && (await isCommandAvailable("codex")) &&
(existsSync(join(homedir(), ".codex", "auth.json")) || Boolean(process.env.OPENAI_API_KEY)) (existsSync(join(homedir(), ".codex", "auth.json")) ||
Boolean(process.env.OPENAI_API_KEY))
); );
case "copilot": case "copilot":
return isCommandAvailableSync("copilot"); return await isCommandAvailable("copilot");
case "opencode": case "opencode":
return isCommandAvailableSync("opencode"); return await isCommandAvailable("opencode");
case "pi": case "pi":
return ( return (
isCommandAvailableSync(process.env.PI_ACP_PI_COMMAND ?? "pi") && (await isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi")) &&
(Boolean(process.env.OPENAI_API_KEY) || (Boolean(process.env.OPENAI_API_KEY) ||
Boolean(process.env.ANTHROPIC_API_KEY) || Boolean(process.env.ANTHROPIC_API_KEY) ||
Boolean(process.env.OPENROUTER_API_KEY) || Boolean(process.env.OPENROUTER_API_KEY) ||
existsSync(join(homedir(), ".pi", "agent", "auth.json"))) existsSync(join(homedir(), ".pi", "agent", "auth.json")))
); );
} }
})();
providerAvailabilityCache.set(provider, availability);
return availability;
} }
/** /**
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -18,9 +18,19 @@ function compactText(value: string): string {
} }
describe("daemon E2E (real claude) - autonomous wake simple", () => { describe("daemon E2E (real claude) - autonomous wake simple", () => {
test.runIf(isProviderAvailable("claude"))( let canRun = false;
"hello + background sleep returns idle, then wakes once on completion",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("hello + background sleep returns idle, then wakes once on completion", async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -109,7 +119,5 @@ describe("daemon E2E (real claude) - autonomous wake simple", () => {
await daemon.close().catch(() => undefined); await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 420_000);
420_000,
);
}); });
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { homedir, tmpdir } from "node:os"; import { homedir, tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -353,9 +353,19 @@ function summarizeTimelineEntry(entry: {
} }
describe("daemon E2E (real claude) - autonomous wake from background task", () => { describe("daemon E2E (real claude) - autonomous wake from background task", () => {
test.runIf(isProviderAvailable("claude"))( let canRun = false;
"A: background sleep returns idle, then wakes autonomously and appends timeline activity",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("A: background sleep returns idle, then wakes autonomously and appends timeline activity", async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -427,13 +437,9 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 420_000);
420_000,
);
test.runIf(isProviderAvailable("claude"))( test("B: immediate HELLO before task notification returns promptly without deadlock", async () => {
"B: immediate HELLO before task notification returns promptly without deadlock",
async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -485,13 +491,9 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 420_000);
420_000,
);
test.runIf(isProviderAvailable("claude"))( test("C: interrupt during overlap returns quickly and does not leave agent stuck running", async () => {
"C: interrupt during overlap returns quickly and does not leave agent stuck running",
async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -555,13 +557,9 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 600_000);
600_000,
);
test.runIf(isProviderAvailable("claude"))( test("returns to running after background sleep completes without a second prompt", async () => {
"returns to running after background sleep completes without a second prompt",
async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -617,13 +615,9 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 420_000);
420_000,
);
test.runIf(isProviderAvailable("claude"))( test("accepts a new prompt after background sleep finishes and replies HELLO", async () => {
"accepts a new prompt after background sleep finishes and replies HELLO",
async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -666,13 +660,9 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 420_000);
420_000,
);
test.runIf(isProviderAvailable("claude"))( test("repro: do-it-again + immediate hello can hang after autonomous wake under churn", async () => {
"repro: do-it-again + immediate hello can hang after autonomous wake under churn",
async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -773,13 +763,9 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 900_000);
900_000,
);
test.runIf(isProviderAvailable("claude"))( test("repro: second background sleep completion after HELLO should settle back to idle", async () => {
"repro: second background sleep completion after HELLO should settle back to idle",
async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -849,13 +835,9 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 600_000);
600_000,
);
test.runIf(isProviderAvailable("claude"))( test("stress: immediate HELLO before task notification should not leave autonomous run stuck", async () => {
"stress: immediate HELLO before task notification should not leave autonomous run stuck",
async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -939,13 +921,9 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 900_000);
900_000,
);
test.runIf(isProviderAvailable("claude"))( test("repro: transcript/timeline parity after do-it-again + hello race (hang + interrupt + drop)", async () => {
"repro: transcript/timeline parity after do-it-again + hello race (hang + interrupt + drop)",
async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -1086,7 +1064,5 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 600_000);
600_000,
);
}); });
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -14,9 +14,19 @@ function tmpCwd(): string {
} }
describe("daemon E2E (real claude) - model resolution on init", () => { describe("daemon E2E (real claude) - model resolution on init", () => {
test.runIf(isProviderAvailable("claude"))( let canRun = false;
"runtimeInfo.model is set as soon as the agent starts running, not after turn completes",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("runtimeInfo.model is set as soon as the agent starts running, not after turn completes", async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -59,7 +69,5 @@ describe("daemon E2E (real claude) - model resolution on init", () => {
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 120_000);
120_000,
);
}); });
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -14,9 +14,19 @@ function tmpCwd(): string {
} }
describe("daemon E2E (real claude) - runtime model reconciliation", () => { describe("daemon E2E (real claude) - runtime model reconciliation", () => {
test.runIf(isProviderAvailable("claude"))( let canRun = false;
"normalizes runtime model to a model ID exposed by the provider catalog",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("normalizes runtime model to a model ID exposed by the provider catalog", async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -57,7 +67,5 @@ describe("daemon E2E (real claude) - runtime model reconciliation", () => {
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 300_000);
300_000,
);
}); });
@@ -1,4 +1,4 @@
import { describe, test, expect } from "vitest"; import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -29,9 +29,19 @@ async function createHarness(): Promise<{
} }
describe("daemon E2E (real opencode) - custom agent discovery", () => { describe("daemon E2E (real opencode) - custom agent discovery", () => {
test.runIf(isProviderAvailable("opencode"))( let canRun = false;
"custom agents defined in opencode.json appear in availableModes",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("custom agents defined in opencode.json appear in availableModes", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
writeFileSync( writeFileSync(
path.join(cwd, "opencode.json"), path.join(cwd, "opencode.json"),
@@ -77,7 +87,5 @@ describe("daemon E2E (real opencode) - custom agent discovery", () => {
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 60_000);
60_000,
);
}); });
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -29,9 +29,19 @@ async function createHarness(): Promise<{
} }
describe("daemon E2E (real opencode) - initial prompt wait", () => { describe("daemon E2E (real opencode) - initial prompt wait", () => {
test.runIf(isProviderAvailable("opencode"))( let canRun = false;
"waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const { client, daemon } = await createHarness(); const { client, daemon } = await createHarness();
@@ -72,13 +82,9 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 90_000);
90_000,
);
test.runIf(isProviderAvailable("opencode"))( test("waitForFinish surfaces a terminal error when zai/glm-5.1 enters a fatal retry loop", async () => {
"waitForFinish surfaces a terminal error when zai/glm-5.1 enters a fatal retry loop",
async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const { client, daemon } = await createHarness(); const { client, daemon } = await createHarness();
@@ -107,7 +113,5 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 90_000);
90_000,
);
}); });
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -29,9 +29,19 @@ async function createHarness(): Promise<{
} }
describe("daemon E2E (real opencode) - invalid model handling", () => { describe("daemon E2E (real opencode) - invalid model handling", () => {
test.runIf(isProviderAvailable("opencode"))( let canRun = false;
"initial prompt with a nonexistent OpenCode model fails instead of hanging forever",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("initial prompt with a nonexistent OpenCode model fails instead of hanging forever", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const { client, daemon } = await createHarness(); const { client, daemon } = await createHarness();
@@ -55,7 +65,5 @@ describe("daemon E2E (real opencode) - invalid model handling", () => {
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 60_000);
60_000,
);
}); });
@@ -1,4 +1,4 @@
import { describe, test, expect } from "vitest"; import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -39,9 +39,19 @@ async function createHarness(): Promise<{
} }
describe("daemon E2E (real opencode) - plan mode and clarifying questions", () => { describe("daemon E2E (real opencode) - plan mode and clarifying questions", () => {
test.runIf(isProviderAvailable("opencode"))( let canRun = false;
"surfaces clarifying questions as pending permissions",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("surfaces clarifying questions as pending permissions", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const { client, daemon } = await createHarness(); const { client, daemon } = await createHarness();
@@ -86,13 +96,9 @@ describe("daemon E2E (real opencode) - plan mode and clarifying questions", () =
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 180_000);
180_000,
);
test.runIf(isProviderAvailable("opencode"))( test("plan mode stays read-only through the daemon path", async () => {
"plan mode stays read-only through the daemon path",
async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const filePath = path.join(cwd, "plan-mode-output.txt"); const filePath = path.join(cwd, "plan-mode-output.txt");
const { client, daemon } = await createHarness(); const { client, daemon } = await createHarness();
@@ -134,7 +140,5 @@ describe("daemon E2E (real opencode) - plan mode and clarifying questions", () =
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 240_000);
240_000,
);
}); });
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -281,9 +281,19 @@ async function createHarness(): Promise<{
} }
describe("daemon E2E (real opencode) - send while working and interrupt", () => { describe("daemon E2E (real opencode) - send while working and interrupt", () => {
test.runIf(isProviderAvailable("opencode"))( let canRun = false;
"send_message while sleep tool call is running starts a clean replacement turn",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("send_message while sleep tool call is running starts a clean replacement turn", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const { client, daemon } = await createHarness(); const { client, daemon } = await createHarness();
const collector = createMessageCollector(client); const collector = createMessageCollector(client);
@@ -341,13 +351,9 @@ describe("daemon E2E (real opencode) - send while working and interrupt", () =>
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 360_000);
360_000,
);
test.runIf(isProviderAvailable("opencode"))( test("explicit interrupt during sleep tool call still allows the next turn to complete", async () => {
"explicit interrupt during sleep tool call still allows the next turn to complete",
async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const { client, daemon } = await createHarness(); const { client, daemon } = await createHarness();
const collector = createMessageCollector(client); const collector = createMessageCollector(client);
@@ -401,9 +407,9 @@ describe("daemon E2E (real opencode) - send while working and interrupt", () =>
expect(postInterruptAssistantTexts.some((text) => text.includes("[System Error]"))).toBe( expect(postInterruptAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(
false, false,
); );
expect( expect(postInterruptAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(
postInterruptAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET)), false,
).toBe(false); );
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 200 }); const timeline = await client.fetchAgentTimeline(agent.id, { limit: 200 });
const assistantTexts = getTimelineAssistantTexts(timeline); const assistantTexts = getTimelineAssistantTexts(timeline);
@@ -416,7 +422,5 @@ describe("daemon E2E (real opencode) - send while working and interrupt", () =>
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 360_000);
360_000,
);
}); });
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -16,9 +16,19 @@ function tmpCwd(): string {
} }
describe("daemon E2E (real pi)", () => { describe("daemon E2E (real pi)", () => {
test.runIf(isProviderAvailable("pi"))( let canRun = false;
"smoke test with thinking option configured separately from modes",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("pi");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("smoke test with thinking option configured separately from modes", async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -70,7 +80,5 @@ describe("daemon E2E (real pi)", () => {
await daemon.close().catch(() => undefined); await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 420_000);
420_000,
);
}); });
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -14,9 +14,19 @@ function tmpCwd(): string {
} }
describe("daemon E2E (real claude) - rewind user message dedupe", () => { describe("daemon E2E (real claude) - rewind user message dedupe", () => {
test.runIf(isProviderAvailable("claude"))( let canRun = false;
"emits /rewind user message once in persisted timeline",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("emits /rewind user message once in persisted timeline", async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -61,7 +71,5 @@ describe("daemon E2E (real claude) - rewind user message dedupe", () => {
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 300_000);
300_000,
);
}); });
@@ -1,4 +1,4 @@
import { describe, test, expect } from "vitest"; import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -168,9 +168,19 @@ async function waitForRunningToolCall(
} }
describe("daemon E2E (real claude) - send message during tool call", () => { describe("daemon E2E (real claude) - send message during tool call", () => {
test.runIf(isProviderAvailable("claude"))( let canRun = false;
"sending a message while a tool call is running replaces the turn without error, idle flash, or autonomous fallback",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("sending a message while a tool call is running replaces the turn without error, idle flash, or autonomous fallback", async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -272,7 +282,5 @@ describe("daemon E2E (real claude) - send message during tool call", () => {
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 300_000);
300_000,
);
}); });
@@ -1,4 +1,4 @@
import { describe, test, expect } from "vitest"; import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -15,9 +15,19 @@ function tmpCwd(): string {
} }
describe("daemon E2E (real claude) - send while running recovery", () => { describe("daemon E2E (real claude) - send while running recovery", () => {
test.runIf(isProviderAvailable("claude"))( let canRun = false;
"clears input processing when the interrupt transition is missed",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("clears input processing when the interrupt transition is missed", async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -117,7 +127,5 @@ describe("daemon E2E (real claude) - send while running recovery", () => {
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 300_000);
300_000,
);
}); });
@@ -1,4 +1,4 @@
import { describe, test, expect } from "vitest"; import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -15,9 +15,19 @@ function tmpCwd(): string {
} }
describe("daemon E2E (real codex) - send while running recovery", () => { describe("daemon E2E (real codex) - send while running recovery", () => {
test.runIf(isProviderAvailable("codex"))( let canRun = false;
"clears input processing when the interrupt transition is missed",
async () => { beforeAll(async () => {
canRun = await isProviderAvailable("codex");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("clears input processing when the interrupt transition is missed", async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -114,7 +124,5 @@ describe("daemon E2E (real codex) - send while running recovery", () => {
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 240_000);
240_000,
);
}); });
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest"; import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -456,11 +456,19 @@ function createRealAgentClient(provider: AgentProvider, logger: pino.Logger): Ag
} }
describe.each(allProviders)("daemon E2E (real %s) - UI action stress", (provider) => { describe.each(allProviders)("daemon E2E (real %s) - UI action stress", (provider) => {
const shouldRun = isProviderAvailable(provider); let shouldRun = false;
test.runIf(shouldRun)( beforeAll(async () => {
"normal UI submit path (idle sends) stays correct", shouldRun = await isProviderAvailable(provider);
async () => { });
beforeEach((context) => {
if (!shouldRun) {
context.skip();
}
});
test("normal UI submit path (idle sends) stays correct", async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -494,13 +502,9 @@ describe.each(allProviders)("daemon E2E (real %s) - UI action stress", (provider
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 420_000);
420_000,
);
test.runIf(shouldRun)( test("queued-send-now path is stable under overlap", async () => {
"queued-send-now path is stable under overlap",
async () => {
const logger = pino({ level: "silent" }); const logger = pino({ level: "silent" });
const cwd = tmpCwd(); const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({ const daemon = await createTestPaseoDaemon({
@@ -541,7 +545,5 @@ describe.each(allProviders)("daemon E2E (real %s) - UI action stress", (provider
await daemon.close(); await daemon.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
}, }, 600_000);
600_000,
);
}); });
+2 -2
View File
@@ -33,11 +33,11 @@ export {
export { applyProviderEnv } from "./agent/provider-launch-config.js"; export { applyProviderEnv } from "./agent/provider-launch-config.js";
export { export {
findExecutable, findExecutable,
findExecutableSync, executableExists,
quoteWindowsArgument, quoteWindowsArgument,
quoteWindowsCommand, quoteWindowsCommand,
} from "../utils/executable.js"; } from "../utils/executable.js";
export { spawnProcess } from "../utils/spawn.js"; export { execCommand, spawnProcess } from "../utils/spawn.js";
// Provider manifest (source of truth for provider definitions) // Provider manifest (source of truth for provider definitions)
export { export {
+3 -4
View File
@@ -1,8 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util";
import { z } from "zod"; import { z } from "zod";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { curateAgentActivity } from "./agent/activity-curator.js"; import { curateAgentActivity } from "./agent/activity-curator.js";
@@ -15,8 +13,8 @@ import type {
AgentTimelineItem, AgentTimelineItem,
AgentProvider, AgentProvider,
} from "./agent/agent-sdk-types.js"; } from "./agent/agent-sdk-types.js";
import { execCommand, platformShell } from "../utils/spawn.js";
const execFileAsync = promisify(execFile);
const LOOP_ID_LENGTH = 8; const LOOP_ID_LENGTH = 8;
const DEFAULT_LOOP_PROVIDER: AgentProvider = "claude"; const DEFAULT_LOOP_PROVIDER: AgentProvider = "claude";
const MAX_VERIFY_OUTPUT_BYTES = 64 * 1024; const MAX_VERIFY_OUTPUT_BYTES = 64 * 1024;
@@ -256,7 +254,8 @@ async function runVerifyCheck(options: {
}): Promise<LoopVerifyCheckResult> { }): Promise<LoopVerifyCheckResult> {
const startedAt = nowIso(); const startedAt = nowIso();
try { try {
const result = await execFileAsync("/bin/sh", ["-lc", options.command], { const shell = platformShell();
const result = await execCommand(shell.command, [...shell.flag, options.command], {
cwd: options.cwd, cwd: options.cwd,
maxBuffer: MAX_VERIFY_OUTPUT_BYTES, maxBuffer: MAX_VERIFY_OUTPUT_BYTES,
}); });
+11 -11
View File
@@ -1,7 +1,7 @@
import equal from "fast-deep-equal"; import equal from "fast-deep-equal";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { stat } from "fs/promises"; import { stat } from "fs/promises";
import { exec, execFile } from "node:child_process"; import { exec } from "node:child_process";
import { promisify } from "util"; import { promisify } from "util";
import { resolve, sep } from "path"; import { resolve, sep } from "path";
import { homedir } from "node:os"; import { homedir } from "node:os";
@@ -170,6 +170,7 @@ import { ChatServiceError, FileBackedChatService } from "./chat/chat-service.js"
import { notifyChatMentions } from "./chat/chat-mentions.js"; import { notifyChatMentions } from "./chat/chat-mentions.js";
import { LoopService } from "./loop-service.js"; import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js"; import { ScheduleService } from "./schedule/service.js";
import { execCommand } from "../utils/spawn.js";
import { import {
assertSafeGitRef as assertWorktreeSafeGitRef, assertSafeGitRef as assertWorktreeSafeGitRef,
buildAgentSessionConfig as buildWorktreeAgentSessionConfig, buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
@@ -182,7 +183,6 @@ import {
} from "./worktree-session.js"; } from "./worktree-session.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>(); const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
@@ -3531,7 +3531,7 @@ export class Session {
private async checkoutExistingBranch(cwd: string, branch: string): Promise<void> { private async checkoutExistingBranch(cwd: string, branch: string): Promise<void> {
this.assertSafeGitRef(branch, "branch"); this.assertSafeGitRef(branch, "branch");
try { try {
await execFileAsync("git", ["rev-parse", "--verify", branch], { cwd }); await execCommand("git", ["rev-parse", "--verify", branch], { cwd });
} catch (error) { } catch (error) {
throw new Error(`Branch not found: ${branch}`); throw new Error(`Branch not found: ${branch}`);
} }
@@ -3545,7 +3545,7 @@ export class Session {
} }
await this.ensureCleanWorkingTree(cwd); await this.ensureCleanWorkingTree(cwd);
await execFileAsync("git", ["checkout", branch], { cwd }); await execCommand("git", ["checkout", branch], { cwd });
} }
private async createBranchFromBase(params: { private async createBranchFromBase(params: {
@@ -3558,7 +3558,7 @@ export class Session {
this.assertSafeGitRef(newBranchName, "new branch"); this.assertSafeGitRef(newBranchName, "new branch");
try { try {
await execFileAsync("git", ["rev-parse", "--verify", baseBranch], { cwd }); await execCommand("git", ["rev-parse", "--verify", baseBranch], { cwd });
} catch (error) { } catch (error) {
throw new Error(`Base branch not found: ${baseBranch}`); throw new Error(`Base branch not found: ${baseBranch}`);
} }
@@ -3569,7 +3569,7 @@ export class Session {
} }
await this.ensureCleanWorkingTree(cwd); await this.ensureCleanWorkingTree(cwd);
await execFileAsync("git", ["checkout", "-b", newBranchName, baseBranch], { await execCommand("git", ["checkout", "-b", newBranchName, baseBranch], {
cwd, cwd,
}); });
} }
@@ -3577,7 +3577,7 @@ export class Session {
private async doesLocalBranchExist(cwd: string, branch: string): Promise<boolean> { private async doesLocalBranchExist(cwd: string, branch: string): Promise<boolean> {
this.assertSafeGitRef(branch, "branch"); this.assertSafeGitRef(branch, "branch");
try { try {
await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { await execCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
cwd, cwd,
}); });
return true; return true;
@@ -4030,7 +4030,7 @@ export class Session {
// Try local branch first // Try local branch first
try { try {
await execFileAsync("git", ["rev-parse", "--verify", branchName], { await execCommand("git", ["rev-parse", "--verify", branchName], {
cwd: resolvedCwd, cwd: resolvedCwd,
env: READ_ONLY_GIT_ENV, env: READ_ONLY_GIT_ENV,
}); });
@@ -4051,7 +4051,7 @@ export class Session {
// Try remote branch (origin/{branchName}) // Try remote branch (origin/{branchName})
try { try {
await execFileAsync("git", ["rev-parse", "--verify", `origin/${branchName}`], { await execCommand("git", ["rev-parse", "--verify", `origin/${branchName}`], {
cwd: resolvedCwd, cwd: resolvedCwd,
env: READ_ONLY_GIT_ENV, env: READ_ONLY_GIT_ENV,
}); });
@@ -4281,7 +4281,7 @@ export class Session {
const message = branchLabel const message = branchLabel
? `${Session.PASEO_STASH_PREFIX} ${branchLabel}` ? `${Session.PASEO_STASH_PREFIX} ${branchLabel}`
: `${Session.PASEO_STASH_PREFIX} unnamed`; : `${Session.PASEO_STASH_PREFIX} unnamed`;
await execFileAsync("git", ["stash", "push", "--include-untracked", "-m", message], { cwd }); await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({ this.emit({
type: "stash_save_response", type: "stash_save_response",
@@ -4300,7 +4300,7 @@ export class Session {
): Promise<void> { ): Promise<void> {
const { cwd, stashIndex, requestId } = msg; const { cwd, stashIndex, requestId } = msg;
try { try {
await execFileAsync("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd }); await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd });
this.checkoutDiffManager.scheduleRefreshForCwd(cwd); this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({ this.emit({
type: "stash_pop_response", type: "stash_pop_response",
+26 -12
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env node #!/usr/bin/env node
import { Command } from "commander"; import { Command } from "commander";
import { spawnSync } from "node:child_process";
import { appendFileSync, existsSync, openSync, readFileSync } from "node:fs"; import { appendFileSync, existsSync, openSync, readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { FileTaskStore } from "./task-store.js"; import { FileTaskStore } from "./task-store.js";
import { computeExecutionOrder, buildSortedChildrenMap } from "./execution-order.js"; import { computeExecutionOrder, buildSortedChildrenMap } from "./execution-order.js";
import { resolvePackageVersion } from "../server/package-version.js"; import { resolvePackageVersion } from "../server/package-version.js";
import { spawnProcess } from "../utils/spawn.js";
import type { AgentType, ModelName, Task } from "./types.js"; import type { AgentType, ModelName, Task } from "./types.js";
const TASKS_DIR = resolve(process.cwd(), ".tasks"); const TASKS_DIR = resolve(process.cwd(), ".tasks");
@@ -638,11 +638,11 @@ function getAgentConfig(modelStr: string): AgentConfig {
return { cli: "claude", model }; return { cli: "claude", model };
} }
function runAgentWithModel( async function runAgentWithModel(
prompt: string, prompt: string,
modelStr: string, modelStr: string,
logFile: string, logFile: string,
): { success: boolean; output: string } { ): Promise<{ success: boolean; output: string }> {
const config = getAgentConfig(modelStr); const config = getAgentConfig(modelStr);
let args: string[]; let args: string[];
@@ -667,20 +667,34 @@ function runAgentWithModel(
args.push(prompt); args.push(prompt);
} }
const result = spawnSync(config.cli, args, { const { stdout, stderr, exitCode } = await new Promise<{
stdout: string;
stderr: string;
exitCode: number | null;
}>((resolve, reject) => {
const child = spawnProcess(config.cli, args, {
stdio: ["inherit", "pipe", "pipe"], stdio: ["inherit", "pipe", "pipe"],
cwd: process.cwd(), cwd: process.cwd(),
maxBuffer: 50 * 1024 * 1024,
}); });
const stdoutChunks: Buffer[] = [];
const stdout = result.stdout?.toString() ?? ""; const stderrChunks: Buffer[] = [];
const stderr = result.stderr?.toString() ?? ""; child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
child.on("error", reject);
child.on("close", (code) => {
resolve({
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
stderr: Buffer.concat(stderrChunks).toString("utf8"),
exitCode: code,
});
});
});
const output = stdout + stderr; const output = stdout + stderr;
// Append to log file for history // Append to log file for history
appendFileSync(logFile, output); appendFileSync(logFile, output);
return { success: result.status === 0, output }; return { success: exitCode === 0, output };
} }
async function buildTaskContext(task: Task, scopeId?: string): Promise<string> { async function buildTaskContext(task: Task, scopeId?: string): Promise<string> {
@@ -1102,7 +1116,7 @@ program
log(logFile, `[PLANNER] Running ${plannerModel} (${reason})...`); log(logFile, `[PLANNER] Running ${plannerModel} (${reason})...`);
const context = await buildTaskContext(task, scopeId); const context = await buildTaskContext(task, scopeId);
const plannerPrompt = makePlannerPrompt(task, context, reason); const plannerPrompt = makePlannerPrompt(task, context, reason);
runAgentWithModel(plannerPrompt, plannerModel, logFile); await runAgentWithModel(plannerPrompt, plannerModel, logFile);
// Check if planner created subtasks for this task // Check if planner created subtasks for this task
const children = await store.getChildren(task.id); const children = await store.getChildren(task.id);
@@ -1221,7 +1235,7 @@ program
const freshContext = await buildTaskContext(freshTask, scopeId); const freshContext = await buildTaskContext(freshTask, scopeId);
const workerPrompt = makeWorkerPrompt(freshTask, freshContext, iteration); const workerPrompt = makeWorkerPrompt(freshTask, freshContext, iteration);
runAgentWithModel(workerPrompt, workerModel, logFile); await runAgentWithModel(workerPrompt, workerModel, logFile);
// Step 3: Judge // Step 3: Judge
log(logFile, `[JUDGE] Verifying with ${judgeModel}...`); log(logFile, `[JUDGE] Verifying with ${judgeModel}...`);
@@ -1229,7 +1243,7 @@ program
if (!judgeTask) break; if (!judgeTask) break;
const judgePrompt = makeJudgePrompt(judgeTask); const judgePrompt = makeJudgePrompt(judgeTask);
const judgeResult = runAgentWithModel(judgePrompt, judgeModel, logFile); const judgeResult = await runAgentWithModel(judgePrompt, judgeModel, logFile);
const verdict = parseJudgeVerdict(judgeResult.output); const verdict = parseJudgeVerdict(judgeResult.output);
log(logFile, `[JUDGE] Verdict: ${verdict || "UNKNOWN"}`); log(logFile, `[JUDGE] Verdict: ${verdict || "UNKNOWN"}`);
+3 -6
View File
@@ -1,5 +1,3 @@
import { execFile } from "child_process";
import { promisify } from "util";
import { resolve, dirname, basename } from "path"; import { resolve, dirname, basename } from "path";
import { existsSync, realpathSync } from "fs"; import { existsSync, realpathSync } from "fs";
import { open as openFile, stat as statFile } from "fs/promises"; import { open as openFile, stat as statFile } from "fs/promises";
@@ -8,10 +6,9 @@ import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
import { findExecutable } from "./executable.js"; import { findExecutable } from "./executable.js";
import { runGitCommand } from "./run-git-command.js"; import { runGitCommand } from "./run-git-command.js";
import { execCommand } from "./spawn.js";
import { isPaseoOwnedWorktreeCwd } from "./worktree.js"; import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js"; import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js";
const execFileAsync = promisify(execFile);
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
...process.env, ...process.env,
GIT_OPTIONAL_LOCKS: "0", GIT_OPTIONAL_LOCKS: "0",
@@ -1899,7 +1896,7 @@ export async function createPullRequest(
if (options.body) { if (options.body) {
args.push("-f", `body=${options.body}`); args.push("-f", `body=${options.body}`);
} }
const { stdout } = await execFileAsync(ghPath, args, { cwd, env: ghEnv }); const { stdout } = await execCommand(ghPath, args, { cwd, env: ghEnv });
const parsed = JSON.parse(stdout.trim()); const parsed = JSON.parse(stdout.trim());
if (!parsed?.url || !parsed?.number) { if (!parsed?.url || !parsed?.number) {
throw new Error("GitHub CLI did not return PR url/number"); throw new Error("GitHub CLI did not return PR url/number");
@@ -1951,7 +1948,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestSta
}; };
} }
try { try {
const { stdout } = await execFileAsync( const { stdout } = await execCommand(
ghPath, ghPath,
["pr", "view", "--json", "url,title,state,baseRefName,headRefName,mergedAt"], ["pr", "view", "--json", "url,title,state,baseRefName,headRefName,mergedAt"],
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }, { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
+195 -56
View File
@@ -1,78 +1,209 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { promisify } from "node:util";
import { afterEach, describe, expect, test, vi } from "vitest";
import { findExecutableSync, quoteWindowsArgument, quoteWindowsCommand } from "./executable.js"; type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void;
type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutableSync>[1]>; async function loadExecutableModule(params?: {
execFileImpl?: (
command: string,
args: string[],
options: unknown,
callback: ExecFileCallback,
) => void;
}) {
vi.resetModules();
function createFindExecutableDependencies(): FindExecutableDependencies { const execFileMock = vi.fn(
params?.execFileImpl ??
((_command: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
callback(new Error("execFile not mocked"), "", "");
}),
);
Object.assign(execFileMock, {
[promisify.custom]: (command: string, args: string[], options: unknown) =>
new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execFileMock(
command,
args,
options,
(error: Error | null, stdout: string, stderr: string) => {
if (error) {
reject(error);
return;
}
resolve({ stdout, stderr });
},
);
}),
});
vi.doMock("node:child_process", () => ({
execFile: execFileMock,
}));
const module = await import("./executable.js");
return { return {
execFileSync: vi.fn(), ...module,
existsSync: vi.fn(), execFileMock,
platform: vi.fn(() => "darwin"),
}; };
} }
let findExecutableDependencies: FindExecutableDependencies; describe("findExecutable", () => {
const originalPlatform = process.platform;
beforeEach(() => { function setPlatform(value: string) {
findExecutableDependencies = createFindExecutableDependencies(); Object.defineProperty(process, "platform", { value, writable: true });
}
afterEach(() => {
setPlatform(originalPlatform);
}); });
describe("findExecutableSync", () => { test("on Windows, resolves executables using where.exe with inherited PATH", async () => {
test("on Windows, resolves executables using where.exe with inherited PATH", () => { setPlatform("win32");
findExecutableDependencies.platform = vi.fn(() => "win32"); const { execFileMock, findExecutable } = await loadExecutableModule({
findExecutableDependencies.execFileSync.mockReturnValue( execFileImpl: (_command, _args, _options, callback) => {
"C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n", callback(null, "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n", "");
); },
});
expect(findExecutableSync("claude", findExecutableDependencies)).toBe( await expect(findExecutable("claude")).resolves.toBe(
"C:\\Users\\boudr\\.local\\bin\\claude.exe", "C:\\Users\\boudr\\.local\\bin\\claude.exe",
); );
expect(findExecutableDependencies.execFileSync).toHaveBeenCalledOnce(); expect(execFileMock).toHaveBeenCalledOnce();
const call = findExecutableDependencies.execFileSync.mock.calls[0]; const call = execFileMock.mock.calls[0];
expect(call?.[0]).toBe("where.exe"); expect(call?.[0]).toBe("where.exe");
expect(call?.[1]).toEqual(["claude"]); expect(call?.[1]).toEqual(["claude"]);
expect(call?.[2]?.encoding).toBe("utf8"); expect(call?.[2]).toMatchObject({
expect(call?.[2]?.windowsHide).toBe(true);
});
test("on Windows, preserves the first where.exe match", () => {
findExecutableDependencies.platform = vi.fn(() => "win32");
findExecutableDependencies.execFileSync.mockReturnValue(
"C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n",
);
expect(findExecutableSync("codex", findExecutableDependencies)).toBe(
"C:\\nvm4w\\nodejs\\codex",
);
});
test("on Unix, uses the last line from which output", () => {
findExecutableDependencies.execFileSync.mockReturnValue("/usr/local/bin/codex\n");
expect(findExecutableSync("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex");
expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith("which", ["codex"], {
encoding: "utf8", encoding: "utf8",
windowsHide: true,
}); });
}); });
test("warns and returns null when the final which line is not an absolute path", () => { test("on Windows, prefers an executable match from where.exe output", async () => {
findExecutableDependencies.execFileSync.mockReturnValue("codex\n"); setPlatform("win32");
const { findExecutable } = await loadExecutableModule({
execFileImpl: (_command, _args, _options, callback) => {
callback(null, "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n", "");
},
});
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.cmd");
});
test("on Windows, prefers .exe over .cmd, .ps1, and extensionless candidates", async () => {
setPlatform("win32");
const { findExecutable } = await loadExecutableModule({
execFileImpl: (_command, _args, _options, callback) => {
callback(
null,
[
"C:\\nvm4w\\nodejs\\codex",
"C:\\nvm4w\\nodejs\\codex.ps1",
"C:\\nvm4w\\nodejs\\codex.cmd",
"C:\\nvm4w\\nodejs\\codex.exe",
].join("\r\n"),
"",
);
},
});
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.exe");
});
test("on Windows, returns null when where.exe output is empty", async () => {
setPlatform("win32");
const { findExecutable } = await loadExecutableModule({
execFileImpl: (_command, _args, _options, callback) => {
callback(null, "\r\n", "");
},
});
await expect(findExecutable("codex")).resolves.toBeNull();
});
test("on Windows, falls back to the first extensionless candidate when needed", async () => {
setPlatform("win32");
const { findExecutable } = await loadExecutableModule({
execFileImpl: (_command, _args, _options, callback) => {
callback(null, "C:\\nvm4w\\nodejs\\codex\r\n", "");
},
});
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex");
});
test("on Unix, uses the last line from which output", async () => {
const { execFileMock, findExecutable } = await loadExecutableModule({
execFileImpl: (_command, _args, _options, callback) => {
callback(null, "/usr/local/bin/codex\n", "");
},
});
await expect(findExecutable("codex")).resolves.toBe("/usr/local/bin/codex");
expect(execFileMock).toHaveBeenCalledWith(
"which",
["codex"],
{ encoding: "utf8" },
expect.any(Function),
);
});
test("warns and returns null when the final which line is not an absolute path", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { findExecutable } = await loadExecutableModule({
execFileImpl: (_command, _args, _options, callback) => {
callback(null, "codex\n", "");
},
});
expect(findExecutableSync("codex", findExecutableDependencies)).toBeNull(); await expect(findExecutable("codex")).resolves.toBeNull();
expect(warnSpy).toHaveBeenCalledOnce(); expect(warnSpy).toHaveBeenCalledOnce();
warnSpy.mockRestore(); warnSpy.mockRestore();
}); });
test("returns direct paths when they exist", () => { test("returns null when which lookup fails", async () => {
findExecutableDependencies.existsSync.mockReturnValue(true); const { findExecutable } = await loadExecutableModule({
execFileImpl: (_command, _args, _options, callback) => {
callback(new Error("which failed"), "", "");
},
});
expect(findExecutableSync("/usr/local/bin/codex", findExecutableDependencies)).toBe( await expect(findExecutable("codex")).resolves.toBeNull();
"/usr/local/bin/codex", });
); });
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");
describe("executableExists", () => {
const originalPlatform = process.platform;
function setPlatform(value: string) {
Object.defineProperty(process, "platform", { value, writable: true });
}
afterEach(() => {
setPlatform(originalPlatform);
});
test("returns the path when it already exists", async () => {
const { executableExists } = await loadExecutableModule();
const exists = vi.fn((candidate: string) => candidate === "/usr/local/bin/codex");
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", async () => {
setPlatform("win32");
const { executableExists } = await loadExecutableModule();
const exists = vi.fn((candidate: string) => candidate === "C:\\tools\\codex.cmd");
expect(executableExists("C:\\tools\\codex", exists)).toBe("C:\\tools\\codex.cmd");
});
test("returns null when no matching path exists", async () => {
const { executableExists } = await loadExecutableModule();
const exists = vi.fn(() => false);
expect(executableExists("/missing/codex", exists)).toBeNull();
}); });
}); });
@@ -87,27 +218,31 @@ describe("quoteWindowsCommand", () => {
setPlatform(originalPlatform); setPlatform(originalPlatform);
}); });
test("quotes a Windows path with spaces", () => { test("quotes a Windows path with spaces", async () => {
setPlatform("win32"); setPlatform("win32");
const { quoteWindowsCommand } = await loadExecutableModule();
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe( expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"', '"C:\\Program Files\\Anthropic\\claude.exe"',
); );
}); });
test("does not double-quote an already-quoted path", () => { test("does not double-quote an already-quoted path", async () => {
setPlatform("win32"); setPlatform("win32");
const { quoteWindowsCommand } = await loadExecutableModule();
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe( expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
'"C:\\Program Files\\Anthropic\\claude.exe"', '"C:\\Program Files\\Anthropic\\claude.exe"',
); );
}); });
test("returns the command unchanged when there are no spaces", () => { test("returns the command unchanged when there are no spaces", async () => {
setPlatform("win32"); setPlatform("win32");
const { quoteWindowsCommand } = await loadExecutableModule();
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex"); expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
}); });
test("returns the command unchanged on non-Windows platforms", () => { test("returns the command unchanged on non-Windows platforms", async () => {
setPlatform("darwin"); setPlatform("darwin");
const { quoteWindowsCommand } = await loadExecutableModule();
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
}); });
}); });
@@ -123,27 +258,31 @@ describe("quoteWindowsArgument", () => {
setPlatform(originalPlatform); setPlatform(originalPlatform);
}); });
test("quotes a Windows argument with spaces", () => { test("quotes a Windows argument with spaces", async () => {
setPlatform("win32"); setPlatform("win32");
const { quoteWindowsArgument } = await loadExecutableModule();
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe( expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"', '"C:\\Program Files\\Anthropic\\cli.js"',
); );
}); });
test("does not double-quote an already-quoted argument", () => { test("does not double-quote an already-quoted argument", async () => {
setPlatform("win32"); setPlatform("win32");
const { quoteWindowsArgument } = await loadExecutableModule();
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe( expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
'"C:\\Program Files\\Anthropic\\cli.js"', '"C:\\Program Files\\Anthropic\\cli.js"',
); );
}); });
test("returns the argument unchanged when there are no spaces", () => { test("returns the argument unchanged when there are no spaces", async () => {
setPlatform("win32"); setPlatform("win32");
const { quoteWindowsArgument } = await loadExecutableModule();
expect(quoteWindowsArgument("--version")).toBe("--version"); expect(quoteWindowsArgument("--version")).toBe("--version");
}); });
test("returns the argument unchanged on non-Windows platforms", () => { test("returns the argument unchanged on non-Windows platforms", async () => {
setPlatform("darwin"); setPlatform("darwin");
const { quoteWindowsArgument } = await loadExecutableModule();
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
}); });
}); });
+28 -61
View File
@@ -1,15 +1,21 @@
import { execFile, execFileSync } from "node:child_process"; import { execFile } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { platform } from "node:os"; import path, { extname } from "node:path";
import path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
export interface FindExecutableDependencies { function pickBestWindowsCandidate(lines: string[]): string | null {
execFileSync: typeof execFileSync; const candidates = lines.filter((line) => line.length > 0);
existsSync: typeof existsSync; if (candidates.length === 0) return null;
platform: typeof platform;
const extPriority = [".exe", ".cmd", ".ps1"];
for (const ext of extPriority) {
const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext));
if (match) return match;
}
return candidates[0] ?? null;
} }
function resolveExecutableFromWhichOutput( function resolveExecutableFromWhichOutput(
@@ -44,60 +50,20 @@ function resolveExecutableFromWhichOutput(
* enriches it at startup via inheritLoginShellEnv(); on Windows, Electron * enriches it at startup via inheritLoginShellEnv(); on Windows, Electron
* inherits the full user environment from Explorer. * inherits the full user environment from Explorer.
*/ */
export function findExecutableSync( export function executableExists(
name: string, executablePath: string,
dependencies?: FindExecutableDependencies, exists: typeof existsSync = existsSync,
): string | null { ): string | null {
const trimmed = name.trim(); if (exists(executablePath)) return executablePath;
if (!trimmed) { if (process.platform === "win32" && !extname(executablePath)) {
for (const ext of [".exe", ".cmd", ".ps1"]) {
const candidate = executablePath + ext;
if (exists(candidate)) return candidate;
}
}
return null; return null;
} }
const deps: FindExecutableDependencies = {
execFileSync,
existsSync,
platform,
...dependencies,
};
if (trimmed.includes("/") || trimmed.includes("\\")) {
return deps.existsSync(trimmed) ? trimmed : null;
}
if (deps.platform() === "win32") {
try {
const out = deps
.execFileSync("where.exe", [trimmed], {
encoding: "utf8",
windowsHide: true,
})
.trim();
return (
out
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0) ?? null
);
} catch {
return null;
}
}
try {
return resolveExecutableFromWhichOutput(
trimmed,
deps.execFileSync("which", [trimmed], { encoding: "utf8" }).trim(),
"which",
);
} catch {
return null;
}
}
export function isCommandAvailableSync(command: string): boolean {
return findExecutableSync(command) !== null;
}
export async function findExecutable(name: string): Promise<string | null> { export async function findExecutable(name: string): Promise<string | null> {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) { if (!trimmed) {
@@ -105,21 +71,22 @@ export async function findExecutable(name: string): Promise<string | null> {
} }
if (trimmed.includes("/") || trimmed.includes("\\")) { if (trimmed.includes("/") || trimmed.includes("\\")) {
return existsSync(trimmed) ? trimmed : null; return executableExists(trimmed);
} }
if (platform() === "win32") { if (process.platform === "win32") {
try { try {
const { stdout } = await execFileAsync("where.exe", [trimmed], { const { stdout } = await execFileAsync("where.exe", [trimmed], {
encoding: "utf8", encoding: "utf8",
windowsHide: true, windowsHide: true,
}); });
return ( return (
pickBestWindowsCandidate(
stdout stdout
.trim() .trim()
.split(/\r?\n/) .split(/\r?\n/)
.map((line) => line.trim()) .map((line) => line.trim()),
.find((line) => line.length > 0) ?? null ) ?? null
); );
} catch { } catch {
return null; return null;
+54
View File
@@ -0,0 +1,54 @@
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { execCommand } from "./spawn.js";
describe("execCommand", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const tempDir of tempDirs) {
rmSync(tempDir, { recursive: true, force: true });
}
tempDirs.length = 0;
});
test("returns stdout and stderr for a successful command", async () => {
await expect(execCommand("echo", ["hello"])).resolves.toEqual({
stdout: "hello\n",
stderr: "",
});
});
test("rejects when the command times out", async () => {
const command =
process.platform === "win32"
? {
command: process.execPath,
args: ["-e", "setTimeout(() => {}, 10_000)"],
}
: { command: "sleep", args: ["10"] };
await expect(execCommand(command.command, command.args, { timeout: 100 })).rejects.toThrow();
});
test("runs the command in the provided cwd", async () => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "spawn-test-")));
tempDirs.push(cwd);
const command =
process.platform === "win32"
? {
command: process.execPath,
args: ["-e", "console.log(process.cwd())"],
}
: { command: "pwd", args: [] };
await expect(execCommand(command.command, command.args, { cwd })).resolves.toEqual({
stdout: `${cwd}\n`,
stderr: "",
});
});
});
+57 -19
View File
@@ -1,15 +1,27 @@
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
interface ExecCommandOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
encoding?: BufferEncoding;
timeout?: number;
maxBuffer?: number;
}
interface ExecCommandResult {
stdout: string;
stderr: string;
}
function quoteForCmd(value: string): string {
if (!value.includes(" ")) return value;
if (value.startsWith('"') && value.endsWith('"')) return value;
return `"${value}"`;
}
/**
* Platform-aware spawn that centralizes Windows shell and quoting concerns.
*
* On Windows:
* - Enables `shell: true` (routes through cmd.exe) unless the caller explicitly sets `shell`
* - Quotes the command and arguments so paths with spaces survive cmd.exe parsing
* - Always sets `windowsHide: true` to prevent console window flashes
*
* On other platforms the call is passed through to `spawn` unchanged (with `windowsHide: true`).
*/
export function spawnProcess( export function spawnProcess(
command: string, command: string,
args: string[], args: string[],
@@ -27,12 +39,38 @@ export function spawnProcess(
}); });
} }
/** export async function execCommand(
* Quote a string for cmd.exe if it contains spaces and isn't already quoted. command: string,
* No-op for strings without spaces or strings that are already double-quoted. args: string[],
*/ options?: ExecCommandOptions,
function quoteForCmd(value: string): string { ): Promise<ExecCommandResult> {
if (!value.includes(" ")) return value; const isWindows = process.platform === "win32";
if (value.startsWith('"') && value.endsWith('"')) return value; const resolvedCommand = isWindows ? quoteForCmd(command) : command;
return `"${value}"`; const resolvedArgs = isWindows ? args.map(quoteForCmd) : args;
return execFileAsync(resolvedCommand, resolvedArgs, {
cwd: options?.cwd,
env: options?.env,
encoding: options?.encoding ?? "utf8",
timeout: options?.timeout,
maxBuffer: options?.maxBuffer,
shell: isWindows,
windowsHide: true,
}) as Promise<ExecCommandResult>;
}
export function platformShell(): { command: string; flag: string[] } {
if (process.platform === "win32") {
return { command: "cmd.exe", flag: ["/c"] };
}
return { command: "/bin/sh", flag: ["-lc"] };
}
export function platformBash(): { command: string; flag: string[] } {
if (process.platform === "win32") {
return { command: "cmd.exe", flag: ["/c"] };
}
return { command: "/bin/bash", flag: ["-lc"] };
} }
+5 -3
View File
@@ -1,4 +1,4 @@
import { exec, spawn } from "child_process"; import { exec } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs"; import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs";
import { join, basename, dirname, resolve, sep } from "path"; import { join, basename, dirname, resolve, sep } from "path";
@@ -13,6 +13,7 @@ import {
writePaseoWorktreeRuntimeMetadata, writePaseoWorktreeRuntimeMetadata,
} from "./worktree-metadata.js"; } from "./worktree-metadata.js";
import { runGitCommand } from "./run-git-command.js"; import { runGitCommand } from "./run-git-command.js";
import { platformBash, spawnProcess } from "./spawn.js";
import { resolvePaseoHome } from "../server/paseo-home.js"; import { resolvePaseoHome } from "../server/paseo-home.js";
interface PaseoConfig { interface PaseoConfig {
@@ -204,7 +205,7 @@ async function execSetupCommand(
const { stdout, stderr } = await execAsync(command, { const { stdout, stderr } = await execAsync(command, {
cwd: options.cwd, cwd: options.cwd,
env: options.env, env: options.env,
shell: "/bin/bash", ...(process.platform === "win32" ? {} : { shell: "/bin/bash" }),
}); });
return { return {
command, command,
@@ -275,7 +276,8 @@ async function execSetupCommandStreamed(options: {
cwd: options.cwd, cwd: options.cwd,
}); });
const child = spawn("/bin/bash", ["-lc", options.command], { const shell = platformBash();
const child = spawnProcess(shell.command, [...shell.flag, options.command], {
cwd: options.cwd, cwd: options.cwd,
env: options.env, env: options.env,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],