From 8f0c1d45484e10d60bd99149a3b8a6d4c1798b77 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:33:38 -0500 Subject: [PATCH] feat(cli): add isolated test-drive command (#12894) Add a foreground-only test-drive workflow with isolated data, provider-backed CEO bootstrap, OpenCode/OpenRouter support, worktree execution setup, reuse safeguards, and delayed browser opening. Co-Authored-By: Paperclip --- README.md | 19 + cli/src/__tests__/git-workspace.test.ts | 43 ++ cli/src/__tests__/test-drive.test.ts | 514 ++++++++++++++++++ cli/src/commands/git-workspace.ts | 51 ++ cli/src/commands/run.ts | 38 +- cli/src/commands/test-drive.ts | 466 ++++++++++++++++ cli/src/commands/worktree.ts | 41 +- cli/src/index.ts | 25 +- doc/CLI.md | 79 +++ doc/DEVELOPING.md | 40 ++ .../opencode-local/src/server/execute.test.ts | 69 +++ server/src/__tests__/env-file-policy.test.ts | 28 + server/src/config.ts | 9 +- server/src/env-file-policy.ts | 10 + server/src/index.ts | 167 +++--- 15 files changed, 1470 insertions(+), 129 deletions(-) create mode 100644 cli/src/__tests__/git-workspace.test.ts create mode 100644 cli/src/__tests__/test-drive.test.ts create mode 100644 cli/src/commands/git-workspace.ts create mode 100644 cli/src/commands/test-drive.ts create mode 100644 server/src/__tests__/env-file-policy.test.ts create mode 100644 server/src/env-file-policy.ts diff --git a/README.md b/README.md index 969882e929..8096cf9bbc 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,25 @@ To try Paperclip without installing anything permanently: npx --registry https://registry.npmjs.org paperclipai onboard --yes ``` +For an isolated manual test instance that is already initialized with a CEO +agent, use `test-drive`. It stays in the foreground, never installs a service +or creates a first task, and opens the browser only after setup succeeds: + +```bash +ANTHROPIC_API_KEY=... npx paperclipai test-drive +OPENAI_API_KEY=... npx paperclipai test-drive --harness codex +OPENROUTER_API_KEY=... npx paperclipai test-drive \ + --harness opencode \ + --model openrouter/anthropic/claude-sonnet-4.5 +``` + +Each run without `--data-dir` gets a unique, retained temporary directory; its +absolute path is printed at startup. Pass `--data-dir` to reuse one, or +`--no-browser` to leave the initialized instance unopened. When invoked from a +linked Git worktree, `test-drive` also enables task execution in that worktree. +See [`doc/CLI.md`](doc/CLI.md#isolated-manual-test-drives) for credential and +reuse behavior. + > **Troubleshooting: private npm registry `.npmrc`** > > If this fails with an `E404` for `paperclipai` (or similar) and you use a private npm registry (for example GitHub Packages) via a global `~/.npmrc`, `npx` may be resolving `paperclipai` against that private registry instead of the public npm registry. diff --git a/cli/src/__tests__/git-workspace.test.ts b/cli/src/__tests__/git-workspace.test.ts new file mode 100644 index 0000000000..5bdb3627bc --- /dev/null +++ b/cli/src/__tests__/git-workspace.test.ts @@ -0,0 +1,43 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { afterEach, describe, expect, it } from "vitest"; +import { detectGitWorkspaceInfo, isLinkedGitWorktree } from "../commands/git-workspace.js"; + +const cleanupDirectories: string[] = []; + +afterEach(() => { + while (cleanupDirectories.length > 0) { + fs.rmSync(cleanupDirectories.pop()!, { recursive: true, force: true }); + } +}); + +describe("Git worktree detection", () => { + it("distinguishes a linked worktree from the primary checkout and non-Git paths", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-git-workspace-")); + cleanupDirectories.push(root); + const primary = path.join(root, "primary"); + const linked = path.join(root, "linked"); + fs.mkdirSync(primary); + execFileSync("git", ["init"], { cwd: primary, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: primary }); + execFileSync("git", ["config", "user.name", "Paperclip Test"], { cwd: primary }); + fs.writeFileSync(path.join(primary, "README.md"), "test\n"); + execFileSync("git", ["add", "README.md"], { cwd: primary }); + execFileSync("git", ["commit", "-m", "initial"], { cwd: primary, stdio: "ignore" }); + execFileSync("git", ["worktree", "add", "-b", "linked-test", linked], { + cwd: primary, + stdio: "ignore", + }); + + const primaryInfo = detectGitWorkspaceInfo(primary); + const linkedInfo = detectGitWorkspaceInfo(linked); + expect(primaryInfo?.gitDir).toBe(primaryInfo?.commonDir); + expect(linkedInfo?.gitDir).not.toBe(linkedInfo?.commonDir); + expect(isLinkedGitWorktree(primary)).toBe(false); + expect(isLinkedGitWorktree(linked)).toBe(true); + expect(detectGitWorkspaceInfo(root)).toBeNull(); + expect(isLinkedGitWorktree(root)).toBe(false); + }); +}); diff --git a/cli/src/__tests__/test-drive.test.ts b/cli/src/__tests__/test-drive.test.ts new file mode 100644 index 0000000000..63ba3017b8 --- /dev/null +++ b/cli/src/__tests__/test-drive.test.ts @@ -0,0 +1,514 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Agent, Company, InstanceExperimentalSettings } from "@paperclipai/shared"; +import { + assertTestDriveDatabaseIsolation, + bootstrapTestDrive, + prepareTestDriveEnvironment, + reconcileTestDriveWorktreeExecution, + redactTestDriveText, + resolveTestDriveBootstrap, + resolveTestDriveDataDir, + testDriveCommand, + type TestDriveApi, + type TestDriveHarness, +} from "../commands/test-drive.js"; +import type { RunOptions, StartedServer } from "../commands/run.js"; +import type { PaperclipConfig } from "../config/schema.js"; + +const ORIGINAL_ENV = { ...process.env }; +const cleanupDirectories: string[] = []; + +function company(id = "company-1", name = "Test Company"): Company { + return { id, name } as Company; +} + +function agent(overrides: Partial = {}): Agent { + return { + id: "agent-1", + companyId: "company-1", + name: "CEO", + role: "ceo", + adapterType: "claude_local", + adapterConfig: {}, + ...overrides, + } as Agent; +} + +function settings(overrides: Partial = {}): InstanceExperimentalSettings { + return { + enableWorktreeRunExecution: false, + worktreeRunExecutionActivatedAt: null, + worktreeRunExecutionActivationInstanceId: null, + ...overrides, + } as InstanceExperimentalSettings; +} + +function freshBootstrapApi(input?: { failAgent?: boolean }) { + const calls: Array<{ method: string; path: string; body?: unknown }> = []; + const api = { + get: vi.fn(async (requestPath: string) => { + calls.push({ method: "GET", path: requestPath }); + return [] as T; + }), + post: vi.fn(async (requestPath: string, body?: unknown) => { + calls.push({ method: "POST", path: requestPath, body }); + if (requestPath === "/api/companies") return company() as T; + if (requestPath.endsWith("/agents")) { + if (input?.failAgent) throw new Error("agent setup failed"); + const payload = body as { name: string; adapterType: Agent["adapterType"]; adapterConfig: Record }; + return agent({ + name: payload.name, + adapterType: payload.adapterType, + adapterConfig: payload.adapterConfig, + }) as T; + } + return { ok: true } as T; + }), + patch: vi.fn(async () => ({ ok: true }) as T), + delete: vi.fn(async (requestPath: string) => { + calls.push({ method: "DELETE", path: requestPath }); + return { ok: true } as T; + }), + } as TestDriveApi; + return { api, calls }; +} + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + while (cleanupDirectories.length > 0) { + fs.rmSync(cleanupDirectories.pop()!, { recursive: true, force: true }); + } + vi.restoreAllMocks(); +}); + +describe("test-drive data isolation", () => { + it("creates unique retained OS temporary directories and reports absolute paths", () => { + const first = resolveTestDriveDataDir(); + const second = resolveTestDriveDataDir(); + cleanupDirectories.push(first, second); + + expect(path.isAbsolute(first)).toBe(true); + expect(path.dirname(first)).toBe(os.tmpdir()); + expect(first).not.toBe(second); + expect(fs.existsSync(first)).toBe(true); + expect(fs.existsSync(second)).toBe(true); + }); + + it("resolves an explicit reusable directory without resetting it", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-test-drive-explicit-")); + cleanupDirectories.push(root); + const marker = path.join(root, "keep.txt"); + fs.writeFileSync(marker, "keep"); + + expect(resolveTestDriveDataDir(root)).toBe(path.resolve(root)); + expect(fs.readFileSync(marker, "utf8")).toBe("keep"); + }); + + it("discards inherited Paperclip routing while preserving a custom key source", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-test-drive-env-")); + cleanupDirectories.push(root); + process.env.PAPERCLIP_HOME = "/normal/home"; + process.env.PAPERCLIP_CONFIG = "/normal/config.json"; + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_TEST_PROVIDER_KEY = "secret-value"; + process.env.DATABASE_URL = "postgres://normal-instance"; + + const prepared = await prepareTestDriveEnvironment( + { dataDir: root, apiKeyEnv: "PAPERCLIP_TEST_PROVIDER_KEY" }, + os.tmpdir(), + ); + + expect(prepared.dataDir).toBe(path.resolve(root)); + expect(prepared.linkedWorktree).toBe(false); + expect(process.env.PAPERCLIP_HOME).toBe(path.resolve(root)); + expect(process.env.PAPERCLIP_CONFIG).toBe( + path.join(path.resolve(root), "instances", "default", "config.json"), + ); + expect(process.env.PAPERCLIP_IN_WORKTREE).toBe("false"); + expect(process.env.PAPERCLIP_DISABLE_CWD_ENV_FILE).toBe("true"); + expect(process.env.PAPERCLIP_DEPLOYMENT_MODE).toBe("local_trusted"); + expect(process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE).toBe("private"); + expect(process.env.PAPERCLIP_BIND).toBe("loopback"); + expect(process.env.HOST).toBe("127.0.0.1"); + expect(process.env.PAPERCLIP_TEST_PROVIDER_KEY).toBe("secret-value"); + expect(process.env.DATABASE_URL).toBeUndefined(); + expect(Number(process.env.PORT)).toBeGreaterThanOrEqual(3100); + }); + + it.each(["DATABASE_URL", "DATABASE_MIGRATION_URL"])( + "rejects %s loaded from the isolated directory", + (variable) => { + const readConfigFile = vi.fn(() => null); + expect(() => assertTestDriveDatabaseIsolation( + undefined, + { [variable]: "postgres://external-database" }, + readConfigFile, + )).toThrow(/requires its isolated embedded database/); + expect(readConfigFile).not.toHaveBeenCalled(); + }, + ); + + it("rejects an explicitly reused PostgreSQL configuration", () => { + const externalConfig = { + database: { mode: "postgres" }, + } as PaperclipConfig; + expect(() => assertTestDriveDatabaseIsolation( + "/tmp/reused/config.json", + {}, + () => externalConfig, + )).toThrow(/cannot reuse.*external PostgreSQL database/); + }); + + it("accepts an embedded configuration", () => { + const embeddedConfig = { + database: { mode: "embedded-postgres" }, + } as PaperclipConfig; + expect(() => assertTestDriveDatabaseIsolation( + "/tmp/reused/config.json", + {}, + () => embeddedConfig, + )).not.toThrow(); + }); +}); + +describe("test-drive bootstrap validation", () => { + it("uses Claude defaults and the canonical environment credential", () => { + const resolved = resolveTestDriveBootstrap({}, { ANTHROPIC_API_KEY: "anthropic-secret" }); + expect(resolved).toMatchObject({ + companyName: "Test Company", + agentName: "CEO", + adapterType: "claude_local", + credentialTarget: "ANTHROPIC_API_KEY", + credential: "anthropic-secret", + }); + expect(resolved.model).toBeUndefined(); + }); + + it("maps all harnesses and leaves Claude/Codex models optional", () => { + const cases: Array<[TestDriveHarness, string, string]> = [ + ["claude", "claude_local", "ANTHROPIC_API_KEY"], + ["codex", "codex_local", "OPENAI_API_KEY"], + ["opencode", "opencode_local", "OPENROUTER_API_KEY"], + ]; + for (const [harness, adapterType, credentialTarget] of cases) { + const resolved = resolveTestDriveBootstrap( + { + harness, + ...(harness === "opencode" ? { model: "openrouter/anthropic/claude-sonnet-4.5" } : {}), + }, + { [credentialTarget]: "provider-secret" }, + ); + expect(resolved.adapterType).toBe(adapterType); + expect(resolved.credentialTarget).toBe(credentialTarget); + } + }); + + it("requires an OpenRouter OpenCode model and preserves every model path segment", () => { + expect(() => resolveTestDriveBootstrap( + { harness: "opencode" }, + { OPENROUTER_API_KEY: "secret" }, + )).toThrow(/require --model openrouter/); + for (const model of ["anthropic/claude", "openrouter/", "openrouter//claude", "openrouter/a/"]) { + expect(() => resolveTestDriveBootstrap( + { harness: "opencode", model }, + { OPENROUTER_API_KEY: "secret" }, + )).toThrow(/require --model openrouter/); + } + + const model = "openrouter/publisher/family/model"; + expect(resolveTestDriveBootstrap( + { harness: "opencode", model }, + { OPENROUTER_API_KEY: "secret" }, + ).model).toBe(model); + }); + + it("supports custom source variables while retaining the canonical target", () => { + const resolved = resolveTestDriveBootstrap( + { + harness: "opencode", + model: "openrouter/anthropic/claude-sonnet-4.5", + apiKeyEnv: "MY_OPENROUTER_KEY", + }, + { MY_OPENROUTER_KEY: "custom-secret" }, + ); + expect(resolved.credential).toBe("custom-secret"); + expect(resolved.credentialSource).toBe("MY_OPENROUTER_KEY"); + expect(resolved.credentialTarget).toBe("OPENROUTER_API_KEY"); + }); + + it("rejects invalid key variable names and redacts credentials", () => { + expect(() => resolveTestDriveBootstrap({ + apiKeyEnv: "NOT-A-VALID-NAME", + }, { ANTHROPIC_API_KEY: "env-secret" })).toThrow(/valid environment variable/); + expect(redactTestDriveText( + "custom-secret and env-secret must never appear", + ["custom-secret", "env-secret"], + )).toBe("[REDACTED] and [REDACTED] must never appear"); + }); +}); + +describe("test-drive API bootstrap", () => { + it.each([ + ["claude", "claude_local", "ANTHROPIC_API_KEY", undefined], + ["codex", "codex_local", "OPENAI_API_KEY", undefined], + ["opencode", "opencode_local", "OPENROUTER_API_KEY", "openrouter/anthropic/claude-sonnet-4.5"], + ] as const)("creates exactly one company and one CEO for %s", async ( + harness, + adapterType, + credentialTarget, + model, + ) => { + const { api, calls } = freshBootstrapApi(); + const result = await bootstrapTestDrive({ + api, + options: { harness, ...(model ? { model } : {}) }, + linkedWorktree: false, + instanceId: "default", + env: { [credentialTarget]: "secret" }, + }); + + expect(result.reused).toBe(false); + expect(result.agent?.role).toBe("ceo"); + expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual([ + "GET /api/companies", + "POST /api/companies", + "POST /api/companies/company-1/user-secret-definitions", + "POST /api/companies/company-1/me/user-secrets", + "POST /api/companies/company-1/agents", + ]); + const secretValueCall = calls.find((call) => call.path.endsWith("/me/user-secrets")); + expect(secretValueCall?.body).toEqual({ definitionKey: credentialTarget, value: "secret" }); + const agentCall = calls.find((call) => call.path.endsWith("/agents")); + expect(agentCall?.body).toEqual({ + name: "CEO", + role: "ceo", + adapterType, + adapterConfig: { + ...(model ? { model } : {}), + env: { + [credentialTarget]: { + type: "user_secret_ref", + key: credentialTarget, + version: "latest", + required: true, + }, + }, + }, + }); + expect(calls.some((call) => /issues|projects|goals|tasks|heartbeat/.test(call.path))).toBe(false); + }); + + it("rejects invalid OpenCode configuration before creating a company", async () => { + const { api, calls } = freshBootstrapApi(); + await expect(bootstrapTestDrive({ + api, + options: { harness: "opencode" }, + linkedWorktree: false, + instanceId: "default", + env: { OPENROUTER_API_KEY: "secret" }, + })).rejects.toThrow(/require --model openrouter/); + expect(calls).toEqual([{ method: "GET", path: "/api/companies" }]); + }); + + it("preserves seeded data and ignores every bootstrap flag", async () => { + const get = vi.fn(async (requestPath: string) => { + if (requestPath === "/api/companies") return [company("existing", "Existing Company")] as T; + throw new Error(`Unexpected GET ${requestPath}`); + }); + const api = { + get, + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as TestDriveApi; + + const result = await bootstrapTestDrive({ + api, + options: { harness: "opencode", companyName: "Ignored" }, + linkedWorktree: false, + instanceId: "default", + env: {}, + }); + + expect(result).toMatchObject({ reused: true, company: { id: "existing" }, agent: null }); + expect(api.post).not.toHaveBeenCalled(); + expect(api.patch).not.toHaveBeenCalled(); + expect(api.delete).not.toHaveBeenCalled(); + }); + + it("deletes only the newly-created company when fresh bootstrap fails", async () => { + const { api, calls } = freshBootstrapApi({ failAgent: true }); + await expect(bootstrapTestDrive({ + api, + options: {}, + linkedWorktree: false, + instanceId: "default", + env: { ANTHROPIC_API_KEY: "secret" }, + })).rejects.toThrow("agent setup failed"); + expect(calls.at(-1)).toEqual({ method: "DELETE", path: "/api/companies/company-1" }); + }); +}); + +describe("test-drive worktree setting reconciliation", () => { + function worktreeApi(initial: InstanceExperimentalSettings, instanceId = "test-instance") { + let current = initial; + const patchBodies: unknown[] = []; + const api = { + get: vi.fn(async () => current as T), + patch: vi.fn(async (_path: string, body?: unknown) => { + patchBodies.push(body); + const enabled = (body as { enableWorktreeRunExecution: boolean }).enableWorktreeRunExecution; + current = settings({ + ...current, + enableWorktreeRunExecution: enabled, + worktreeRunExecutionActivatedAt: enabled ? "2026-09-05T12:00:00.000Z" : null, + worktreeRunExecutionActivationInstanceId: enabled ? instanceId : null, + }); + return current as T; + }), + post: vi.fn(), + delete: vi.fn(), + } as unknown as TestDriveApi; + return { api, patchBodies }; + } + + it("enables a disabled setting", async () => { + const { api, patchBodies } = worktreeApi(settings()); + await reconcileTestDriveWorktreeExecution(api, "test-instance"); + expect(patchBodies).toEqual([{ enableWorktreeRunExecution: true }]); + }); + + it("leaves a correctly armed setting unchanged", async () => { + const { api, patchBodies } = worktreeApi(settings({ + enableWorktreeRunExecution: true, + worktreeRunExecutionActivatedAt: "2026-09-05T11:00:00.000Z", + worktreeRunExecutionActivationInstanceId: "test-instance", + })); + await reconcileTestDriveWorktreeExecution(api, "test-instance"); + expect(patchBodies).toEqual([]); + }); + + it.each([ + settings({ enableWorktreeRunExecution: true }), + settings({ + enableWorktreeRunExecution: true, + worktreeRunExecutionActivatedAt: "2026-09-05T11:00:00.000Z", + worktreeRunExecutionActivationInstanceId: "another-instance", + }), + ])("rearms missing or mismatched activation metadata", async (initial) => { + const { api, patchBodies } = worktreeApi(initial); + await reconcileTestDriveWorktreeExecution(api, "test-instance"); + expect(patchBodies).toEqual([ + { enableWorktreeRunExecution: false }, + { enableWorktreeRunExecution: true }, + ]); + }); + + it("fails when the setting cannot be armed for this instance", async () => { + const api = { + get: vi.fn(async () => settings() as T), + patch: vi.fn(async () => settings() as T), + post: vi.fn(), + delete: vi.fn(), + } as unknown as TestDriveApi; + await expect(reconcileTestDriveWorktreeExecution(api, "test-instance")) + .rejects.toThrow(/Could not arm/); + }); +}); + +describe("test-drive foreground lifecycle", () => { + const server: StartedServer = { + apiUrl: "http://127.0.0.1:3100/api", + databaseUrl: "postgres://embedded", + host: "127.0.0.1", + listenPort: 3100, + }; + + it("skips service-manager integration for an auto-created directory and opens after initialization", async () => { + process.env.PAPERCLIP_HOME = "/tmp/test-drive-lifecycle"; + process.env.PAPERCLIP_INSTANCE_ID = "default"; + process.env.PAPERCLIP_IN_WORKTREE = "false"; + process.env.ANTHROPIC_API_KEY = "secret"; + const events: string[] = []; + let runOptions: RunOptions | undefined; + const api = { + get: vi.fn(async () => { + events.push("initialized"); + return [company("existing", "Existing")] as T; + }), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as TestDriveApi; + + await testDriveCommand({}, { + run: async (options) => { + runOptions = options; + events.push("listening"); + await options.afterStart?.(server); + }, + createApi: () => api, + openBrowser: async () => { + events.push("browser"); + return true; + }, + }); + + expect(runOptions).toMatchObject({ + yes: true, + bind: "loopback", + installService: false, + skipServiceManagerCheck: true, + introLabel: "paperclipai test-drive", + }); + expect(events).toEqual(["listening", "initialized", "browser"]); + }); + + it("retains the managed-instance collision guard for an explicitly reused directory", async () => { + process.env.PAPERCLIP_HOME = "/tmp/test-drive-reused"; + process.env.PAPERCLIP_INSTANCE_ID = "default"; + process.env.PAPERCLIP_IN_WORKTREE = "false"; + let runOptions: RunOptions | undefined; + const api = { + get: vi.fn(async () => [company("existing", "Existing")] as T), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as TestDriveApi; + + await testDriveCommand({ dataDir: "/tmp/test-drive-reused", browser: false }, { + run: async (options) => { + runOptions = options; + await options.afterStart?.(server); + }, + createApi: () => api, + openBrowser: vi.fn(async () => true), + }); + + expect(runOptions?.skipServiceManagerCheck).toBe(false); + }); + + it("honors --no-browser after successful initialization", async () => { + process.env.PAPERCLIP_HOME = "/tmp/test-drive-no-browser"; + process.env.PAPERCLIP_INSTANCE_ID = "default"; + process.env.PAPERCLIP_IN_WORKTREE = "false"; + const api = { + get: vi.fn(async () => [company("existing", "Existing")] as T), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + } as unknown as TestDriveApi; + const openBrowser = vi.fn(async () => true); + + await testDriveCommand({ browser: false }, { + run: async (options) => options.afterStart?.(server), + createApi: () => api, + openBrowser, + }); + + expect(openBrowser).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/commands/git-workspace.ts b/cli/src/commands/git-workspace.ts new file mode 100644 index 0000000000..e3145cb17e --- /dev/null +++ b/cli/src/commands/git-workspace.ts @@ -0,0 +1,51 @@ +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +export type GitWorkspaceInfo = { + root: string; + commonDir: string; + gitDir: string; + hooksPath: string; +}; + +/** + * Resolve the repository metadata Git exposes for both primary checkouts and + * linked worktrees. Returns null outside a Git working tree. + */ +export function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null { + try { + const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const gitDirRaw = execFileSync("git", ["rev-parse", "--git-dir"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const hooksPathRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return { + root: path.resolve(root), + commonDir: path.resolve(root, commonDirRaw), + gitDir: path.resolve(root, gitDirRaw), + hooksPath: path.resolve(root, hooksPathRaw), + }; + } catch { + return null; + } +} + +export function isLinkedGitWorktree(cwd: string): boolean { + const workspace = detectGitWorkspaceInfo(cwd); + return Boolean(workspace && workspace.gitDir !== workspace.commonDir); +} diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index 9269851bbd..a141a9a42c 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -21,26 +21,37 @@ import { removeRuntimeInfoForPid, writeRuntimeInfo } from "../runtime-info.js"; import { printUpdateNotice } from "../update-notice.js"; import { ensureWorktreeSeeded } from "./worktree.js"; -interface RunOptions { +export interface RunOptions { config?: string; instance?: string; repair?: boolean; yes?: boolean; bind?: "loopback" | "lan" | "tailnet"; force?: boolean; + /** Internal lifecycle option used by foreground-only commands. */ + installService?: boolean; + /** Internal lifecycle option for isolated instances that cannot collide with a managed service. */ + skipServiceManagerCheck?: boolean; + /** Internal label override for commands that reuse the foreground run path. */ + introLabel?: string; + /** Runs after the server is listening and all normal post-start initialization has completed. */ + afterStart?: (server: StartedServer) => Promise; } -interface StartedServer { +export interface StartedServer { apiUrl: string; databaseUrl: string; host: string; listenPort: number; + shutdown?: (signal?: "SIGINT" | "SIGTERM") => Promise; } export async function runCommand(opts: RunOptions): Promise { const instanceId = resolvePaperclipInstanceId(opts.instance); process.env.PAPERCLIP_INSTANCE_ID = instanceId; - await assertForegroundRunAllowed(instanceId, opts.force); + if (!opts.skipServiceManagerCheck) { + await assertForegroundRunAllowed(instanceId, opts.force); + } const homeDir = resolvePaperclipHomeDir(); fs.mkdirSync(homeDir, { recursive: true }); @@ -53,20 +64,26 @@ export async function runCommand(opts: RunOptions): Promise { loadPaperclipEnvFile(configPath); await printUpdateNotice(configPath); - p.intro(pc.bgCyan(pc.black(" paperclipai run "))); + p.intro(pc.bgCyan(pc.black(` ${opts.introLabel ?? "paperclipai run"} `))); p.log.message(pc.dim(`Home: ${paths.homeDir}`)); p.log.message(pc.dim(`Instance: ${paths.instanceId}`)); p.log.message(pc.dim(`Config: ${configPath}`)); if (!configExists(configPath)) { - if (!process.stdin.isTTY || !process.stdout.isTTY) { + if ((!process.stdin.isTTY || !process.stdout.isTTY) && !opts.yes) { p.log.error("No config found and terminal is non-interactive."); p.log.message(`Run ${pc.cyan("paperclipai onboard")} once, then retry ${pc.cyan("paperclipai run")}.`); process.exit(1); } p.log.step("No config found. Starting onboarding..."); - await onboard({ config: configPath, invokedByRun: true, bind: opts.bind }); + await onboard({ + config: configPath, + invokedByRun: true, + bind: opts.bind, + yes: opts.yes, + installService: opts.installService, + }); } const seedResult = await ensureWorktreeSeeded({ config: configPath }); @@ -113,6 +130,15 @@ export async function runCommand(opts: RunOptions): Promise { baseUrl: resolveBootstrapInviteBaseUrl(config, startedServer), }); } + + if (opts.afterStart) { + try { + await opts.afterStart(startedServer); + } catch (error) { + await startedServer.shutdown?.("SIGTERM"); + throw error; + } + } } function resolveBootstrapInviteBaseUrl( diff --git a/cli/src/commands/test-drive.ts b/cli/src/commands/test-drive.ts new file mode 100644 index 0000000000..eb6b87bc3a --- /dev/null +++ b/cli/src/commands/test-drive.ts @@ -0,0 +1,466 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createServer } from "node:net"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { Option, type Command } from "commander"; +import type { Agent, Company, InstanceExperimentalSettings } from "@paperclipai/shared"; +import { PaperclipApiClient } from "../client/http.js"; +import { openUrl } from "../client/board-auth.js"; +import { + expandHomePrefix, + resolveDefaultConfigPath, + resolveDefaultContextPath, +} from "../config/home.js"; +import { readConfig } from "../config/store.js"; +import type { PaperclipConfig } from "../config/schema.js"; +import { runCommand, type StartedServer } from "./run.js"; +import { isLinkedGitWorktree } from "./git-workspace.js"; + +export const TEST_DRIVE_HARNESSES = ["claude", "codex", "opencode"] as const; +export type TestDriveHarness = (typeof TEST_DRIVE_HARNESSES)[number]; + +export interface TestDriveOptions { + dataDir?: string; + companyName?: string; + agentName?: string; + harness?: TestDriveHarness; + model?: string; + apiKeyEnv?: string; + browser?: boolean; +} + +export type TestDriveApi = Pick; + +type HarnessDefinition = { + adapterType: "claude_local" | "codex_local" | "opencode_local"; + credentialTarget: "ANTHROPIC_API_KEY" | "OPENAI_API_KEY" | "OPENROUTER_API_KEY"; + credentialName: string; +}; + +export type ResolvedTestDriveBootstrap = HarnessDefinition & { + companyName: string; + agentName: string; + model?: string; + credential: string; + credentialSource: string; +}; + +export type TestDriveBootstrapResult = { + reused: boolean; + company: Company; + agent: Agent | null; +}; + +export interface TestDriveDependencies { + run: typeof runCommand; + createApi: (apiBase: string) => TestDriveApi; + openBrowser: (url: string) => Promise; +} + +const HARNESS_DEFINITIONS: Record = { + claude: { + adapterType: "claude_local", + credentialTarget: "ANTHROPIC_API_KEY", + credentialName: "Anthropic API Key", + }, + codex: { + adapterType: "codex_local", + credentialTarget: "OPENAI_API_KEY", + credentialName: "OpenAI API Key", + }, + opencode: { + adapterType: "opencode_local", + credentialTarget: "OPENROUTER_API_KEY", + credentialName: "OpenRouter API Key", + }, +}; + +const NON_PAPERCLIP_ISOLATED_ENV_KEYS = [ + "DATABASE_URL", + "DATABASE_MIGRATION_URL", + "HOST", + "PORT", + "SERVE_UI", + "BETTER_AUTH_URL", + "BETTER_AUTH_BASE_URL", +] as const; + +function requiredApiResult(value: T | null, action: string): T { + if (value === null) { + throw new Error(`Paperclip returned no result while ${action}.`); + } + return value; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message || error.name; + if (typeof error === "string") return error; + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +export function redactTestDriveText(text: string, credentials: Array): string { + let redacted = text; + for (const credential of credentials) { + if (!credential) continue; + redacted = redacted.replaceAll(credential, "[REDACTED]"); + } + return redacted; +} + +export function resolveTestDriveDataDir(dataDir?: string): string { + const explicit = dataDir?.trim(); + if (explicit) { + return path.resolve(expandHomePrefix(explicit)); + } + return fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-test-drive-")); +} + +async function loopbackPortAvailable(port: number): Promise { + return await new Promise((resolve) => { + const server = createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); +} + +export async function resolveTestDriveServerPort(preferredPort = 3100): Promise { + for (let port = preferredPort; port <= 65_535; port += 1) { + if (await loopbackPortAvailable(port)) return port; + } + throw new Error(`No available loopback port found at or above ${preferredPort}.`); +} + +/** + * Establish isolation before the CLI's normal config and .env loading hook. + * The selected credential source is preserved in case its name happens to use + * a PAPERCLIP_ prefix; all other Paperclip routing/configuration is discarded. + */ +export async function prepareTestDriveEnvironment( + options: Pick, + cwd = process.cwd(), +): Promise<{ dataDir: string; linkedWorktree: boolean }> { + const sourceEnvName = options.apiKeyEnv?.trim(); + const preservedCredential = sourceEnvName ? process.env[sourceEnvName] : undefined; + + for (const key of Object.keys(process.env)) { + if (key.startsWith("PAPERCLIP_")) { + delete process.env[key]; + } + } + for (const key of NON_PAPERCLIP_ISOLATED_ENV_KEYS) { + delete process.env[key]; + } + if (sourceEnvName && preservedCredential !== undefined) { + process.env[sourceEnvName] = preservedCredential; + } + + const dataDir = resolveTestDriveDataDir(options.dataDir); + const linkedWorktree = isLinkedGitWorktree(cwd); + process.env.PAPERCLIP_HOME = dataDir; + process.env.PAPERCLIP_INSTANCE_ID = "default"; + process.env.PAPERCLIP_CONFIG = resolveDefaultConfigPath("default"); + process.env.PAPERCLIP_CONTEXT = resolveDefaultContextPath(); + process.env.PAPERCLIP_IN_WORKTREE = linkedWorktree ? "true" : "false"; + process.env.PAPERCLIP_OPEN_ON_LISTEN = "false"; + process.env.PAPERCLIP_DISABLE_CWD_ENV_FILE = "true"; + process.env.PAPERCLIP_DEPLOYMENT_MODE = "local_trusted"; + process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private"; + process.env.PAPERCLIP_BIND = "loopback"; + process.env.HOST = "127.0.0.1"; + process.env.PORT = String(await resolveTestDriveServerPort()); + + return { dataDir, linkedWorktree }; +} + +export function assertTestDriveDatabaseIsolation( + configPath?: string, + env: NodeJS.ProcessEnv = process.env, + readConfigFile: (path?: string) => PaperclipConfig | null = readConfig, +): void { + if (env.DATABASE_URL?.trim() || env.DATABASE_MIGRATION_URL?.trim()) { + throw new Error( + "test-drive requires its isolated embedded database. Remove DATABASE_URL and " + + "DATABASE_MIGRATION_URL from the selected data directory's .env, or choose a fresh --data-dir.", + ); + } + + const config = readConfigFile(configPath); + if (config?.database.mode === "postgres") { + throw new Error( + "test-drive cannot reuse a data directory configured for an external PostgreSQL database. " + + "Choose a fresh data directory or change database.mode to embedded-postgres.", + ); + } +} + +export function resolveTestDriveBootstrap( + options: TestDriveOptions, + env: NodeJS.ProcessEnv = process.env, +): ResolvedTestDriveBootstrap { + const harness = options.harness ?? "claude"; + const definition = HARNESS_DEFINITIONS[harness]; + if (!definition) { + throw new Error(`Unsupported test-drive harness: ${String(harness)}.`); + } + + const companyName = (options.companyName ?? "Test Company").trim(); + const agentName = (options.agentName ?? "CEO").trim(); + if (!companyName) throw new Error("--company-name cannot be empty."); + if (!agentName) throw new Error("--agent-name cannot be empty."); + + const model = options.model; + if (model !== undefined && (!model || model.trim() !== model)) { + throw new Error("--model cannot be empty or have surrounding whitespace."); + } + if ( + harness === "opencode" && + (!model || !/^openrouter\/[^/\s]+(?:\/[^/\s]+)*$/.test(model)) + ) { + throw new Error( + "OpenCode test drives require --model openrouter/, with no empty path segments.", + ); + } + + const sourceEnvName = options.apiKeyEnv?.trim() || definition.credentialTarget; + if (options.apiKeyEnv !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sourceEnvName)) { + throw new Error("--api-key-env must name a valid environment variable."); + } + const credential = env[sourceEnvName]; + if (!credential || credential.trim().length === 0) { + throw new Error( + `No credential found. Set ${sourceEnvName} or pass --api-key-env .`, + ); + } + + return { + ...definition, + companyName, + agentName, + ...(model ? { model } : {}), + credential, + credentialSource: sourceEnvName, + }; +} + +function worktreeExecutionArmed( + settings: InstanceExperimentalSettings, + instanceId: string, +): boolean { + return settings.enableWorktreeRunExecution === true + && Boolean(settings.worktreeRunExecutionActivatedAt) + && settings.worktreeRunExecutionActivationInstanceId === instanceId; +} + +export async function reconcileTestDriveWorktreeExecution( + api: TestDriveApi, + instanceId: string, +): Promise { + const current = requiredApiResult( + await api.get("/api/instance/settings/experimental"), + "reading experimental settings", + ); + + if (!current.enableWorktreeRunExecution) { + await api.patch("/api/instance/settings/experimental", { + enableWorktreeRunExecution: true, + }); + } else if (!worktreeExecutionArmed(current, instanceId)) { + await api.patch("/api/instance/settings/experimental", { + enableWorktreeRunExecution: false, + }); + await api.patch("/api/instance/settings/experimental", { + enableWorktreeRunExecution: true, + }); + } + + const verified = requiredApiResult( + await api.get("/api/instance/settings/experimental"), + "verifying experimental settings", + ); + if (!worktreeExecutionArmed(verified, instanceId)) { + throw new Error( + `Could not arm “Run tasks in this worktree” for Paperclip instance ${instanceId}. ` + + "Check that PAPERCLIP_IN_WORKTREE=true and retry the command.", + ); + } +} + +export async function bootstrapTestDrive(input: { + api: TestDriveApi; + options: TestDriveOptions; + linkedWorktree: boolean; + instanceId: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const companies = requiredApiResult( + await input.api.get("/api/companies"), + "reading companies", + ); + const existingCompany = companies[0]; + if (existingCompany) { + if (input.linkedWorktree) { + await reconcileTestDriveWorktreeExecution(input.api, input.instanceId); + } + return { reused: true, company: existingCompany, agent: null }; + } + + // Resolve every bootstrap input before the first mutation. In particular, + // OpenCode model validation and credential lookup happen before company + // creation so an invalid invocation leaves the database untouched. + const resolved = resolveTestDriveBootstrap(input.options, input.env); + let company: Company | null = null; + try { + company = requiredApiResult( + await input.api.post("/api/companies", { name: resolved.companyName }), + "creating the test company", + ); + await input.api.post(`/api/companies/${company.id}/user-secret-definitions`, { + key: resolved.credentialTarget, + name: resolved.credentialName, + }); + await input.api.post(`/api/companies/${company.id}/me/user-secrets`, { + definitionKey: resolved.credentialTarget, + value: resolved.credential, + }); + + const adapterConfig: Record = { + env: { + [resolved.credentialTarget]: { + type: "user_secret_ref", + key: resolved.credentialTarget, + version: "latest", + required: true, + }, + }, + }; + if (resolved.model) adapterConfig.model = resolved.model; + + const agent = requiredApiResult( + await input.api.post(`/api/companies/${company.id}/agents`, { + name: resolved.agentName, + role: "ceo", + adapterType: resolved.adapterType, + adapterConfig, + }), + "creating the CEO agent", + ); + + if (input.linkedWorktree) { + await reconcileTestDriveWorktreeExecution(input.api, input.instanceId); + } + return { reused: false, company, agent }; + } catch (error) { + if (company) { + try { + await input.api.delete(`/api/companies/${company.id}`); + } catch (cleanupError) { + throw new Error( + `${errorMessage(error)} Cleanup also failed for newly-created company ${company.id}: ${errorMessage(cleanupError)}`, + { cause: error }, + ); + } + } + throw error; + } +} + +function dashboardUrl(server: StartedServer): string { + return server.apiUrl.replace(/\/api\/?$/, ""); +} + +export async function testDriveCommand( + options: TestDriveOptions, + dependencies: TestDriveDependencies = { + run: runCommand, + createApi: (apiBase) => new PaperclipApiClient({ apiBase }), + openBrowser: openUrl, + }, +): Promise { + const dataDir = path.resolve(process.env.PAPERCLIP_HOME ?? resolveTestDriveDataDir(options.dataDir)); + const linkedWorktree = process.env.PAPERCLIP_IN_WORKTREE === "true"; + const instanceId = process.env.PAPERCLIP_INSTANCE_ID ?? "default"; + const possibleCredentials = [ + options.apiKeyEnv ? process.env[options.apiKeyEnv] : undefined, + process.env[HARNESS_DEFINITIONS[options.harness ?? "claude"].credentialTarget], + ]; + + p.log.message(pc.dim(`Data directory: ${dataDir}`)); + p.log.message(pc.dim("The data directory is retained when Paperclip exits.")); + + try { + await dependencies.run({ + repair: true, + yes: true, + bind: "loopback", + installService: false, + // Auto-created directories are private to this process. Explicitly reused + // directories retain the normal guard against an already-managed instance. + skipServiceManagerCheck: !options.dataDir?.trim(), + introLabel: "paperclipai test-drive", + afterStart: async (server) => { + const api = dependencies.createApi(server.apiUrl); + const result = await bootstrapTestDrive({ + api, + options, + linkedWorktree, + instanceId, + }); + if (result.reused) { + p.log.message( + `Using existing data for ${pc.cyan(result.company.name)}; bootstrap flags were ignored.`, + ); + } else { + p.log.success( + `Created ${pc.cyan(result.company.name)} with agent ${pc.cyan(result.agent?.name ?? "CEO")}.`, + ); + } + if (linkedWorktree) { + p.log.success("Run tasks in this worktree is enabled for this instance."); + } + + const url = dashboardUrl(server); + if (options.browser === false) { + p.log.success(`Paperclip is ready at ${pc.cyan(url)}.`); + return; + } + const opened = await dependencies.openBrowser(url); + if (opened) { + p.log.success(`Paperclip is ready and opened at ${pc.cyan(url)}.`); + } else { + p.log.warn(`Paperclip is ready, but the browser could not be opened. Visit ${url}.`); + } + }, + }); + } catch (error) { + throw new Error(redactTestDriveText(errorMessage(error), possibleCredentials), { cause: error }); + } +} + +export function registerTestDriveCommand(program: Command): void { + program + .command("test-drive") + .description("Start an isolated, initialized Paperclip instance for manual testing") + .option("-d, --data-dir ", "Paperclip data directory to create or reuse") + .option("--company-name ", "Initial company name", "Test Company") + .option("--agent-name ", "Initial CEO agent name", "CEO") + .addOption( + new Option("--harness ", "Initial agent harness") + .choices(TEST_DRIVE_HARNESSES) + .default("claude"), + ) + .option("--model ", "Initial agent model") + .option("--api-key-env ", "Read the provider key from an environment variable") + .option("--no-browser", "Do not open the initialized instance in a browser") + .action(async (options: TestDriveOptions) => { + await testDriveCommand(options); + }); +} diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index e4e87cc14d..8acbe10b04 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -104,6 +104,7 @@ import { type PlannedIssueDocumentMerge, type PlannedIssueInsert, } from "./worktree-merge-history-lib.js"; +import { detectGitWorkspaceInfo } from "./git-workspace.js"; type WorktreeInitOptions = { name?: string; @@ -204,13 +205,6 @@ type EmbeddedPostgresHandle = { stop: () => Promise; }; -type GitWorkspaceInfo = { - root: string; - commonDir: string; - gitDir: string; - hooksPath: string; -}; - type CopiedGitHooksResult = { sourceHooksPath: string; targetHooksPath: string; @@ -717,39 +711,6 @@ function resolveRepairWorktreeDirName(branchName: string): string { return normalized || "worktree"; } -function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null { - try { - const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { - cwd, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], { - cwd: root, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - const gitDirRaw = execFileSync("git", ["rev-parse", "--git-dir"], { - cwd: root, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - const hooksPathRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks"], { - cwd: root, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - return { - root: path.resolve(root), - commonDir: path.resolve(root, commonDirRaw), - gitDir: path.resolve(root, gitDirRaw), - hooksPath: path.resolve(root, hooksPathRaw), - }; - } catch { - return null; - } -} - function copyDirectoryContents(sourceDir: string, targetDir: string): boolean { if (!existsSync(sourceDir)) return false; diff --git a/cli/src/index.ts b/cli/src/index.ts index b83727883f..156abd8246 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -50,6 +50,12 @@ import { uninstallCommand } from "./commands/uninstall.js"; import { updateCommand } from "./commands/update.js"; import { registerServiceCommands } from "./commands/service.js"; import { registerConnectionIntentCommands } from "./commands/client/connections.js"; +import { + assertTestDriveDatabaseIsolation, + prepareTestDriveEnvironment, + registerTestDriveCommand, + type TestDriveOptions, +} from "./commands/test-drive.js"; const program = new Command(); const DATA_DIR_OPTION_HELP = @@ -92,17 +98,30 @@ program .option("--no-backup", "Skip the pre-update database backup") .action(updateCommand); -program.hook("preAction", (_thisCommand, actionCommand) => { - const options = actionCommand.optsWithGlobals() as DataDirOptionLike; +program.hook("preAction", async (_thisCommand, actionCommand) => { + const options = actionCommand.optsWithGlobals() as DataDirOptionLike & TestDriveOptions; + let dataDirOptions: DataDirOptionLike = options; + if (actionCommand.name() === "test-drive") { + const prepared = await prepareTestDriveEnvironment({ + dataDir: options.dataDir, + apiKeyEnv: options.apiKeyEnv, + }); + dataDirOptions = { ...options, dataDir: prepared.dataDir }; + } const optionNames = new Set(actionCommand.options.map((option) => option.attributeName())); - applyDataDirOverride(options, { + applyDataDirOverride(dataDirOptions, { hasConfigOption: optionNames.has("config"), hasContextOption: optionNames.has("context"), }); loadPaperclipEnvFile(options.config); + if (actionCommand.name() === "test-drive") { + assertTestDriveDatabaseIsolation(options.config); + } initTelemetryFromConfigFile(options.config); }); +registerTestDriveCommand(program); + program .command("onboard") .description("Interactive first-run setup wizard") diff --git a/doc/CLI.md b/doc/CLI.md index 0cdd67107b..b137483feb 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -154,6 +154,85 @@ Choose local instance: npx paperclipai run --instance dev ``` +## Isolated Manual Test Drives + +`paperclipai test-drive` creates or reuses an isolated local data directory, +ensures one usable CEO agent exists in a fresh database, starts Paperclip in the +foreground, and opens the browser after initialization succeeds. It never +installs a background service and never creates a goal, project, issue, task, +or heartbeat. + +```sh +npx paperclipai test-drive \ + [-d, --data-dir ] \ + [--company-name ] \ + [--agent-name ] \ + [--harness ] \ + [--model ] \ + [--api-key-env ] \ + [--no-browser] +``` + +Defaults are `Test Company`, a `CEO` agent with the `ceo` role, and the Claude +harness. Without `--data-dir`, every invocation creates a unique OS temporary +directory and prints its absolute path. The directory is retained after exit +for inspection. An explicit data directory is reused and is never reset. The +reused directory must use Paperclip's embedded database; `DATABASE_URL`, +`DATABASE_MIGRATION_URL`, and configs with `database.mode: postgres` are +rejected so test-drive cannot mutate an external database. The server also +ignores the invocation directory's `.env` for test-drive launches, while still +loading the selected instance's own environment file. Reused directories also +retain the normal guard against colliding with a managed Paperclip service. The +server uses the first available loopback port at or above `3100`, so an +unrelated local Paperclip process can remain running. + +Harness configuration: + +| Harness | Agent adapter | Agent credential variable | Model | +| --- | --- | --- | --- | +| `claude` | `claude_local` | `ANTHROPIC_API_KEY` | Optional; omitted uses the adapter default | +| `codex` | `codex_local` | `OPENAI_API_KEY` | Optional; omitted uses the adapter default | +| `opencode` | `opencode_local` | `OPENROUTER_API_KEY` | Required and must begin with `openrouter/` | + +OpenCode model references retain their complete path, including additional +slashes: + +```sh +OPENROUTER_API_KEY=... npx paperclipai test-drive \ + --harness opencode \ + --model openrouter/anthropic/claude-sonnet-4.5 +``` + +Credentials come from the variable named by `--api-key-env`, or from the +harness's canonical environment variable shown in the table. A custom source +variable is still stored and projected under the canonical target variable: + +```sh +MY_ROUTER_KEY=... npx paperclipai test-drive \ + --harness opencode \ + --model openrouter/openai/gpt-5.4 \ + --api-key-env MY_ROUTER_KEY +``` + +Credentials are stored through Paperclip's user-secret reference path and are +redacted from command output. The command does not accept provider credentials +in command-line arguments because process listings and shell history can expose +them. Provider connectivity, local harness installation, credential validity, +and model availability are intentionally checked only when the agent first +runs. + +When invoked inside a linked Git worktree, the command ignores inherited +`PAPERCLIP_IN_WORKTREE` state, launches in worktree mode, and verifies **Run +tasks in this worktree** is armed for the current instance before opening the +browser. In a primary checkout or non-Git directory it launches without +worktree mode and does not alter the setting. On reuse, if any company already +exists, all bootstrap flags are ignored and companies, agents, and secrets are +left untouched; worktree-setting reconciliation is the only permitted +mutation. + +Use `--no-browser` for a foreground instance that prints its ready URL without +opening it. + ## Install, Update, And Uninstall Managed installs keep CLI payloads under `~/.paperclip/cli`, expose a stable diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index c652718cef..6bf2b7f5e4 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -305,6 +305,46 @@ pnpm paperclipai run 2. `paperclipai doctor` with repair enabled 3. starts the server when checks pass +### One-command isolated manual test drive + +Use `test-drive` when you want to exercise the UI from a fresh checkout or SHA +without completing onboarding by hand: + +```sh +ANTHROPIC_API_KEY=... node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts test-drive +OPENAI_API_KEY=... node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts test-drive --harness codex --model gpt-5.4 +OPENROUTER_API_KEY=... node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts test-drive \ + --harness opencode \ + --model openrouter/anthropic/claude-sonnet-4.5 +``` + +The command creates a trusted-loopback instance in a unique OS temporary data +directory, prints the absolute directory, runs onboarding and doctor +non-interactively, creates `Test Company` with a `CEO` agent, then opens the +browser. It runs in the foreground, does not install a background service, and +does not create a goal, project, issue, task, or first heartbeat. Temporary +directories are retained for inspection. Use `--data-dir ` to reuse one +or `--no-browser` to suppress browser opening. Reused directories must use the +embedded database: the command rejects database URL environment overrides and +configs with `database.mode: postgres`, suppresses the invocation directory's +`.env` when the server starts, and keeps the normal managed-service collision +guard. The selected instance's own environment file still loads. The command +selects the first available loopback port at or above `3100`. + +Claude uses `ANTHROPIC_API_KEY`; Codex uses `OPENAI_API_KEY`; OpenCode uses +`OPENROUTER_API_KEY` and requires an `openrouter/...` model. `--api-key-env` +can name a different source variable while the agent still receives the +canonical variable. Provider keys must come from environment variables because +command-line arguments can appear in process listings and shell history. In a +linked Git worktree the command sets worktree runtime mode and safely arms +**Run tasks in this worktree** for the current isolated instance. Primary +checkouts and non-Git directories leave that experimental setting unchanged. + +If the selected data directory already contains any company, `test-drive` +preserves all companies, agents, and secrets and ignores the bootstrap flags. +The worktree execution setting is the only value it may reconcile in that +case. + ## Docker Quickstart (No local Node install) Build and run Paperclip in Docker: diff --git a/packages/adapters/opencode-local/src/server/execute.test.ts b/packages/adapters/opencode-local/src/server/execute.test.ts index 74ec4d560a..5158d98bac 100644 --- a/packages/adapters/opencode-local/src/server/execute.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.test.ts @@ -103,6 +103,75 @@ describe("OpenCode local skill injection", () => { await fs.rm(root, { recursive: true, force: true }); } }); + + it("passes an OpenRouter key and complete model to OpenCode without logging the key", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-openrouter-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "opencode"); + const apiKey = "openrouter-test-secret"; + const model = "openrouter/anthropic/claude-sonnet-4.5"; + await fs.mkdir(workspace, { recursive: true }); + await fs.writeFile(commandPath, "#!/bin/sh\nexit 0\n", "utf8"); + await fs.chmod(commandPath, 0o755); + runProcessMock.mockReset(); + runProcessMock.mockResolvedValueOnce(probeResult({ + stdout: JSON.stringify({ + type: "text", + sessionID: "session-openrouter", + part: { text: "done" }, + }), + })); + const logs: string[] = []; + const metadata: unknown[] = []; + + try { + const result = await execute({ + runId: "run-openrouter", + agent: { + id: "agent-openrouter", + companyId: "company-1", + name: "OpenRouter Coder", + adapterType: "opencode_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + model, + env: { + OPENROUTER_API_KEY: apiKey, + OPENCODE_ALLOW_ALL_MODELS: "1", + }, + promptTemplate: "Run the task.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async (_stream, chunk) => { + logs.push(chunk); + }, + onMeta: async (value) => { + metadata.push(value); + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.model).toBe(model); + const executionCall = runProcessMock.mock.calls.at(-1)!; + expect(executionCall[3]).toContain("--model"); + expect(executionCall[3]).toContain(model); + expect((executionCall[4] as { env: Record }).env.OPENROUTER_API_KEY).toBe(apiKey); + expect(JSON.stringify({ logs, metadata, result })).not.toContain(apiKey); + expect(JSON.stringify(metadata)).toContain('"OPENROUTER_API_KEY":"***REDACTED***"'); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); }); describe("ensureRemoteOpenCodeModelConfiguredAndAvailable", () => { diff --git a/server/src/__tests__/env-file-policy.test.ts b/server/src/__tests__/env-file-policy.test.ts new file mode 100644 index 0000000000..308867de49 --- /dev/null +++ b/server/src/__tests__/env-file-policy.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { shouldLoadWorkingDirectoryEnv } from "../env-file-policy.js"; + +describe("working-directory environment loading", () => { + it("loads a distinct working-directory .env by default", () => { + expect(shouldLoadWorkingDirectoryEnv({ + cwdEnvExists: true, + isPaperclipEnvFile: false, + env: {}, + })).toBe(true); + }); + + it("does not load the working-directory .env when explicitly disabled", () => { + expect(shouldLoadWorkingDirectoryEnv({ + cwdEnvExists: true, + isPaperclipEnvFile: false, + env: { PAPERCLIP_DISABLE_CWD_ENV_FILE: "true" }, + })).toBe(false); + }); + + it("does not load the same file twice", () => { + expect(shouldLoadWorkingDirectoryEnv({ + cwdEnvExists: true, + isPaperclipEnvFile: true, + env: {}, + })).toBe(false); + }); +}); diff --git a/server/src/config.ts b/server/src/config.ts index 996b6e3cfb..0c32138171 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -5,6 +5,7 @@ import { resolve } from "node:path"; import { config as loadDotenv } from "dotenv"; import { resolvePaperclipEnvPath } from "./paths.js"; import { maybeRepairLegacyWorktreeConfigAndEnvFiles } from "./worktree-config.js"; +import { shouldLoadWorkingDirectoryEnv } from "./env-file-policy.js"; import { AUTH_BASE_URL_MODES, BIND_MODES, @@ -36,10 +37,14 @@ if (existsSync(PAPERCLIP_ENV_FILE_PATH)) { } const CWD_ENV_PATH = resolve(process.cwd(), ".env"); -const isSameFile = existsSync(CWD_ENV_PATH) && existsSync(PAPERCLIP_ENV_FILE_PATH) +const cwdEnvExists = existsSync(CWD_ENV_PATH); +const isSameFile = cwdEnvExists && existsSync(PAPERCLIP_ENV_FILE_PATH) ? realpathSync(CWD_ENV_PATH) === realpathSync(PAPERCLIP_ENV_FILE_PATH) : CWD_ENV_PATH === PAPERCLIP_ENV_FILE_PATH; -if (!isSameFile && existsSync(CWD_ENV_PATH)) { +if (shouldLoadWorkingDirectoryEnv({ + cwdEnvExists, + isPaperclipEnvFile: isSameFile, +})) { loadDotenv({ path: CWD_ENV_PATH, override: false, quiet: true }); } diff --git a/server/src/env-file-policy.ts b/server/src/env-file-policy.ts new file mode 100644 index 0000000000..e71351b817 --- /dev/null +++ b/server/src/env-file-policy.ts @@ -0,0 +1,10 @@ +export function shouldLoadWorkingDirectoryEnv(input: { + cwdEnvExists: boolean; + isPaperclipEnvFile: boolean; + env?: NodeJS.ProcessEnv; +}): boolean { + const env = input.env ?? process.env; + return env.PAPERCLIP_DISABLE_CWD_ENV_FILE !== "true" + && input.cwdEnvExists + && !input.isPaperclipEnvFile; +} diff --git a/server/src/index.ts b/server/src/index.ts index 29daa8eaa4..a4087526b3 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -155,6 +155,7 @@ export interface StartedServer { listenPort: number; apiUrl: string; databaseUrl: string; + shutdown: (signal?: "SIGINT" | "SIGTERM") => Promise; } export async function startServer(): Promise { @@ -1793,7 +1794,6 @@ export async function startServer(): Promise { databaseBackupRetentionDays: config.databaseBackupRetentionDays, databaseBackupDir: config.databaseBackupDir, }); - const boardClaimUrl = getBoardClaimWarningUrl(config.host, listenPort); if (boardClaimUrl) { const red = "\x1b[41m\x1b[30m"; @@ -1810,96 +1810,106 @@ export async function startServer(): Promise { ); } - { - const shutdown = async (signal: "SIGINT" | "SIGTERM") => { - await systemdNotify(["--stopping", `--status=Stopping after ${signal}`]); - heartbeatSchedulerStopped = true; - if (heartbeatSchedulerInterval) { - clearInterval(heartbeatSchedulerInterval); - heartbeatSchedulerInterval = null; - } + const shutdown = async ( + signal: "SIGINT" | "SIGTERM", + exitProcess: boolean, + ) => { + await systemdNotify(["--stopping", `--status=Stopping after ${signal}`]); + heartbeatSchedulerStopped = true; + if (heartbeatSchedulerInterval) { + clearInterval(heartbeatSchedulerInterval); + heartbeatSchedulerInterval = null; + } - const heartbeatShutdown = await coordinateHeartbeatSchedulerShutdown({ - signal, - prepareHotRestartShutdown, - waitForHeartbeatSchedulerIdle, - }); - const skipHeartbeatDrain = heartbeatShutdown.hotRestart?.skipDrain === true; - const selectiveDrainRunIds = heartbeatShutdown.hotRestart?.drainRunIds ?? null; - if (skipHeartbeatDrain) { - logger.info( - { signal, hotRestart: heartbeatShutdown.hotRestart }, - "hot-restart shutdown prepared after scheduler quiescence; skipping graceful run drain", - ); - } else if (heartbeatShutdown.preparationError) { - logger.error( - { err: heartbeatShutdown.preparationError, signal }, - "hot-restart shutdown preparation failed; falling back to graceful heartbeat run drain", - ); - } + const heartbeatShutdown = await coordinateHeartbeatSchedulerShutdown({ + signal, + prepareHotRestartShutdown, + waitForHeartbeatSchedulerIdle, + }); + const skipHeartbeatDrain = heartbeatShutdown.hotRestart?.skipDrain === true; + const selectiveDrainRunIds = heartbeatShutdown.hotRestart?.drainRunIds ?? null; + if (skipHeartbeatDrain) { + logger.info( + { signal, hotRestart: heartbeatShutdown.hotRestart }, + "hot-restart shutdown prepared after scheduler quiescence; skipping graceful run drain", + ); + } else if (heartbeatShutdown.preparationError) { + logger.error( + { err: heartbeatShutdown.preparationError, signal }, + "hot-restart shutdown preparation failed; falling back to graceful heartbeat run drain", + ); + } - const telemetryClient = getTelemetryClient(); - if (telemetryClient) { - telemetryClient.stop(); - await telemetryClient.flush(); - } + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + telemetryClient.stop(); + await telemetryClient.flush(); + } - if (!skipHeartbeatDrain && drainHeartbeatRunsForShutdown) { - try { - const drain = await drainHeartbeatRunsForShutdown(signal, selectiveDrainRunIds); - logger.info({ signal, drain }, "graceful heartbeat run drain complete"); - } catch (err) { - logger.error({ err, signal }, "graceful heartbeat run drain failed"); - } - } - - if (!skipHeartbeatDrain) { - await drainRunExecutionFinalizersForShutdown({ - signal, - drain: drainHeartbeatExecutionFinalizers, - log: logger, - }); - } - - // Whatever the drain did not finalize (timed-out runs, the hot-restart - // skip path) still has a local-only tail when the in-flight run-log - // mirror is enabled; upload those tails now so an orderly restart - // never loses run output. No-op when the mirror is off. + if (!skipHeartbeatDrain && drainHeartbeatRunsForShutdown) { try { - await flushInFlightRunLogMirrors(); + const drain = await drainHeartbeatRunsForShutdown(signal, selectiveDrainRunIds); + logger.info({ signal, drain }, "graceful heartbeat run drain complete"); } catch (err) { - logger.error({ err, signal }, "run-log in-flight mirror flush failed"); + logger.error({ err, signal }, "graceful heartbeat run drain failed"); } + } - const appShutdown = (app as { locals?: { paperclipShutdown?: () => Promise } }).locals - ?.paperclipShutdown; - const stopEmbeddedPostgres = embeddedPostgres && embeddedPostgresStartedByThisProcess - ? () => embeddedPostgresSupervisor?.shutdown() ?? embeddedPostgres!.stop() - : null; - - // Await the ordered application teardown before the process exits. A live - // setup-token login session must stop and release its sandbox lease before - // the database and the provider stop, so an orderly shutdown never leaves a - // sandbox lease or confidential login state alive past the process exit. - await finalizeServerShutdown({ + if (!skipHeartbeatDrain) { + await drainRunExecutionFinalizersForShutdown({ signal, - shutdownAppServices: appShutdown, - stopEmbeddedPostgres, - shutdownInstrumentation, - shutdownSentry, + drain: drainHeartbeatExecutionFinalizers, log: logger, }); + } - process.exit(0); - }; + // Whatever the drain did not finalize (timed-out runs, the hot-restart + // skip path) still has a local-only tail when the in-flight run-log + // mirror is enabled; upload those tails now so an orderly restart + // never loses run output. No-op when the mirror is off. + try { + await flushInFlightRunLogMirrors(); + } catch (err) { + logger.error({ err, signal }, "run-log in-flight mirror flush failed"); + } - process.once("SIGINT", () => { - void shutdown("SIGINT"); + const appShutdown = (app as { locals?: { paperclipShutdown?: () => Promise } }).locals + ?.paperclipShutdown; + const stopEmbeddedPostgres = embeddedPostgres && embeddedPostgresStartedByThisProcess + ? () => embeddedPostgresSupervisor?.shutdown() ?? embeddedPostgres!.stop() + : null; + + // Await the ordered application teardown before the process exits. A live + // setup-token login session must stop and release its sandbox lease before + // the database and the provider stop, so an orderly shutdown never leaves a + // sandbox lease or confidential login state alive past the process exit. + await finalizeServerShutdown({ + signal, + shutdownAppServices: appShutdown, + stopEmbeddedPostgres, + shutdownInstrumentation, + shutdownSentry, + log: logger, }); - process.once("SIGTERM", () => { - void shutdown("SIGTERM"); - }); - } + + if (!exitProcess && server.listening) { + await new Promise((resolveClose, rejectClose) => { + server.close((err) => { + if (err) rejectClose(err); + else resolveClose(); + }); + }); + } + + if (exitProcess) process.exit(0); + }; + + process.once("SIGINT", () => { + void shutdown("SIGINT", true); + }); + process.once("SIGTERM", () => { + void shutdown("SIGTERM", true); + }); return { server, @@ -1907,6 +1917,7 @@ export async function startServer(): Promise { listenPort, apiUrl: configuredApiUrl, databaseUrl: activeDatabaseConnectionString, + shutdown: (signal = "SIGTERM") => shutdown(signal, false), }; } catch (error) { if (startupListenerBound) {