diff --git a/packages/app/src/app/agent/new.tsx b/packages/app/src/app/agent/new.tsx index 5fb67b83f..94cbfb059 100644 --- a/packages/app/src/app/agent/new.tsx +++ b/packages/app/src/app/agent/new.tsx @@ -244,6 +244,9 @@ export default function DraftAgentScreen() { const isNonGitDirectory = repoRequestStatus === "error" && /not in a git repository/i.test(repoRequestError?.message ?? ""); + const isDirectoryNotExists = + repoRequestStatus === "error" && + /does not exist|no such file or directory|ENOENT/i.test(repoRequestError?.message ?? ""); const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo ? "idle" : repoAvailabilityError @@ -490,6 +493,10 @@ export default function DraftAgentScreen() { setErrorMessage("Working directory is required"); throw new Error("Working directory is required"); } + if (isDirectoryNotExists) { + setErrorMessage("Working directory does not exist on the selected host"); + throw new Error("Working directory does not exist on the selected host"); + } if (!trimmedPrompt) { setErrorMessage("Initial prompt is required"); throw new Error("Initial prompt is required"); @@ -562,6 +569,7 @@ export default function DraftAgentScreen() { createNewBranch, createWorktree, gitBlockingError, + isDirectoryNotExists, isLoading, isNonGitDirectory, modeOptions, @@ -692,6 +700,13 @@ export default function DraftAgentScreen() { wrapInContainer={false} renderTrigger={renderDropdownTrigger} /> + {isDirectoryNotExists && ( + + + Directory does not exist on the selected host + + + )} {renderConfigRow({ label: "Agent", value: `${providerDefinitions.find((p) => p.id === selectedProvider)?.label ?? selectedProvider} ยท ${selectedModel || "auto"}`, @@ -1037,6 +1052,17 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.destructiveForeground, fontSize: theme.fontSize.sm, }, + warningContainer: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.palette.yellow[400], + }, + warningText: { + color: "#000000", + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, hostBadge: { flexDirection: "row", alignItems: "center", diff --git a/packages/server/src/server/daemon.e2e.test.ts b/packages/server/src/server/daemon.e2e.test.ts index 16dbc8afd..1e01f0c6f 100644 --- a/packages/server/src/server/daemon.e2e.test.ts +++ b/packages/server/src/server/daemon.e2e.test.ts @@ -82,6 +82,19 @@ describe("daemon E2E", () => { expect(hasAssistantMessage).toBe(true); }, 180000); // 3 minute timeout for E2E test + test("fails to create agent with non-existent cwd", async () => { + const nonExistentCwd = "/this/path/does/not/exist/12345"; + + const result = await ctx.client.createAgentExpectFail({ + provider: "codex", + cwd: nonExistentCwd, + title: "Should Fail Agent", + }); + + expect(result.error).toContain("Working directory does not exist"); + expect(result.error).toContain(nonExistentCwd); + }); + describe("permission flow: Codex", () => { test( "approves permission and executes command", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ae286b502..5b3d68a67 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,5 +1,5 @@ import { v4 as uuidv4 } from "uuid"; -import { readFile, mkdir, writeFile } from "fs/promises"; +import { readFile, mkdir, writeFile, stat } from "fs/promises"; import { exec } from "child_process"; import { promisify, inspect } from "util"; import { join } from "path"; @@ -1273,6 +1273,24 @@ export class Session { ); try { + // Validate that the working directory exists + const resolvedCwd = expandTilde(config.cwd); + try { + const stats = await stat(resolvedCwd); + if (!stats.isDirectory()) { + throw new Error( + `Working directory is not a directory: ${config.cwd}` + ); + } + } catch (statError: any) { + if (statError.code === "ENOENT") { + throw new Error( + `Working directory does not exist: ${config.cwd}` + ); + } + throw statError; + } + const sessionConfig = await this.buildAgentSessionConfig( config, git, diff --git a/packages/server/src/server/test-utils/daemon-client.ts b/packages/server/src/server/test-utils/daemon-client.ts index b90f61545..2e7fc33e8 100644 --- a/packages/server/src/server/test-utils/daemon-client.ts +++ b/packages/server/src/server/test-utils/daemon-client.ts @@ -197,6 +197,50 @@ export class DaemonClient { ); // 60 second timeout for initialization } + /** + * Create an agent and expect it to fail. + * Returns the error message from the failure. + */ + async createAgentExpectFail( + options: CreateAgentOptions + ): Promise<{ error: string }> { + const requestId = nanoid(); + + // Record the current queue position so we only check NEW messages + const startPosition = this.messageQueue.length; + + this.send({ + type: "create_agent_request", + requestId, + config: { + provider: options.provider, + cwd: options.cwd, + title: options.title, + model: options.model, + modeId: options.modeId, + mcpServers: options.mcpServers, + extra: options.extra, + }, + initialPrompt: options.initialPrompt, + }); + + // Wait for either agent_create_failed status or timeout + return this.waitFor( + (msg) => { + if ( + msg.type === "status" && + msg.payload.status === "agent_create_failed" && + msg.payload.requestId === requestId + ) { + return { error: String(msg.payload.error ?? "Unknown error") }; + } + return null; + }, + 10000, + { skipQueueBefore: startPosition } + ); + } + async deleteAgent(agentId: string): Promise { this.send({ type: "delete_agent_request", agentId }); await this.waitFor((msg) => {