diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index 9b09a7fa..dcbbb7ed 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -14,7 +14,7 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports | Field | Type | Required | Description | |-------|------|----------|-------------| -| `cwd` | string | Yes | Working directory for the agent process | +| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) | | `model` | string | No | Claude model to use (e.g. `claude-opus-4-6`) | | `promptTemplate` | string | No | Prompt used for all runs | | `env` | object | No | Environment variables (supports secret refs) | @@ -52,5 +52,5 @@ The adapter creates a temporary directory with symlinks to Paperclip skills and Use the "Test Environment" button in the UI to validate the adapter config. It checks: - Claude CLI is installed and accessible -- Working directory exists and is valid +- Working directory is absolute and available (auto-created if missing and permitted) - API key is configured (warning if missing) diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md index 79f5f873..f58213fb 100644 --- a/docs/adapters/codex-local.md +++ b/docs/adapters/codex-local.md @@ -14,7 +14,7 @@ The `codex_local` adapter runs OpenAI's Codex CLI locally. It supports session p | Field | Type | Required | Description | |-------|------|----------|-------------| -| `cwd` | string | Yes | Working directory for the agent process | +| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) | | `model` | string | No | Model to use | | `promptTemplate` | string | No | Prompt used for all runs | | `env` | object | No | Environment variables (supports secret refs) | @@ -35,5 +35,5 @@ The adapter symlinks Paperclip skills into the global Codex skills directory (`~ The environment test checks: - Codex CLI is installed and accessible -- Working directory exists and is valid +- Working directory is absolute and available (auto-created if missing and permitted) - API key is configured diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index c744f4ce..9de5b7cc 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -113,20 +113,40 @@ export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return { ...env, PATH: defaultPathForPlatform() }; } -export async function ensureAbsoluteDirectory(cwd: string) { +export async function ensureAbsoluteDirectory( + cwd: string, + opts: { createIfMissing?: boolean } = {}, +) { if (!path.isAbsolute(cwd)) { throw new Error(`Working directory must be an absolute path: "${cwd}"`); } - let stats; + const assertDirectory = async () => { + const stats = await fs.stat(cwd); + if (!stats.isDirectory()) { + throw new Error(`Working directory is not a directory: "${cwd}"`); + } + }; + try { - stats = await fs.stat(cwd); - } catch { - throw new Error(`Working directory does not exist: "${cwd}"`); + await assertDirectory(); + return; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (!opts.createIfMissing || code !== "ENOENT") { + if (code === "ENOENT") { + throw new Error(`Working directory does not exist: "${cwd}"`); + } + throw err instanceof Error ? err : new Error(String(err)); + } } - if (!stats.isDirectory()) { - throw new Error(`Working directory is not a directory: "${cwd}"`); + try { + await fs.mkdir(cwd, { recursive: true }); + await assertDirectory(); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`Could not create working directory "${cwd}": ${reason}`); } } diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 6e96361f..481c305d 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -12,7 +12,7 @@ export const agentConfigurationDoc = `# claude_local agent configuration Adapter: claude_local Core fields: -- cwd (string, optional): default absolute working directory fallback for the agent process +- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) - instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime - model (string, optional): Claude model id - effort (string, optional): reasoning effort passed via --effort (low|medium|high) diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index eeef39b5..9827544d 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -116,7 +116,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); - await ensureAbsoluteDirectory(cwd); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const envConfig = parseObject(config.env); const hasExplicitApiKey = diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index d74703e2..bc44d9dc 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -30,7 +30,7 @@ export async function testEnvironment( const cwd = asString(config.cwd, process.cwd()); try { - await ensureAbsoluteDirectory(cwd); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); checks.push({ code: "claude_cwd_valid", level: "info", diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index 92dd95ca..0b9b6103 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -18,7 +18,7 @@ export const agentConfigurationDoc = `# codex_local agent configuration Adapter: codex_local Core fields: -- cwd (string, optional): default absolute working directory fallback for the agent process +- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) - instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to stdin prompt at runtime - model (string, optional): Codex model id - modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high) passed via -c model_reasoning_effort=... diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 0e21b9f4..47ce9e96 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -132,7 +132,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); - await ensureAbsoluteDirectory(cwd); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); await ensureCodexSkillsInjected(onLog); const envConfig = parseObject(config.env); const hasExplicitApiKey = diff --git a/packages/adapters/codex-local/src/server/test.ts b/packages/adapters/codex-local/src/server/test.ts index 8f89cbd6..606ec233 100644 --- a/packages/adapters/codex-local/src/server/test.ts +++ b/packages/adapters/codex-local/src/server/test.ts @@ -30,7 +30,7 @@ export async function testEnvironment( const cwd = asString(config.cwd, process.cwd()); try { - await ensureAbsoluteDirectory(cwd); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); checks.push({ code: "codex_cwd_valid", level: "info", diff --git a/server/src/__tests__/claude-local-adapter-environment.test.ts b/server/src/__tests__/claude-local-adapter-environment.test.ts index 8fe62025..7fd96824 100644 --- a/server/src/__tests__/claude-local-adapter-environment.test.ts +++ b/server/src/__tests__/claude-local-adapter-environment.test.ts @@ -1,4 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { testEnvironment } from "@paperclipai/adapter-claude-local/server"; const ORIGINAL_ANTHROPIC = process.env.ANTHROPIC_API_KEY; @@ -60,4 +63,29 @@ describe("claude_local environment diagnostics", () => { ).toBe(true); expect(result.checks.some((check) => check.level === "error")).toBe(false); }); + + it("creates a missing working directory when cwd is absolute", async () => { + const cwd = path.join( + os.tmpdir(), + `paperclip-claude-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`, + "workspace", + ); + + await fs.rm(path.dirname(cwd), { recursive: true, force: true }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "claude_local", + config: { + command: process.execPath, + cwd, + }, + }); + + expect(result.checks.some((check) => check.code === "claude_cwd_valid")).toBe(true); + expect(result.checks.some((check) => check.level === "error")).toBe(false); + const stats = await fs.stat(cwd); + expect(stats.isDirectory()).toBe(true); + await fs.rm(path.dirname(cwd), { recursive: true, force: true }); + }); }); diff --git a/server/src/__tests__/codex-local-adapter-environment.test.ts b/server/src/__tests__/codex-local-adapter-environment.test.ts new file mode 100644 index 00000000..9814334d --- /dev/null +++ b/server/src/__tests__/codex-local-adapter-environment.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { testEnvironment } from "@paperclipai/adapter-codex-local/server"; + +describe("codex_local environment diagnostics", () => { + it("creates a missing working directory when cwd is absolute", async () => { + const cwd = path.join( + os.tmpdir(), + `paperclip-codex-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`, + "workspace", + ); + + await fs.rm(path.dirname(cwd), { recursive: true, force: true }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "codex_local", + config: { + command: process.execPath, + cwd, + }, + }); + + expect(result.checks.some((check) => check.code === "codex_cwd_valid")).toBe(true); + expect(result.checks.some((check) => check.level === "error")).toBe(false); + const stats = await fs.stat(cwd); + expect(stats.isDirectory()).toBe(true); + await fs.rm(path.dirname(cwd), { recursive: true, force: true }); + }); +});