diff --git a/Dockerfile b/Dockerfile index 35f11219..6fa685d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,7 @@ COPY packages/shared/package.json packages/shared/ COPY packages/db/package.json packages/db/ COPY packages/adapter-utils/package.json packages/adapter-utils/ COPY packages/mcp-server/package.json packages/mcp-server/ +COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ diff --git a/cli/package.json b/cli/package.json index a2d0b3bf..73ab7c24 100644 --- a/cli/package.json +++ b/cli/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "@clack/prompts": "^0.10.0", + "@paperclipai/adapter-acpx-local": "workspace:*", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index e4443f55..59799cf4 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -1,4 +1,5 @@ import type { CLIAdapterModule } from "@paperclipai/adapter-utils"; +import { printAcpxStreamEvent } from "@paperclipai/adapter-acpx-local/cli"; import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli"; import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; @@ -14,6 +15,11 @@ const claudeLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printClaudeStreamEvent, }; +const acpxLocalCLIAdapter: CLIAdapterModule = { + type: "acpx_local", + formatStdoutEvent: printAcpxStreamEvent, +}; + const codexLocalCLIAdapter: CLIAdapterModule = { type: "codex_local", formatStdoutEvent: printCodexStreamEvent, @@ -46,6 +52,7 @@ const openclawGatewayCLIAdapter: CLIAdapterModule = { const adaptersByType = new Map( [ + acpxLocalCLIAdapter, claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, diff --git a/docs/pr-screenshots/pap-2944/skills-claude-dark.png b/docs/pr-screenshots/pap-2944/skills-claude-dark.png new file mode 100644 index 00000000..35720124 Binary files /dev/null and b/docs/pr-screenshots/pap-2944/skills-claude-dark.png differ diff --git a/docs/pr-screenshots/pap-2944/skills-claude-light.png b/docs/pr-screenshots/pap-2944/skills-claude-light.png new file mode 100644 index 00000000..5c97755c Binary files /dev/null and b/docs/pr-screenshots/pap-2944/skills-claude-light.png differ diff --git a/docs/pr-screenshots/pap-2944/skills-codex-dark.png b/docs/pr-screenshots/pap-2944/skills-codex-dark.png new file mode 100644 index 00000000..dd396b30 Binary files /dev/null and b/docs/pr-screenshots/pap-2944/skills-codex-dark.png differ diff --git a/docs/pr-screenshots/pap-2944/skills-codex-light.png b/docs/pr-screenshots/pap-2944/skills-codex-light.png new file mode 100644 index 00000000..4642da7b Binary files /dev/null and b/docs/pr-screenshots/pap-2944/skills-codex-light.png differ diff --git a/docs/pr-screenshots/pap-2944/skills-custom-dark.png b/docs/pr-screenshots/pap-2944/skills-custom-dark.png new file mode 100644 index 00000000..a4f5cb1e Binary files /dev/null and b/docs/pr-screenshots/pap-2944/skills-custom-dark.png differ diff --git a/docs/pr-screenshots/pap-2944/skills-custom-light.png b/docs/pr-screenshots/pap-2944/skills-custom-light.png new file mode 100644 index 00000000..ab9873a1 Binary files /dev/null and b/docs/pr-screenshots/pap-2944/skills-custom-light.png differ diff --git a/docs/pr-screenshots/pap-2944/skills-empty-library-dark.png b/docs/pr-screenshots/pap-2944/skills-empty-library-dark.png new file mode 100644 index 00000000..bee4fd1e Binary files /dev/null and b/docs/pr-screenshots/pap-2944/skills-empty-library-dark.png differ diff --git a/docs/pr-screenshots/pap-2944/skills-empty-library-light.png b/docs/pr-screenshots/pap-2944/skills-empty-library-light.png new file mode 100644 index 00000000..80fa0636 Binary files /dev/null and b/docs/pr-screenshots/pap-2944/skills-empty-library-light.png differ diff --git a/docs/pr-screenshots/pap-2944/skills-loading-dark.png b/docs/pr-screenshots/pap-2944/skills-loading-dark.png new file mode 100644 index 00000000..93c58be0 Binary files /dev/null and b/docs/pr-screenshots/pap-2944/skills-loading-dark.png differ diff --git a/docs/pr-screenshots/pap-2944/skills-loading-light.png b/docs/pr-screenshots/pap-2944/skills-loading-light.png new file mode 100644 index 00000000..61c0ceea Binary files /dev/null and b/docs/pr-screenshots/pap-2944/skills-loading-light.png differ diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts new file mode 100644 index 00000000..9a5f3716 --- /dev/null +++ b/packages/adapter-utils/src/command-redaction.ts @@ -0,0 +1,21 @@ +export const REDACTED_COMMAND_TEXT_VALUE = "***REDACTED***"; + +const COMMAND_CLI_SECRET_OPTION_RE = + /(\B-{1,2}(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:\s+|=)(["']?))[^\s"'`]+(\2)/gi; +const COMMAND_ENV_SECRET_ASSIGNMENT_RE = + /(\b[A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|AUTHORIZATION|JWT)[A-Za-z0-9_]*\s*=\s*)[^\s"'`]+/gi; +const COMMAND_AUTHORIZATION_BEARER_RE = /(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi; +const COMMAND_OPENAI_KEY_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g; +const COMMAND_GITHUB_TOKEN_RE = /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g; +const COMMAND_JWT_RE = + /\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]{8,})?\b/g; + +export function redactCommandText(command: string, redactedValue = REDACTED_COMMAND_TEXT_VALUE): string { + return command + .replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`) + .replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`) + .replace(COMMAND_ENV_SECRET_ASSIGNMENT_RE, `$1${redactedValue}`) + .replace(COMMAND_OPENAI_KEY_RE, redactedValue) + .replace(COMMAND_GITHUB_TOKEN_RE, redactedValue) + .replace(COMMAND_JWT_RE, redactedValue); +} diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index c563ab21..0c144b7a 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -55,6 +55,10 @@ export { redactHomePathUserSegmentsInValue, redactTranscriptEntryPaths, } from "./log-redaction.js"; +export { + REDACTED_COMMAND_TEXT_VALUE, + redactCommandText, +} from "./command-redaction.js"; export { inferOpenAiCompatibleBiller } from "./billing.js"; // Keep the root adapter-utils entry browser-safe because the UI imports it. // The sandbox callback bridge stays available via its dedicated subpath export. diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index f5e8f716..16ad5303 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -1,9 +1,14 @@ import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { applyPaperclipWorkspaceEnv, appendWithByteCap, + buildInvocationEnvForLogs, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + materializePaperclipSkillCopy, renderPaperclipWakePrompt, runningProcesses, runChildProcess, @@ -39,6 +44,82 @@ async function waitForTextMatch(read: () => string, pattern: RegExp, timeoutMs = return read().match(pattern); } +describe("buildInvocationEnvForLogs", () => { + it("redacts inline secrets from resolved command metadata", () => { + const loggedEnv = buildInvocationEnvForLogs( + { SAFE_VALUE: "visible" }, + { + resolvedCommand: "env OPENAI_API_KEY=sk-live-example custom-acp --token ghp_example_secret", + }, + ); + + expect(loggedEnv.SAFE_VALUE).toBe("visible"); + expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe( + "env OPENAI_API_KEY=***REDACTED*** custom-acp --token ***REDACTED***", + ); + }); +}); + +describe("materializePaperclipSkillCopy", () => { + it("refuses to materialize into an ancestor of the source", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-copy-")); + try { + const source = path.join(root, "parent", "skill"); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, "SKILL.md"), "# skill\n", "utf8"); + + await expect(materializePaperclipSkillCopy(source, path.join(root, "parent"))).rejects.toThrow( + /ancestor/, + ); + await expect(fs.readFile(path.join(source, "SKILL.md"), "utf8")).resolves.toBe("# skill\n"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("does not delete and recopy an unchanged materialized skill target", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-copy-")); + try { + const source = path.join(root, "source"); + const target = path.join(root, "target"); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, "SKILL.md"), "# skill\n", "utf8"); + + const first = await materializePaperclipSkillCopy(source, target); + expect(first.copiedFiles).toBe(1); + await fs.writeFile(path.join(target, "local-marker.txt"), "keep\n", "utf8"); + + const second = await materializePaperclipSkillCopy(source, target); + expect(second.copiedFiles).toBe(0); + await expect(fs.readFile(path.join(target, "local-marker.txt"), "utf8")).resolves.toBe("keep\n"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("breaks stale materialization locks left by dead processes", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-copy-")); + try { + const source = path.join(root, "source"); + const target = path.join(root, "target"); + const lock = `${target}.lock`; + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, "SKILL.md"), "# skill\n", "utf8"); + await fs.mkdir(lock, { recursive: true }); + await fs.writeFile( + path.join(lock, "owner.json"), + JSON.stringify({ pid: 999_999_999, createdAt: "2000-01-01T00:00:00.000Z" }), + "utf8", + ); + + await expect(materializePaperclipSkillCopy(source, target)).resolves.toMatchObject({ copiedFiles: 1 }); + await expect(fs.readFile(path.join(target, "SKILL.md"), "utf8")).resolves.toBe("# skill\n"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); + describe("runChildProcess", () => { it("does not arm a timeout when timeoutSec is 0", async () => { const result = await runChildProcess( diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 425a1f3f..bb4eb40d 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1,7 +1,9 @@ import { spawn, type ChildProcess } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; import { constants as fsConstants, promises as fs, type Dirent } from "node:fs"; import path from "node:path"; import { buildSshSpawnTarget, type SshRemoteExecutionSpec } from "./ssh.js"; +import { redactCommandText } from "./command-redaction.js"; import type { AdapterSkillEntry, AdapterSkillSnapshot, @@ -76,10 +78,14 @@ export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024; export const MAX_EXCERPT_BYTES = 32 * 1024; const TERMINAL_RESULT_SCAN_OVERLAP_CHARS = 64 * 1024; const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i; +const REDACTED_LOG_VALUE = "***REDACTED***"; const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [ "../../skills", "../../../../../skills", ]; +const MATERIALIZED_SKILL_SENTINEL = ".paperclip-materialized-skill.json"; +const MATERIALIZED_SKILL_LOCK_OWNER = "owner.json"; +const MATERIALIZED_SKILL_LOCK_STALE_MS = 30_000; export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [ "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", @@ -111,6 +117,11 @@ export interface InstalledSkillTarget { kind: "symlink" | "directory" | "file"; } +export interface MaterializedPaperclipSkillCopyResult { + copiedFiles: number; + skippedSymlinks: string[]; +} + interface PersistentSkillSnapshotOptions { adapterType: string; availableEntries: PaperclipSkillEntry[]; @@ -780,11 +791,15 @@ export function renderPaperclipWakePrompt( export function redactEnvForLogs(env: Record): Record { const redacted: Record = {}; for (const [key, value] of Object.entries(env)) { - redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "***REDACTED***" : value; + redacted[key] = SENSITIVE_ENV_KEY.test(key) ? REDACTED_LOG_VALUE : value; } return redacted; } +export function redactCommandTextForLogs(command: string): string { + return redactCommandText(command, REDACTED_LOG_VALUE); +} + export function buildInvocationEnvForLogs( env: Record, options: { @@ -806,7 +821,7 @@ export function buildInvocationEnvForLogs( const resolvedCommand = options.resolvedCommand?.trim(); if (resolvedCommand) { - merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = resolvedCommand; + merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = redactCommandTextForLogs(resolvedCommand); } return redactEnvForLogs(merged); @@ -1395,6 +1410,190 @@ export async function ensurePaperclipSkillSymlink( return "repaired"; } +async function hashSkillDirectory(root: string): Promise { + const hash = createHash("sha256"); + + async function visit(candidate: string, relativePath: string): Promise { + const stat = await fs.lstat(candidate); + if (stat.isSymbolicLink()) { + hash.update(`symlink:${relativePath}\n`); + return; + } + if (stat.isDirectory()) { + hash.update(`dir:${relativePath}\n`); + const entries = await fs.readdir(candidate, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const childRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name; + await visit(path.join(candidate, entry.name), childRelativePath); + } + return; + } + if (stat.isFile()) { + hash.update(`file:${relativePath}:${stat.mode}\n`); + hash.update(await fs.readFile(candidate)); + hash.update("\n"); + return; + } + hash.update(`other:${relativePath}:${stat.mode}\n`); + } + + await visit(root, ""); + return hash.digest("hex"); +} + +async function materializedSkillFingerprintMatches(targetRoot: string, sourceFingerprint: string): Promise { + try { + const raw = JSON.parse(await fs.readFile(path.join(targetRoot, MATERIALIZED_SKILL_SENTINEL), "utf8")) as unknown; + const parsed = parseObject(raw); + return parsed.version === 1 && parsed.sourceFingerprint === sourceFingerprint; + } catch { + return false; + } +} + +async function acquireMaterializeLock(lockDir: string): Promise<() => Promise> { + await fs.mkdir(path.dirname(lockDir), { recursive: true }); + const deadline = Date.now() + MATERIALIZED_SKILL_LOCK_STALE_MS; + while (true) { + try { + await fs.mkdir(lockDir); + await fs.writeFile( + path.join(lockDir, MATERIALIZED_SKILL_LOCK_OWNER), + `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, + "utf8", + ); + return async () => { + await fs.rm(lockDir, { recursive: true, force: true }); + }; + } catch (err) { + const code = err && typeof err === "object" ? (err as { code?: unknown }).code : null; + if (code !== "EEXIST") throw err; + if (await removeStaleMaterializeLock(lockDir, MATERIALIZED_SKILL_LOCK_STALE_MS)) continue; + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for Paperclip skill materialization lock at ${lockDir}`); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } +} + +function isPidAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (err) { + const code = err && typeof err === "object" ? (err as { code?: unknown }).code : null; + return code === "EPERM"; + } +} + +async function removeStaleMaterializeLock(lockDir: string, staleMs: number): Promise { + const ownerPath = path.join(lockDir, MATERIALIZED_SKILL_LOCK_OWNER); + let shouldRemove = false; + try { + const raw = JSON.parse(await fs.readFile(ownerPath, "utf8")) as unknown; + const owner = parseObject(raw); + const pid = typeof owner.pid === "number" ? owner.pid : 0; + const createdAt = typeof owner.createdAt === "string" ? Date.parse(owner.createdAt) : Number.NaN; + const ageMs = Number.isFinite(createdAt) ? Date.now() - createdAt : staleMs + 1; + shouldRemove = !isPidAlive(pid) || ageMs > staleMs; + } catch { + const stat = await fs.stat(lockDir).catch(() => null); + shouldRemove = !stat || Date.now() - stat.mtimeMs > staleMs; + } + if (!shouldRemove) return false; + await fs.rm(lockDir, { recursive: true, force: true }).catch(() => {}); + return true; +} + +export async function materializePaperclipSkillCopy( + source: string, + target: string, +): Promise { + const sourceRoot = path.resolve(source); + const targetRoot = path.resolve(target); + const relativeTarget = path.relative(sourceRoot, targetRoot); + const relativeSource = path.relative(targetRoot, sourceRoot); + if ( + !relativeTarget || + (!relativeTarget.startsWith("..") && !path.isAbsolute(relativeTarget)) || + !relativeSource || + (!relativeSource.startsWith("..") && !path.isAbsolute(relativeSource)) + ) { + throw new Error("Refusing to materialize a skill into itself, an ancestor, or one of its descendants."); + } + + const rootStat = await fs.lstat(sourceRoot); + if (rootStat.isSymbolicLink()) { + throw new Error("Refusing to materialize a skill root that is itself a symlink."); + } + if (!rootStat.isDirectory()) { + throw new Error("Paperclip skills must be directories."); + } + + const result: MaterializedPaperclipSkillCopyResult = { + copiedFiles: 0, + skippedSymlinks: [], + }; + + const lockDir = `${targetRoot}.lock`; + const releaseLock = await acquireMaterializeLock(lockDir); + const tempRoot = `${targetRoot}.tmp-${process.pid}-${randomUUID()}`; + + async function copyEntry(sourcePath: string, targetPath: string, relativePath: string): Promise { + const stat = await fs.lstat(sourcePath); + if (stat.isSymbolicLink()) { + result.skippedSymlinks.push(relativePath || "."); + return; + } + + if (stat.isDirectory()) { + await fs.mkdir(targetPath, { recursive: true }); + const entries = await fs.readdir(sourcePath, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const childRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name; + await copyEntry(path.join(sourcePath, entry.name), path.join(targetPath, entry.name), childRelativePath); + } + return; + } + + if (stat.isFile()) { + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_FICLONE).catch(async () => { + await fs.copyFile(sourcePath, targetPath); + }); + await fs.chmod(targetPath, stat.mode).catch(() => {}); + result.copiedFiles += 1; + } + } + + try { + const sourceFingerprint = await hashSkillDirectory(sourceRoot); + if (await materializedSkillFingerprintMatches(targetRoot, sourceFingerprint)) return result; + await copyEntry(sourceRoot, tempRoot, ""); + await fs.writeFile( + path.join(tempRoot, MATERIALIZED_SKILL_SENTINEL), + `${JSON.stringify({ + version: 1, + sourceFingerprint, + copiedFiles: result.copiedFiles, + skippedSymlinks: result.skippedSymlinks, + }, null, 2)}\n`, + "utf8", + ); + if (await materializedSkillFingerprintMatches(targetRoot, sourceFingerprint)) return result; + await fs.rm(targetRoot, { recursive: true, force: true }); + await fs.rename(tempRoot, targetRoot); + return result; + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }).catch(() => {}); + await releaseLock(); + } +} + export async function removeMaintainerOnlySkillSymlinks( skillsHome: string, allowedSkillNames: Iterable, diff --git a/packages/adapter-utils/src/session-compaction.ts b/packages/adapter-utils/src/session-compaction.ts index 90fe544b..c42cbf8f 100644 --- a/packages/adapter-utils/src/session-compaction.ts +++ b/packages/adapter-utils/src/session-compaction.ts @@ -37,6 +37,7 @@ const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = { }; export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ + "acpx_local", "claude_local", "codex_local", "cursor", @@ -47,6 +48,11 @@ export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ ]); export const ADAPTER_SESSION_MANAGEMENT: Record = { + acpx_local: { + supportsSessionResume: true, + nativeContextManagement: "confirmed", + defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY, + }, claude_local: { supportsSessionResume: true, nativeContextManagement: "confirmed", diff --git a/packages/adapters/acpx-local/package.json b/packages/adapters/acpx-local/package.json new file mode 100644 index 00000000..b061a113 --- /dev/null +++ b/packages/adapters/acpx-local/package.json @@ -0,0 +1,64 @@ +{ + "name": "@paperclipai/adapter-acpx-local", + "version": "0.3.1", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "packages/adapters/acpx-local" + }, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist", + "skills" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.31.4", + "@paperclipai/adapter-utils": "workspace:*", + "@zed-industries/codex-acp": "^0.12.0", + "acpx": "^0.6.1", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/adapters/acpx-local/src/cli/format-event.test.ts b/packages/adapters/acpx-local/src/cli/format-event.test.ts new file mode 100644 index 00000000..34e2b6b3 --- /dev/null +++ b/packages/adapters/acpx-local/src/cli/format-event.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { printAcpxStreamEvent } from "./format-event.js"; + +function emit(payload: Record): string { + return JSON.stringify(payload); +} + +interface CapturedOutput { + log: string[]; + stdout: string[]; +} + +function captureOutput(): { capture: CapturedOutput; restore: () => void } { + const log: string[] = []; + const stdout: string[] = []; + const logSpy = vi.spyOn(console, "log").mockImplementation((value?: unknown) => { + log.push(String(value ?? "")); + }); + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { + stdout.push(String(chunk ?? "")); + return true; + }) as typeof process.stdout.write); + return { + capture: { log, stdout }, + restore: () => { + logSpy.mockRestore(); + stdoutSpy.mockRestore(); + }, + }; +} + +function strip(value: string): string { + return value.replace(/\x1b\[[0-9;]*m/g, ""); +} + +describe("printAcpxStreamEvent", () => { + let captured: CapturedOutput; + let restore: () => void; + + beforeEach(() => { + const result = captureOutput(); + captured = result.capture; + restore = result.restore; + }); + + afterEach(() => { + restore(); + }); + + it("renders acpx.session as a labeled session header", () => { + printAcpxStreamEvent( + emit({ + type: "acpx.session", + agent: "claude", + acpSessionId: "acp-1", + mode: "persistent", + permissionMode: "approve-all", + }), + false, + ); + expect(captured.log.map(strip)).toEqual(["claude session: acp-1 [persistent / approve-all]"]); + }); + + it("streams output text_delta to stdout for live progress", () => { + printAcpxStreamEvent( + emit({ type: "acpx.text_delta", text: "hello", channel: "output" }), + false, + ); + expect(captured.log).toEqual([]); + expect(captured.stdout.map(strip)).toEqual(["hello"]); + }); + + it("renders thought text_delta on its own line", () => { + printAcpxStreamEvent( + emit({ type: "acpx.text_delta", text: "thinking…", channel: "thought" }), + false, + ); + expect(captured.log.map(strip)).toEqual(["thinking…"]); + }); + + it("renders tool_call with status and id", () => { + printAcpxStreamEvent( + emit({ + type: "acpx.tool_call", + name: "read", + toolCallId: "tool-1", + status: "running", + text: "read README.md", + }), + false, + ); + expect(captured.log.map(strip)).toEqual([ + "tool_call: read [running] (tool-1)", + "read README.md", + ]); + }); + + it("renders status events with optional context window", () => { + printAcpxStreamEvent( + emit({ type: "acpx.status", tag: "context_window", used: 100, size: 200000 }), + false, + ); + expect(captured.log.map(strip)).toEqual(["status: context_window (100/200000 ctx)"]); + }); + + it("renders acpx.result and acpx.error", () => { + printAcpxStreamEvent(emit({ type: "acpx.result", summary: "completed", stopReason: "end_turn" }), false); + printAcpxStreamEvent(emit({ type: "acpx.error", message: "auth required" }), false); + expect(captured.log.map(strip)).toEqual(["result: completed", "error: auth required"]); + }); + + it("falls back to plain output for non-JSON lines", () => { + printAcpxStreamEvent("not json", false); + expect(captured.log).toEqual(["not json"]); + }); + + it("still emits unknown / non-JSON lines when debug is enabled", () => { + printAcpxStreamEvent("not json", true); + expect(strip(captured.log[0])).toBe("not json"); + }); +}); diff --git a/packages/adapters/acpx-local/src/cli/format-event.ts b/packages/adapters/acpx-local/src/cli/format-event.ts new file mode 100644 index 00000000..9794ba13 --- /dev/null +++ b/packages/adapters/acpx-local/src/cli/format-event.ts @@ -0,0 +1,121 @@ +import pc from "picocolors"; + +function parseJson(line: string): Record | null { + try { + const parsed = JSON.parse(line); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function stringify(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function pickToolUseId(parsed: Record): string { + return ( + asString(parsed.toolCallId) || + asString(parsed.toolUseId) || + asString(parsed.id) + ); +} + +function statusLine(parsed: Record): string { + const text = asString(parsed.text).trim(); + const tag = asString(parsed.tag).trim(); + const used = asNumber(parsed.used, -1); + const size = asNumber(parsed.size, -1); + const parts: string[] = []; + if (text) parts.push(text); + if (tag && !text) parts.push(tag); + if (used >= 0 && size > 0) parts.push(`(${used}/${size} ctx)`); + return parts.join(" ") || tag || "status"; +} + +export function printAcpxStreamEvent(raw: string, debug: boolean): void { + const line = raw.trim(); + if (!line) return; + const parsed = parseJson(line); + if (!parsed) { + if (debug) console.log(pc.gray(line)); + else console.log(line); + return; + } + + const type = asString(parsed.type); + if (type === "acpx.session") { + const agent = asString(parsed.agent, "acpx"); + const session = + asString(parsed.acpSessionId) || + asString(parsed.sessionId) || + asString(parsed.runtimeSessionName); + const mode = asString(parsed.mode); + const permissionMode = asString(parsed.permissionMode); + const tail = [mode, permissionMode].filter(Boolean).join(" / "); + const suffix = tail ? ` [${tail}]` : ""; + console.log(pc.blue(`${agent} session${session ? `: ${session}` : ""}${suffix}`)); + return; + } + if (type === "acpx.text_delta") { + const text = asString(parsed.text); + if (!text) return; + const channel = asString(parsed.channel) || asString(parsed.stream); + const isThought = channel === "thought" || channel === "thinking"; + if (isThought) console.log(pc.gray(text)); + else process.stdout.write(pc.green(text)); + return; + } + if (type === "acpx.tool_call") { + const name = asString(parsed.name, "acp_tool"); + const status = asString(parsed.status); + const id = pickToolUseId(parsed); + const header = status ? `tool_call: ${name} [${status}]` : `tool_call: ${name}`; + const idSuffix = id ? ` (${id})` : ""; + const isError = status === "failed" || status === "cancelled"; + console.log((isError ? pc.red : pc.yellow)(`${header}${idSuffix}`)); + if (parsed.input !== undefined) { + console.log(pc.gray(stringify(parsed.input))); + } else { + const text = asString(parsed.text).trim(); + if (text) console.log(pc.gray(text)); + } + return; + } + if (type === "acpx.tool_result") { + const isError = parsed.isError === true || parsed.error !== undefined; + console.log((isError ? pc.red : pc.cyan)(`tool_result: ${asString(parsed.name, "acp_tool")}`)); + const content = stringify(parsed.content ?? parsed.output ?? parsed.error); + if (content) console.log((isError ? pc.red : pc.gray)(content)); + return; + } + if (type === "acpx.status") { + console.log(pc.gray(`status: ${statusLine(parsed)}`)); + return; + } + if (type === "acpx.result") { + const summary = asString(parsed.summary, asString(parsed.stopReason, asString(parsed.subtype, "complete"))); + console.log(pc.blue(`result: ${summary}`)); + return; + } + if (type === "acpx.error") { + console.log(pc.red(`error: ${asString(parsed.message, line)}`)); + return; + } + console.log(debug ? pc.gray(line) : line); +} diff --git a/packages/adapters/acpx-local/src/cli/index.ts b/packages/adapters/acpx-local/src/cli/index.ts new file mode 100644 index 00000000..51a60e2a --- /dev/null +++ b/packages/adapters/acpx-local/src/cli/index.ts @@ -0,0 +1 @@ +export { printAcpxStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/acpx-local/src/index.ts b/packages/adapters/acpx-local/src/index.ts new file mode 100644 index 00000000..1e4933c0 --- /dev/null +++ b/packages/adapters/acpx-local/src/index.ts @@ -0,0 +1,47 @@ +export const type = "acpx_local"; +export const label = "ACPX (local)"; + +export const DEFAULT_ACPX_LOCAL_AGENT = "claude"; +export const DEFAULT_ACPX_LOCAL_MODE = "persistent"; +export const DEFAULT_ACPX_LOCAL_PERMISSION_MODE = "approve-all"; +export const DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS = "deny"; +export const DEFAULT_ACPX_LOCAL_TIMEOUT_SEC = 0; + +export const acpxAgentOptions = [ + { id: "claude", label: "Claude via ACPX" }, + { id: "codex", label: "Codex via ACPX" }, + { id: "custom", label: "Custom ACP command" }, +] as const; + +export const agentConfigurationDoc = `# acpx_local agent configuration + +Adapter: acpx_local + +Use when: +- The agent should run through Agent Client Protocol via ACPX on the Paperclip host or a managed execution environment. +- You want one built-in adapter that can target Claude, Codex, or a custom ACP server command. +- You need Paperclip-managed session identity and live streamed ACP events in later ACPX runtime phases. + +Don't use when: +- You need today's stable Claude Code or Codex CLI wrapper behavior. Use claude_local or codex_local until acpx_local runtime execution is enabled. +- The host cannot satisfy ACPX's Node >=22.12.0 prerequisite. +- The agent runtime is not an ACP server and cannot be launched through ACPX. + +Core fields: +- agent (string, optional): claude, codex, or custom. Defaults to claude. +- agentCommand (string, optional): custom ACP command when agent=custom, or an override for a built-in ACP agent command. +- mode (string, optional): persistent or oneshot. Defaults to persistent. +- cwd (string, optional): default absolute working directory fallback for the agent process. +- permissionMode (string, optional): defaults to approve-all, meaning ACPX permission requests are auto-approved. +- nonInteractivePermissions (string, optional): fallback behavior when ACPX cannot ask interactively. Supported values are deny and fail. +- stateDir (string, optional): ACPX state directory. Defaults to a Paperclip-managed company/agent scoped location. +- instructionsFilePath (string, optional): absolute path to a markdown instructions file used by Paperclip prompt construction. +- promptTemplate (string, optional): run prompt template. +- bootstrapPromptTemplate (string, optional): first-run bootstrap prompt template. +- timeoutSec (number, optional): run timeout in seconds. Defaults to 0, meaning no adapter timeout. +- env (object, optional): KEY=VALUE environment variables or secret bindings. + +Dependency decision: +- acpx_local declares direct dependencies on acpx, @agentclientprotocol/claude-agent-acp, and @zed-industries/codex-acp so the built-in adapter has deterministic package resolution instead of relying on globally installed ACP commands. +- ACPX currently requires Node >=22.12.0. Paperclip keeps the repo-wide Node >=20 engine and surfaces the stricter runtime prerequisite through acpx_local diagnostics. +`; diff --git a/packages/adapters/acpx-local/src/server/config-schema.ts b/packages/adapters/acpx-local/src/server/config-schema.ts new file mode 100644 index 00000000..87100917 --- /dev/null +++ b/packages/adapters/acpx-local/src/server/config-schema.ts @@ -0,0 +1,102 @@ +import type { AdapterConfigSchema } from "@paperclipai/adapter-utils"; +import { + DEFAULT_ACPX_LOCAL_AGENT, + DEFAULT_ACPX_LOCAL_MODE, + DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS, + DEFAULT_ACPX_LOCAL_PERMISSION_MODE, + DEFAULT_ACPX_LOCAL_TIMEOUT_SEC, + acpxAgentOptions, +} from "../index.js"; + +export function getConfigSchema(): AdapterConfigSchema { + return { + fields: [ + { + key: "agent", + label: "ACP agent", + type: "select", + default: DEFAULT_ACPX_LOCAL_AGENT, + required: true, + options: acpxAgentOptions.map((agent) => ({ value: agent.id, label: agent.label })), + hint: "Choose the ACP agent launched through ACPX.", + }, + { + key: "agentCommand", + label: "Agent command", + type: "text", + hint: "Required for custom agents; optional override for built-in Claude or Codex ACP commands.", + }, + { + key: "mode", + label: "Session mode", + type: "select", + default: DEFAULT_ACPX_LOCAL_MODE, + options: [ + { value: "persistent", label: "Persistent" }, + { value: "oneshot", label: "One shot" }, + ], + }, + { + key: "permissionMode", + label: "Permission mode", + type: "select", + default: DEFAULT_ACPX_LOCAL_PERMISSION_MODE, + options: [ + { value: "approve-all", label: "Approve all" }, + { value: "default", label: "Approve reads" }, + ], + hint: "Defaults to maximum permissions. Approve reads grants read-only requests and asks for approval on writes.", + }, + { + key: "nonInteractivePermissions", + label: "Non-interactive permissions", + type: "select", + default: DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS, + options: [ + { value: "deny", label: "Deny" }, + { value: "fail", label: "Fail" }, + ], + }, + { + key: "cwd", + label: "Working directory", + type: "text", + hint: "Absolute fallback directory. Paperclip execution workspaces can override this at runtime.", + }, + { + key: "stateDir", + label: "State directory", + type: "text", + hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.", + }, + { + key: "instructionsFilePath", + label: "Instructions file", + type: "text", + hint: "Optional absolute path to markdown instructions injected into the run prompt.", + }, + { + key: "promptTemplate", + label: "Prompt template", + type: "textarea", + }, + { + key: "bootstrapPromptTemplate", + label: "Bootstrap prompt template", + type: "textarea", + }, + { + key: "timeoutSec", + label: "Timeout seconds", + type: "number", + default: DEFAULT_ACPX_LOCAL_TIMEOUT_SEC, + }, + { + key: "env", + label: "Environment JSON", + type: "textarea", + hint: "Optional JSON object of environment values or secret bindings.", + }, + ], + }; +} diff --git a/packages/adapters/acpx-local/src/server/execute.test.ts b/packages/adapters/acpx-local/src/server/execute.test.ts new file mode 100644 index 00000000..ccdea013 --- /dev/null +++ b/packages/adapters/acpx-local/src/server/execute.test.ts @@ -0,0 +1,362 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createAcpxLocalExecutor } from "./execute.js"; + +const tempRoots: string[] = []; + +async function makeTempRoot() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-skills-")); + tempRoots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +async function pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +async function onlyChildDir(parent: string): Promise { + const entries = await fs.readdir(parent); + expect(entries).toHaveLength(1); + return path.join(parent, entries[0]!); +} + +async function createSkill(root: string, name: string, body = `---\nrequired: false\n---\n# ${name}\n`) { + const skillDir = path.join(root, name); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), body, "utf8"); + return { + key: `paperclipai/test/${name}`, + runtimeName: name, + source: skillDir, + required: false, + }; +} + +function buildRuntime() { + return { + ensureSession: async () => ({ + backendSessionId: "backend-session", + agentSessionId: "agent-session", + runtimeSessionName: "runtime-session", + }), + startTurn: () => ({ + events: (async function* () { + yield { type: "done", stopReason: "end_turn" }; + })(), + result: Promise.resolve({ status: "completed", stopReason: "end_turn" }), + cancel: async () => {}, + }), + close: async () => {}, + }; +} + +async function runExecutor(config: Record) { + const runtimeOptions: Record[] = []; + const meta: Record[] = []; + const logs: Array<{ stream: string; text: string }> = []; + const execute = createAcpxLocalExecutor({ + createRuntime: (options) => { + runtimeOptions.push(options as unknown as Record); + return buildRuntime() as never; + }, + }); + + const result = await execute({ + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + }, + runtime: {}, + config, + context: {}, + onLog: async (stream: "stdout" | "stderr", text: string) => { + logs.push({ stream, text }); + }, + onMeta: async (payload: unknown) => { + meta.push(payload as Record); + }, + } as never); + + expect(result.exitCode).toBe(0); + return { logs, meta, runtimeOptions, result }; +} + +describe("acpx_local runtime skill isolation", () => { + it.skipIf(process.platform === "win32")("materializes ACPX Claude skills without symlinked descendants", async () => { + const root = await makeTempRoot(); + const skillRoot = path.join(root, "skills"); + const outsideRoot = path.join(root, "outside"); + await fs.mkdir(outsideRoot, { recursive: true }); + await fs.writeFile(path.join(outsideRoot, "secret.txt"), "do not expose", "utf8"); + const skill = await createSkill(skillRoot, "danger"); + await fs.symlink(path.join(outsideRoot, "secret.txt"), path.join(skill.source, "leak.txt")); + await fs.symlink(outsideRoot, path.join(skill.source, "leak-dir")); + + const stateDir = path.join(root, "state"); + const { meta } = await runExecutor({ + agent: "claude", + stateDir, + paperclipRuntimeSkills: [skill], + paperclipSkillSync: { desiredSkills: [skill.key] }, + }); + + const mountedRoot = await onlyChildDir(path.join(stateDir, "runtime-skills", "claude")); + const skillsHome = path.join(mountedRoot, ".claude", "skills"); + const materializedSkill = path.join(skillsHome, skill.runtimeName); + expect(await fs.readFile(path.join(materializedSkill, "SKILL.md"), "utf8")).toContain("# danger"); + expect(await pathExists(path.join(materializedSkill, "leak.txt"))).toBe(false); + expect(await pathExists(path.join(materializedSkill, "leak-dir"))).toBe(false); + expect(String(meta[0]?.prompt ?? "")).toContain(`Skill root: ${skillsHome}`); + }); + + it.skipIf(process.platform === "win32")("revokes removed ACPX Codex skills and skips symlinked descendants", async () => { + const root = await makeTempRoot(); + const skillRoot = path.join(root, "skills"); + const outsideRoot = path.join(root, "outside"); + const codexHome = path.join(root, "codex-home"); + await fs.mkdir(outsideRoot, { recursive: true }); + await fs.writeFile(path.join(outsideRoot, "secret.txt"), "do not expose", "utf8"); + const keep = await createSkill(skillRoot, "keep"); + const remove = await createSkill(skillRoot, "remove"); + await fs.symlink(path.join(outsideRoot, "secret.txt"), path.join(keep.source, "leak.txt")); + await fs.symlink(outsideRoot, path.join(keep.source, "leak-dir")); + + const baseConfig = { + agent: "codex", + stateDir: path.join(root, "state"), + env: { CODEX_HOME: codexHome }, + paperclipRuntimeSkills: [keep, remove], + }; + + await runExecutor({ + ...baseConfig, + paperclipSkillSync: { desiredSkills: [keep.key, remove.key] }, + }); + expect(await pathExists(path.join(codexHome, "skills", remove.runtimeName, "SKILL.md"))).toBe(true); + + await runExecutor({ + ...baseConfig, + paperclipSkillSync: { desiredSkills: [keep.key] }, + }); + + expect(await pathExists(path.join(codexHome, "skills", keep.runtimeName, "SKILL.md"))).toBe(true); + expect(await pathExists(path.join(codexHome, "skills", keep.runtimeName, "leak.txt"))).toBe(false); + expect(await pathExists(path.join(codexHome, "skills", keep.runtimeName, "leak-dir"))).toBe(false); + expect(await pathExists(path.join(codexHome, "skills", remove.runtimeName))).toBe(false); + }); + + it.skipIf(process.platform === "win32")("removes legacy ACPX Codex skill symlinks when a skill is no longer desired", async () => { + const root = await makeTempRoot(); + const skillRoot = path.join(root, "skills"); + const codexHome = path.join(root, "codex-home"); + const legacy = await createSkill(skillRoot, "legacy"); + const skillsHome = path.join(codexHome, "skills"); + await fs.mkdir(skillsHome, { recursive: true }); + await fs.symlink(legacy.source, path.join(skillsHome, legacy.runtimeName)); + + await runExecutor({ + agent: "codex", + stateDir: path.join(root, "state"), + env: { CODEX_HOME: codexHome }, + paperclipRuntimeSkills: [legacy], + paperclipSkillSync: { desiredSkills: [] }, + }); + + expect(await pathExists(path.join(skillsHome, legacy.runtimeName))).toBe(false); + }); + + it.skipIf(process.platform === "win32")("replaces stale managed Codex auth files with source symlinks", async () => { + const root = await makeTempRoot(); + const sourceCodexHome = path.join(root, "source-codex-home"); + const paperclipHome = path.join(root, "paperclip-home"); + const managedCodexHome = path.join( + paperclipHome, + "instances", + "default", + "companies", + "company-1", + "codex-home", + ); + await fs.mkdir(sourceCodexHome, { recursive: true }); + await fs.mkdir(managedCodexHome, { recursive: true }); + const sourceAuth = path.join(sourceCodexHome, "auth.json"); + const managedAuth = path.join(managedCodexHome, "auth.json"); + await fs.writeFile(sourceAuth, "{\"source\":true}", "utf8"); + await fs.writeFile(managedAuth, "{\"stale\":true}", "utf8"); + + const previousCodexHome = process.env.CODEX_HOME; + const previousPaperclipHome = process.env.PAPERCLIP_HOME; + try { + process.env.CODEX_HOME = sourceCodexHome; + process.env.PAPERCLIP_HOME = paperclipHome; + await runExecutor({ + agent: "codex", + stateDir: path.join(root, "state"), + paperclipRuntimeSkills: [], + paperclipSkillSync: { desiredSkills: [] }, + }); + } finally { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousPaperclipHome; + } + + const authStat = await fs.lstat(managedAuth); + expect(authStat.isSymbolicLink()).toBe(true); + expect(path.resolve(path.dirname(managedAuth), await fs.readlink(managedAuth))).toBe(sourceAuth); + }); + + it("keeps fresh credential wrapper scripts across ACPX agent changes", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const baseConfig = { + agentCommand: "node ./fake-acp.js", + stateDir, + }; + + await runExecutor({ + ...baseConfig, + agent: "custom-a", + env: { PAPERCLIP_API_KEY: "old-key" }, + }); + await runExecutor({ + ...baseConfig, + agent: "custom-b", + env: { PAPERCLIP_API_KEY: "new-key" }, + }); + + const wrappers = await fs.readdir(path.join(stateDir, "wrappers")); + expect(wrappers.filter((name) => name.endsWith(".sh"))).toHaveLength(2); + expect(wrappers.filter((name) => name.endsWith(".env"))).toHaveLength(2); + expect(wrappers.some((name) => name.startsWith("custom-a-"))).toBe(true); + expect(wrappers.some((name) => name.startsWith("custom-b-"))).toBe(true); + const wrapperPath = path.join(stateDir, "wrappers", wrappers.find((name) => name.startsWith("custom-b-") && name.endsWith(".sh"))!); + const envPath = path.join(stateDir, "wrappers", wrappers.find((name) => name.startsWith("custom-b-") && name.endsWith(".env"))!); + const wrapper = await fs.readFile(wrapperPath, "utf8"); + const env = await fs.readFile(envPath, "utf8"); + expect((await fs.stat(envPath)).mode & 0o777).toBe(0o600); + expect((await fs.stat(wrapperPath)).mode & 0o777).toBe(0o700); + expect(wrapper).toContain("node ./fake-acp.js"); + expect(wrapper).not.toContain("PAPERCLIP_API_KEY"); + expect(wrapper).not.toContain("new-key"); + expect(wrapper).not.toContain("old-key"); + expect(env).toContain("PAPERCLIP_API_KEY='new-key'"); + expect(env).not.toContain("old-key"); + }); + + it("cleans aged credential wrapper scripts across ACPX agent changes", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const wrappersDir = path.join(stateDir, "wrappers"); + const baseConfig = { + agentCommand: "node ./fake-acp.js", + stateDir, + }; + + await runExecutor({ + ...baseConfig, + agent: "custom-a", + env: { PAPERCLIP_API_KEY: "old-key" }, + }); + const oldDate = new Date(Date.now() - 16 * 60 * 1000); + await Promise.all( + (await fs.readdir(wrappersDir)) + .filter((name) => name.startsWith("custom-a-")) + .map((name) => fs.utimes(path.join(wrappersDir, name), oldDate, oldDate)), + ); + + await runExecutor({ + ...baseConfig, + agent: "custom-b", + env: { PAPERCLIP_API_KEY: "new-key" }, + }); + + const wrappers = await fs.readdir(wrappersDir); + expect(wrappers.filter((name) => name.endsWith(".sh"))).toHaveLength(1); + expect(wrappers.filter((name) => name.endsWith(".env"))).toHaveLength(1); + expect(wrappers.some((name) => name.startsWith("custom-a-"))).toBe(false); + expect(wrappers.some((name) => name.startsWith("custom-b-"))).toBe(true); + }); + + it("keeps distinct wrapper env files for concurrent runs with different credentials", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const baseConfig = { + agent: "custom-a", + agentCommand: "node ./fake-acp.js", + stateDir, + }; + + await runExecutor({ + ...baseConfig, + env: { PAPERCLIP_API_KEY: "first-key" }, + }); + await runExecutor({ + ...baseConfig, + env: { PAPERCLIP_API_KEY: "second-key" }, + }); + + const envFileNames = (await fs.readdir(path.join(stateDir, "wrappers"))).filter((name) => name.endsWith(".env")); + expect(envFileNames).toHaveLength(2); + const envFiles = await Promise.all( + envFileNames.map(async (name) => fs.readFile(path.join(stateDir, "wrappers", name), "utf8")), + ); + expect(envFiles.filter((contents) => contents.includes("PAPERCLIP_API_KEY='first-key'"))).toHaveLength(1); + expect(envFiles.filter((contents) => contents.includes("PAPERCLIP_API_KEY='second-key'"))).toHaveLength(1); + }); + + it("passes Paperclip env through the ACP agent wrapper instead of process.env", async () => { + let observedApiKeyDuringStream: string | undefined; + const execute = createAcpxLocalExecutor({ + createRuntime: () => ({ + ensureSession: async () => ({ + backendSessionId: "backend-session", + agentSessionId: "agent-session", + runtimeSessionName: "runtime-session", + }), + startTurn: () => ({ + events: (async function* () { + await Promise.resolve(); + observedApiKeyDuringStream = process.env.PAPERCLIP_API_KEY; + yield { type: "done", stopReason: "end_turn" }; + })(), + result: Promise.resolve({ status: "completed", stopReason: "end_turn" }), + cancel: async () => {}, + }), + close: async () => {}, + }) as never, + }); + + const previousApiKey = process.env.PAPERCLIP_API_KEY; + try { + delete process.env.PAPERCLIP_API_KEY; + const result = await execute({ + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + }, + runtime: {}, + config: { agent: "custom", agentCommand: "node ./fake-acp.js" }, + context: {}, + authToken: "runtime-key", + onLog: async () => {}, + onMeta: async () => {}, + } as never); + + expect(result.exitCode).toBe(0); + expect(observedApiKeyDuringStream).toBeUndefined(); + } finally { + if (previousApiKey === undefined) delete process.env.PAPERCLIP_API_KEY; + else process.env.PAPERCLIP_API_KEY = previousApiKey; + } + }); +}); diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapters/acpx-local/src/server/execute.ts new file mode 100644 index 00000000..6c2840e0 --- /dev/null +++ b/packages/adapters/acpx-local/src/server/execute.ts @@ -0,0 +1,1212 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { readAdapterExecutionTarget, adapterExecutionTargetSessionIdentity } from "@paperclipai/adapter-utils/execution-target"; +import { + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, + applyPaperclipWorkspaceEnv, + asNumber, + asString, + buildInvocationEnvForLogs, + buildPaperclipEnv, + ensureAbsoluteDirectory, + ensurePathInEnv, + joinPromptSections, + materializePaperclipSkillCopy, + parseObject, + readPaperclipRuntimeSkillEntries, + renderPaperclipWakePrompt, + renderTemplate, + resolvePaperclipDesiredSkillNames, + stringifyPaperclipWakePayload, + type PaperclipSkillEntry, +} from "@paperclipai/adapter-utils/server-utils"; +import { shellQuote } from "@paperclipai/adapter-utils/ssh"; +import { + createAcpRuntime, + createAgentRegistry, + createRuntimeStore, + isAcpRuntimeError, + type AcpAgentRegistry, + type AcpRuntime, + type AcpRuntimeEvent, + type AcpRuntimeHandle, + type AcpRuntimeOptions, + type AcpRuntimeTurn, + type AcpRuntimeTurnResult, +} from "acpx/runtime"; +import { + DEFAULT_ACPX_LOCAL_AGENT, + DEFAULT_ACPX_LOCAL_MODE, + DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS, + DEFAULT_ACPX_LOCAL_PERMISSION_MODE, + DEFAULT_ACPX_LOCAL_TIMEOUT_SEC, +} from "../index.js"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_WARM_HANDLE_IDLE_MS = 15 * 60 * 1000; +const WRAPPER_CLEANUP_RETENTION_MS = 15 * 60 * 1000; +const PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST = ".paperclip-managed-skills.json"; + +type AcpxRuntimeFactory = (options: AcpRuntimeOptions) => AcpRuntime; + +interface RuntimeCacheEntry { + runtime: AcpRuntime; + handle: AcpRuntimeHandle; + fingerprint: string; + lastUsedAt: number; +} + +interface ExecuteDeps { + createRuntime?: AcpxRuntimeFactory; + now?: () => number; + warmHandles?: Map; +} + +interface AcpxPreparedRuntime { + acpxAgent: string; + mode: "persistent" | "oneshot"; + cwd: string; + workspaceId: string; + workspaceRepoUrl: string; + workspaceRepoRef: string; + env: Record; + loggedEnv: Record; + stateDir: string; + permissionMode: "approve-all" | "approve-reads" | "deny-all"; + nonInteractivePermissions: "deny" | "fail"; + timeoutSec: number; + sessionKey: string; + fingerprint: string; + agentCommand: string | null; + agentRegistry: AcpAgentRegistry; + remoteExecutionIdentity: Record | null; + skillPromptInstructions: string; + skillsIdentity: Record; +} + +const defaultWarmHandles = new Map(); + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function shortHash(value: unknown): string { + return createHash("sha256").update(stableJson(value)).digest("hex").slice(0, 16); +} + +function defaultPaperclipInstanceDir(): string { + const home = process.env.PAPERCLIP_HOME?.trim() || path.join(os.homedir(), ".paperclip"); + const instanceId = process.env.PAPERCLIP_INSTANCE_ID?.trim() || "default"; + return path.join(home, "instances", instanceId); +} + +function defaultStateDir(companyId: string, agentId: string): string { + return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "acpx-local", "agents", agentId); +} + +function resolveManagedCodexHomeDir(companyId: string): string { + return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "codex-home"); +} + +function packageRootDir(): string { + return path.resolve(__moduleDir, "../.."); +} + +function resolveBuiltInAgentCommand(agent: string): string | null { + const binName = + agent === "claude" + ? "claude-agent-acp" + : agent === "codex" + ? "codex-acp" + : null; + if (!binName) return null; + return path.join(packageRootDir(), "node_modules", ".bin", binName); +} + +function normalizeAgent(config: Record): string { + const agent = asString(config.agent, DEFAULT_ACPX_LOCAL_AGENT).trim(); + return agent || DEFAULT_ACPX_LOCAL_AGENT; +} + +async function pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +async function ensureParentDir(target: string): Promise { + await fs.mkdir(path.dirname(target), { recursive: true }); +} + +async function writeFileAtomically(input: { + target: string; + contents: string; + mode: number; +}): Promise { + await ensureParentDir(input.target); + const tempPath = `${input.target}.tmp-${process.pid}-${randomUUID()}`; + const handle = await fs.open(tempPath, "wx", input.mode); + try { + await handle.writeFile(input.contents, "utf8"); + await handle.close(); + await fs.rename(tempPath, input.target); + await fs.chmod(input.target, input.mode).catch(() => {}); + } catch (err) { + await handle.close().catch(() => {}); + await fs.rm(tempPath, { force: true }).catch(() => {}); + throw err; + } +} + +async function ensureSymlink(target: string, source: string): Promise { + const resolvedSource = path.resolve(source); + const existing = await fs.lstat(target).catch(() => null); + if (!existing) { + await ensureParentDir(target); + await fs.symlink(resolvedSource, target); + return; + } + + if (!existing.isSymbolicLink()) { + await fs.rm(target, { recursive: true, force: true }); + await fs.symlink(resolvedSource, target); + return; + } + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) return; + + const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); + if (resolvedLinkedPath === resolvedSource) return; + + await fs.unlink(target); + await fs.symlink(resolvedSource, target); +} + +async function ensureCopiedFile(target: string, source: string): Promise { + if (await pathExists(target)) return; + await ensureParentDir(target); + await fs.copyFile(source, target); +} + +async function prepareManagedCodexHome(input: { + companyId: string; + sourceHome: string; + targetHome: string; + onLog: AdapterExecutionContext["onLog"]; +}): Promise { + const { sourceHome, targetHome, onLog } = input; + if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome; + + await fs.mkdir(targetHome, { recursive: true }); + + const authJson = path.join(sourceHome, "auth.json"); + if (await pathExists(authJson)) await ensureSymlink(path.join(targetHome, "auth.json"), authJson); + + for (const name of ["config.json", "config.toml", "instructions.md"]) { + const source = path.join(sourceHome, name); + if (await pathExists(source)) await ensureCopiedFile(path.join(targetHome, name), source); + } + + await onLog( + "stdout", + `[paperclip] Using Paperclip-managed ACPX Codex home "${targetHome}" (seeded from "${sourceHome}").\n`, + ); + return targetHome; +} + +async function hashPathContents( + candidate: string, + hash: ReturnType, + relativePath: string, + seenDirectories: Set, +): Promise { + const stat = await fs.lstat(candidate); + + if (stat.isSymbolicLink()) { + hash.update(`symlink-skipped:${relativePath}\n`); + return; + } + + if (stat.isDirectory()) { + const realDir = await fs.realpath(candidate).catch(() => candidate); + hash.update(`dir:${relativePath}\n`); + if (seenDirectories.has(realDir)) { + hash.update("loop\n"); + return; + } + seenDirectories.add(realDir); + const entries = await fs.readdir(candidate, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const childRelativePath = relativePath.length > 0 ? `${relativePath}/${entry.name}` : entry.name; + await hashPathContents(path.join(candidate, entry.name), hash, childRelativePath, seenDirectories); + } + return; + } + + if (stat.isFile()) { + hash.update(`file:${relativePath}\n`); + hash.update(await fs.readFile(candidate)); + hash.update("\n"); + return; + } + + hash.update(`other:${relativePath}:${stat.mode}\n`); +} + +async function buildSkillSetKey(input: { + skills: PaperclipSkillEntry[]; + label: string; +}): Promise { + const hash = createHash("sha256"); + hash.update(`paperclip-acpx-${input.label}-skills:v1\n`); + const sorted = [...input.skills].sort((left, right) => left.runtimeName.localeCompare(right.runtimeName)); + for (const entry of sorted) { + hash.update(`skill:${entry.key}:${entry.runtimeName}\n`); + await hashPathContents(entry.source, hash, entry.runtimeName, new Set()); + } + return hash.digest("hex"); +} + +async function resolveSelectedRuntimeSkills( + config: Record, +): Promise<{ allSkills: PaperclipSkillEntry[]; selectedSkills: PaperclipSkillEntry[]; desiredSkillNames: string[] }> { + const allSkills = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredSkillNames = resolvePaperclipDesiredSkillNames(config, allSkills); + const desiredSet = new Set(desiredSkillNames); + return { + allSkills, + selectedSkills: allSkills.filter((entry) => desiredSet.has(entry.key)), + desiredSkillNames, + }; +} + +async function prepareClaudeSkillRuntime(input: { + stateDir: string; + config: Record; + onLog: AdapterExecutionContext["onLog"]; +}): Promise<{ + identity: Record; + promptInstructions: string; + commandNotes: string[]; +}> { + const { selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config); + const skillSetKey = await buildSkillSetKey({ skills: selectedSkills, label: "claude" }); + const bundleRoot = path.join(input.stateDir, "runtime-skills", "claude", skillSetKey); + const skillsHome = path.join(bundleRoot, ".claude", "skills"); + await fs.mkdir(skillsHome, { recursive: true }); + + for (const entry of selectedSkills) { + const target = path.join(skillsHome, entry.runtimeName); + try { + const result = await materializePaperclipSkillCopy(entry.source, target); + if (result.skippedSymlinks.length > 0) { + await input.onLog( + "stdout", + `[paperclip] Materialized ACPX Claude skill "${entry.runtimeName}" into ${skillsHome} and skipped ${result.skippedSymlinks.length} symlink(s).\n`, + ); + } + } catch (err) { + await input.onLog( + "stderr", + `[paperclip] Failed to materialize ACPX Claude skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } + + const selectedNames = selectedSkills.map((entry) => entry.runtimeName).sort(); + const promptInstructions = selectedSkills.length > 0 + ? [ + "Paperclip has materialized selected runtime skills for this ACPX Claude session.", + `Skill root: ${skillsHome}`, + selectedNames.length > 0 ? `Selected skills: ${selectedNames.join(", ")}` : "", + "When a task calls for one of these skills, read its SKILL.md from that root and follow it.", + ].filter(Boolean).join("\n") + : ""; + + return { + identity: { + mode: "claude", + skillSetKey, + desiredSkillNames, + selectedSkills: selectedNames, + skillRoot: selectedSkills.length > 0 ? skillsHome : null, + }, + promptInstructions, + commandNotes: selectedSkills.length > 0 + ? [`Materialized ${selectedSkills.length} Paperclip skill(s) for ACPX Claude at ${skillsHome}.`] + : [], + }; +} + +async function readManagedCodexSkillsManifest(skillsHome: string): Promise> { + const manifestPath = path.join(skillsHome, PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST); + try { + const raw = JSON.parse(await fs.readFile(manifestPath, "utf8")) as unknown; + const parsed = parseObject(raw); + const skills = Array.isArray(parsed.managedSkillNames) + ? parsed.managedSkillNames.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + return new Set(skills); + } catch { + return new Set(); + } +} + +async function writeManagedCodexSkillsManifest(skillsHome: string, skillNames: Iterable): Promise { + const managedSkillNames = Array.from(new Set(skillNames)).sort(); + await fs.writeFile( + path.join(skillsHome, PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST), + `${JSON.stringify({ version: 1, managedSkillNames }, null, 2)}\n`, + "utf8", + ); +} + +async function removeSkillTarget(target: string): Promise { + const existing = await fs.lstat(target).catch(() => null); + if (!existing) return false; + await fs.rm(target, { recursive: true, force: true }); + return true; +} + +async function reconcileManagedCodexSkills(input: { + skillsHome: string; + allSkills: PaperclipSkillEntry[]; + selectedSkills: PaperclipSkillEntry[]; + onLog: AdapterExecutionContext["onLog"]; +}): Promise { + const desired = new Set(input.selectedSkills.map((entry) => entry.runtimeName)); + const managed = await readManagedCodexSkillsManifest(input.skillsHome); + const availableByRuntimeName = new Map(input.allSkills.map((entry) => [entry.runtimeName, entry])); + + for (const name of managed) { + if (desired.has(name)) continue; + if (await removeSkillTarget(path.join(input.skillsHome, name))) { + await input.onLog("stdout", `[paperclip] Revoked ACPX Codex skill "${name}" from ${input.skillsHome}\n`); + } + } + + for (const entry of input.allSkills) { + if (desired.has(entry.runtimeName) || managed.has(entry.runtimeName)) continue; + const target = path.join(input.skillsHome, entry.runtimeName); + const existing = await fs.lstat(target).catch(() => null); + if (!existing?.isSymbolicLink()) continue; + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) continue; + const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); + if (resolvedLinkedPath !== path.resolve(entry.source)) continue; + if (await removeSkillTarget(target)) { + await input.onLog("stdout", `[paperclip] Revoked legacy ACPX Codex skill "${entry.runtimeName}" from ${input.skillsHome}\n`); + } + } + + for (const name of managed) { + if (desired.has(name) || availableByRuntimeName.has(name)) continue; + if (await removeSkillTarget(path.join(input.skillsHome, name))) { + await input.onLog("stdout", `[paperclip] Revoked unavailable ACPX Codex skill "${name}" from ${input.skillsHome}\n`); + } + } +} + +async function prepareCodexSkillRuntime(input: { + companyId: string; + config: Record; + env: Record; + onLog: AdapterExecutionContext["onLog"]; +}): Promise<{ identity: Record; commandNotes: string[] }> { + const envConfig = parseObject(input.config.env); + const configuredCodexHome = + typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0 + ? path.resolve(envConfig.CODEX_HOME.trim()) + : null; + const sourceCodexHome = + typeof process.env.CODEX_HOME === "string" && process.env.CODEX_HOME.trim().length > 0 + ? path.resolve(process.env.CODEX_HOME.trim()) + : path.join(os.homedir(), ".codex"); + const managedCodexHome = resolveManagedCodexHomeDir(input.companyId); + const effectiveCodexHome = configuredCodexHome ?? + await prepareManagedCodexHome({ + companyId: input.companyId, + sourceHome: sourceCodexHome, + targetHome: managedCodexHome, + onLog: input.onLog, + }); + const { allSkills, selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config); + const skillSetKey = await buildSkillSetKey({ skills: selectedSkills, label: "codex" }); + const skillsHome = path.join(effectiveCodexHome, "skills"); + await fs.mkdir(skillsHome, { recursive: true }); + await reconcileManagedCodexSkills({ + skillsHome, + allSkills, + selectedSkills, + onLog: input.onLog, + }); + + for (const entry of selectedSkills) { + const target = path.join(skillsHome, entry.runtimeName); + try { + const result = await materializePaperclipSkillCopy(entry.source, target); + if (result.skippedSymlinks.length > 0) { + await input.onLog( + "stdout", + `[paperclip] Materialized ACPX Codex skill "${entry.runtimeName}" into ${skillsHome} and skipped ${result.skippedSymlinks.length} symlink(s).\n`, + ); + } + } catch (err) { + await input.onLog( + "stderr", + `[paperclip] Failed to inject ACPX Codex skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } + await writeManagedCodexSkillsManifest(skillsHome, selectedSkills.map((entry) => entry.runtimeName)); + + input.env.CODEX_HOME = effectiveCodexHome; + + return { + identity: { + mode: "codex", + skillSetKey, + desiredSkillNames, + selectedSkills: selectedSkills.map((entry) => entry.runtimeName).sort(), + codexHome: effectiveCodexHome, + skillsHome, + }, + commandNotes: [`Prepared ACPX Codex skill home at ${skillsHome}.`], + }; +} + +function normalizeMode(config: Record): "persistent" | "oneshot" { + return asString(config.mode, DEFAULT_ACPX_LOCAL_MODE) === "oneshot" ? "oneshot" : "persistent"; +} + +function normalizePermissionMode(config: Record): "approve-all" | "approve-reads" | "deny-all" { + const value = asString(config.permissionMode, DEFAULT_ACPX_LOCAL_PERMISSION_MODE).trim(); + if (value === "approve-reads" || value === "deny-all") return value; + if (value === "default") return "approve-reads"; + return "approve-all"; +} + +function normalizeNonInteractivePermissions(config: Record): "deny" | "fail" { + return asString(config.nonInteractivePermissions, DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS) === "fail" + ? "fail" + : "deny"; +} + +function isCompatibleSession( + params: Record, + runtime: Pick, +): boolean { + if (asString(params.configFingerprint, "") !== runtime.fingerprint) return false; + if (asString(params.sessionKey, "") !== runtime.sessionKey) return false; + if (asString(params.agent, "") !== runtime.acpxAgent) return false; + if (asString(params.mode, "") !== runtime.mode) return false; + const savedCwd = asString(params.cwd, ""); + if (!savedCwd || path.resolve(savedCwd) !== path.resolve(runtime.cwd)) return false; + const savedRemote = parseObject(params.remoteExecution); + return stableJson(savedRemote) === stableJson(runtime.remoteExecutionIdentity ?? {}); +} + +function buildSessionParams(input: { + prepared: AcpxPreparedRuntime; + handle: AcpRuntimeHandle; +}): Record { + const { prepared, handle } = input; + return { + sessionKey: prepared.sessionKey, + runtimeSessionName: handle.runtimeSessionName, + acpxRecordId: handle.acpxRecordId, + acpSessionId: handle.backendSessionId, + agentSessionId: handle.agentSessionId, + agent: prepared.acpxAgent, + cwd: prepared.cwd, + mode: prepared.mode, + stateDir: prepared.stateDir, + configFingerprint: prepared.fingerprint, + skills: prepared.skillsIdentity, + ...(prepared.workspaceId ? { workspaceId: prepared.workspaceId } : {}), + ...(prepared.workspaceRepoUrl ? { repoUrl: prepared.workspaceRepoUrl } : {}), + ...(prepared.workspaceRepoRef ? { repoRef: prepared.workspaceRepoRef } : {}), + ...(prepared.remoteExecutionIdentity ? { remoteExecution: prepared.remoteExecutionIdentity } : {}), + }; +} + +async function writeAgentWrapper(input: { + stateDir: string; + acpxAgent: string; + agentCommandShell: string; + env: Record; +}): Promise<{ wrapperPath: string; envFilePath: string }> { + const wrappersDir = path.join(input.stateDir, "wrappers"); + await fs.mkdir(wrappersDir, { recursive: true }); + const envLines = Object.entries(input.env) + .filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${shellQuote(value)}`); + const wrapperHash = shortHash({ + agent: input.acpxAgent, + command: input.agentCommandShell, + env: envLines, + }); + const wrapperPath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.sh`); + const envFilePath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.env`); + const script = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `env_file=${shellQuote(envFilePath)}`, + "if [[ -f \"$env_file\" ]]; then", + " set -a", + " source \"$env_file\"", + " set +a", + "fi", + `exec ${input.agentCommandShell} "$@"`, + "", + ].join("\n"); + await writeFileAtomically({ + target: envFilePath, + contents: `${envLines.join("\n")}\n`, + mode: 0o600, + }); + await writeFileAtomically({ + target: wrapperPath, + contents: script, + mode: 0o700, + }); + await cleanupStaleAgentWrappers({ + wrappersDir, + currentFileNames: new Set([path.basename(wrapperPath), path.basename(envFilePath)]), + }); + return { wrapperPath, envFilePath }; +} + +async function cleanupStaleAgentWrappers(input: { wrappersDir: string; currentFileNames: Set }) { + const wrappers = await fs.readdir(input.wrappersDir).catch(() => []); + const now = Date.now(); + await Promise.all( + wrappers.map(async (name) => { + const isManagedWrapperFile = name.endsWith(".sh") || name.endsWith(".env"); + if (!isManagedWrapperFile || input.currentFileNames.has(name)) return; + const wrapperPath = path.join(input.wrappersDir, name); + const stats = await fs.stat(wrapperPath).catch(() => null); + if (!stats || now - stats.mtimeMs < WRAPPER_CLEANUP_RETENTION_MS) return; + await fs.rm(wrapperPath, { force: true }); + }), + ); +} + +async function buildRuntime(input: { + ctx: AdapterExecutionContext; +}): Promise { + const { runId, agent, config, context, authToken } = input.ctx; + const workspaceContext = parseObject(context.paperclipWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const workspaceSource = asString(workspaceContext.source, ""); + const workspaceStrategy = asString(workspaceContext.strategy, ""); + const workspaceId = asString(workspaceContext.workspaceId, ""); + const workspaceRepoUrl = asString(workspaceContext.repoUrl, ""); + const workspaceRepoRef = asString(workspaceContext.repoRef, ""); + const workspaceBranch = asString(workspaceContext.branchName, ""); + const workspaceWorktreePath = asString(workspaceContext.worktreePath, ""); + const agentHome = asString(workspaceContext.agentHome, ""); + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + + const acpxAgent = normalizeAgent(config); + const mode = normalizeMode(config); + const permissionMode = normalizePermissionMode(config); + const nonInteractivePermissions = normalizeNonInteractivePermissions(config); + const timeoutSec = asNumber(config.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC); + const stateDir = path.resolve(asString(config.stateDir, "") || defaultStateDir(agent.companyId, agent.id)); + await fs.mkdir(stateDir, { recursive: true }); + + const envConfig = parseObject(config.env); + const hasExplicitApiKey = + typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; + const env: Record = { ...buildPaperclipEnv(agent), PAPERCLIP_RUN_ID: runId }; + const wakeTaskId = + (typeof context.taskId === "string" && context.taskId.trim()) || + (typeof context.issueId === "string" && context.issueId.trim()) || + ""; + const wakeReason = typeof context.wakeReason === "string" ? context.wakeReason.trim() : ""; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim()) || + ""; + const approvalId = typeof context.approvalId === "string" ? context.approvalId.trim() : ""; + const approvalStatus = typeof context.approvalStatus === "string" ? context.approvalStatus.trim() : ""; + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; + if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; + if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; + if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; + if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + applyPaperclipWorkspaceEnv(env, { + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceStrategy, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + workspaceBranch, + workspaceWorktreePath, + agentHome, + }); + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + if (!hasExplicitApiKey && authToken) env.PAPERCLIP_API_KEY = authToken; + + let skillPromptInstructions = ""; + let skillsIdentity: Record = { mode: "unsupported" }; + const skillCommandNotes: string[] = []; + if (acpxAgent === "claude") { + const preparedSkills = await prepareClaudeSkillRuntime({ + stateDir, + config, + onLog: input.ctx.onLog, + }); + skillPromptInstructions = preparedSkills.promptInstructions; + skillsIdentity = preparedSkills.identity; + skillCommandNotes.push(...preparedSkills.commandNotes); + } else if (acpxAgent === "codex") { + const preparedSkills = await prepareCodexSkillRuntime({ + companyId: agent.companyId, + config, + env, + onLog: input.ctx.onLog, + }); + skillsIdentity = preparedSkills.identity; + skillCommandNotes.push(...preparedSkills.commandNotes); + } else { + const desired = resolvePaperclipDesiredSkillNames(config, await readPaperclipRuntimeSkillEntries(config, __moduleDir)); + skillsIdentity = { mode: "custom_unsupported", desiredSkillNames: desired }; + if (desired.length > 0) { + skillCommandNotes.push("Selected Paperclip skills are tracked only; ACPX custom commands do not expose a runtime skill contract yet."); + } + } + + const configuredCommand = asString(config.agentCommand, "").trim(); + const builtInCommand = resolveBuiltInAgentCommand(acpxAgent); + const agentCommand = configuredCommand || builtInCommand || null; + const agentCommandShell = configuredCommand || (builtInCommand ? shellQuote(builtInCommand) : ""); + const wrapper = agentCommand + ? await writeAgentWrapper({ + stateDir, + acpxAgent, + agentCommandShell, + env, + }) + : null; + const wrapperPath = wrapper?.wrapperPath ?? null; + const overrides = wrapperPath ? { [acpxAgent]: wrapperPath } : undefined; + const agentRegistry = createAgentRegistry({ overrides }); + const executionTarget = readAdapterExecutionTarget({ + executionTarget: input.ctx.executionTarget, + legacyRemoteExecution: input.ctx.executionTransport?.remoteExecution, + }); + const remoteExecutionIdentity = adapterExecutionTargetSessionIdentity(executionTarget); + const fingerprint = shortHash({ + acpxAgent, + agentCommand: agentCommand ?? acpxAgent, + cwd: path.resolve(cwd), + mode, + permissionMode, + nonInteractivePermissions, + remoteExecutionIdentity, + skillsIdentity, + skillPromptInstructions, + }); + const taskKey = asString(input.ctx.runtime.taskKey, "") || wakeTaskId || workspaceId || "default"; + const sessionKey = `paperclip:${agent.companyId}:${agent.id}:${taskKey}:${fingerprint}`; + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + const loggedEnv = buildInvocationEnvForLogs(env, { + runtimeEnv, + includeRuntimeKeys: ["HOME"], + resolvedCommand: wrapperPath ?? agentCommand ?? acpxAgent, + }); + + return { + acpxAgent, + mode, + cwd, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + env, + loggedEnv, + stateDir, + permissionMode, + nonInteractivePermissions, + timeoutSec, + sessionKey, + fingerprint, + agentCommand, + agentRegistry, + remoteExecutionIdentity, + skillPromptInstructions, + skillsIdentity: { + ...skillsIdentity, + commandNotes: skillCommandNotes, + }, + }; +} + +async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean): Promise<{ + prompt: string; + promptMetrics: Record; + commandNotes: string[]; +}> { + const { agent, runId, config, context, onLog } = ctx; + const promptTemplate = asString(config.promptTemplate, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE); + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + let instructionsPrefix = ""; + const commandNotes: string[] = []; + if (instructionsFilePath) { + try { + const instructionsContents = await fs.readFile(instructionsFilePath, "utf8"); + instructionsPrefix = + `${instructionsContents}\n\n` + + `The above agent instructions were loaded from ${instructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsDir}.\n\n`; + commandNotes.push( + `Loaded agent instructions from ${instructionsFilePath}`, + `Prepended instructions + path directive to the ACPX prompt (relative references from ${instructionsDir}).`, + ); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await onLog( + "stderr", + `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, + ); + commandNotes.push(`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read.`); + } + } + + const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); + const templateData = { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }; + const renderedBootstrapPrompt = + !resumedSession && bootstrapPromptTemplate.trim().length > 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession }); + const shouldUseResumeDeltaPrompt = resumedSession && wakePrompt.length > 0; + const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix; + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const taskContextNote = asString(context.paperclipTaskMarkdown, "").trim(); + const prompt = joinPromptSections([ + promptInstructionsPrefix, + renderedBootstrapPrompt, + wakePrompt, + sessionHandoffNote, + taskContextNote, + renderedPrompt, + ]); + + return { + prompt, + commandNotes, + promptMetrics: { + promptChars: prompt.length, + instructionsChars: promptInstructionsPrefix.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + wakePromptChars: wakePrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + taskContextChars: taskContextNote.length, + heartbeatPromptChars: renderedPrompt.length, + }, + }; +} + +async function emitAcpxLog(ctx: AdapterExecutionContext, payload: Record) { + await ctx.onLog("stdout", `${JSON.stringify(payload)}\n`); +} + +async function emitRuntimeEvent(ctx: AdapterExecutionContext, event: AcpRuntimeEvent) { + if (event.type === "text_delta") { + await emitAcpxLog(ctx, { + type: "acpx.text_delta", + text: event.text, + channel: event.stream === "thought" ? "thought" : "output", + tag: event.tag, + }); + return; + } + if (event.type === "tool_call") { + await emitAcpxLog(ctx, { + type: "acpx.tool_call", + name: event.title ?? "acp_tool", + toolCallId: event.toolCallId, + status: event.status, + text: event.text, + tag: event.tag, + }); + return; + } + if (event.type === "status") { + await emitAcpxLog(ctx, { + type: "acpx.status", + text: event.text, + tag: event.tag, + used: event.used, + size: event.size, + }); + return; + } + if (event.type === "done") { + await emitAcpxLog(ctx, { + type: "acpx.result", + summary: event.stopReason ?? "completed", + stopReason: event.stopReason, + }); + return; + } + if (event.type === "error") { + await emitAcpxLog(ctx, { + type: "acpx.error", + message: event.message, + code: event.code, + retryable: event.retryable, + }); + } +} + +function resultErrorMessage(result: AcpRuntimeTurnResult): string | null { + if (result.status !== "failed") return null; + return result.error.message; +} + +function classifyError(err: unknown): Pick { + const message = err instanceof Error ? err.message : String(err); + const maybeCode = + err && typeof err === "object" && typeof (err as { code?: unknown }).code === "string" + ? (err as { code: string }).code + : null; + const acpCode = isAcpRuntimeError(err) || (maybeCode?.startsWith("ACP_") ?? false) ? maybeCode : null; + const lower = message.toLowerCase(); + const authLike = lower.includes("auth") || lower.includes("login") || lower.includes("credential"); + if (authLike) { + return { + errorCode: "acpx_auth_required", + errorMeta: { category: "auth", ...(acpCode ? { acpCode } : {}) }, + }; + } + if (acpCode) { + return { + errorCode: "acpx_protocol_error", + errorMeta: { category: "protocol", acpCode }, + }; + } + return { + errorCode: "acpx_runtime_error", + errorMeta: { category: "runtime" }, + }; +} + +function isResumeFailure(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return /resume|load|not found|no session|unknown session|conversation/i.test(message); +} + +async function cleanupIdleHandles(input: { + handles: Map; + now: number; + idleMs: number; +}) { + const stale: Array<[string, RuntimeCacheEntry]> = []; + for (const entry of input.handles.entries()) { + if (input.now - entry[1].lastUsedAt >= input.idleMs) stale.push(entry); + } + for (const [key, entry] of stale) { + input.handles.delete(key); + await entry.runtime.close({ + handle: entry.handle, + reason: "paperclip idle cleanup", + discardPersistentState: false, + }).catch(() => {}); + } +} + +function warmHandleMatches( + entry: RuntimeCacheEntry | undefined, + runtime: AcpRuntime, + handle: AcpRuntimeHandle, +): boolean { + return entry?.runtime === runtime && entry.handle === handle; +} + +export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { + const createRuntime = deps.createRuntime ?? createAcpRuntime; + const now = deps.now ?? (() => Date.now()); + const warmHandles = deps.warmHandles ?? defaultWarmHandles; + + return async function executeAcpxLocal(ctx: AdapterExecutionContext): Promise { + const prepared = await buildRuntime({ ctx }); + const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_WARM_HANDLE_IDLE_MS); + await cleanupIdleHandles({ handles: warmHandles, now: now(), idleMs: warmIdleMs }); + + const previousParams = parseObject(ctx.runtime.sessionParams); + const canResume = isCompatibleSession(previousParams, prepared); + const resumeSessionId = canResume ? asString(previousParams.acpSessionId, "") || undefined : undefined; + const cached = canResume ? warmHandles.get(prepared.sessionKey) : undefined; + const runtimeOptions: AcpRuntimeOptions = { + cwd: prepared.cwd, + sessionStore: createRuntimeStore({ stateDir: prepared.stateDir }), + agentRegistry: prepared.agentRegistry, + permissionMode: prepared.permissionMode, + nonInteractivePermissions: prepared.nonInteractivePermissions, + timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined, + }; + const runtime = cached?.runtime ?? createRuntime(runtimeOptions); + if (!canResume && asString(previousParams.runtimeSessionName, "")) { + await ctx.onLog( + "stdout", + `[paperclip] ACPX session "${asString(previousParams.runtimeSessionName, "")}" does not match the current agent/cwd/mode/runtime identity; starting fresh in "${prepared.cwd}".\n`, + ); + } + + let handle = cached?.handle ?? null; + let resumedSession = Boolean(handle ?? resumeSessionId); + let clearSession = false; + + try { + if (!handle) { + try { + handle = await runtime.ensureSession({ + sessionKey: prepared.sessionKey, + agent: prepared.acpxAgent, + mode: prepared.mode, + cwd: prepared.cwd, + resumeSessionId, + }); + } catch (err) { + if (!resumeSessionId || !isResumeFailure(err)) throw err; + clearSession = true; + resumedSession = false; + await ctx.onLog( + "stdout", + `[paperclip] ACPX resume session "${resumeSessionId}" is unavailable; retrying with a fresh session.\n`, + ); + handle = await runtime.ensureSession({ + sessionKey: prepared.sessionKey, + agent: prepared.acpxAgent, + mode: prepared.mode, + cwd: prepared.cwd, + }); + } + } + } catch (err) { + const classified = classifyError(err); + const message = err instanceof Error ? err.message : String(err); + await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta }); + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: message, + ...classified, + provider: "acpx", + model: null, + clearSession, + resultJson: { phase: "ensure_session" }, + summary: message, + }; + } + + if (!handle) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: "ACPX did not return a runtime session handle.", + errorCode: "acpx_runtime_error", + provider: "acpx", + model: null, + resultJson: { phase: "ensure_session" }, + summary: "ACPX did not return a runtime session handle.", + }; + } + const sessionHandle = handle; + const { prompt, promptMetrics, commandNotes } = await buildPrompt(ctx, resumedSession); + const runPrompt = joinPromptSections([prepared.skillPromptInstructions, prompt]); + await emitAcpxLog(ctx, { + type: "acpx.session", + agent: prepared.acpxAgent, + sessionId: sessionHandle.backendSessionId, + acpSessionId: sessionHandle.backendSessionId, + agentSessionId: sessionHandle.agentSessionId, + runtimeSessionName: sessionHandle.runtimeSessionName, + mode: prepared.mode, + permissionMode: prepared.permissionMode, + }); + if (ctx.onMeta) { + await ctx.onMeta({ + adapterType: "acpx_local", + command: prepared.agentCommand ?? prepared.acpxAgent, + cwd: prepared.cwd, + commandNotes: [ + `ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`, + `Effective ACPX permission mode: ${prepared.permissionMode}.`, + ...(Array.isArray(prepared.skillsIdentity.commandNotes) + ? prepared.skillsIdentity.commandNotes.filter((note): note is string => typeof note === "string") + : []), + ...commandNotes, + ], + env: prepared.loggedEnv, + prompt: runPrompt, + promptMetrics, + context: ctx.context, + }); + } + + let cancelActiveTurn: ((reason: string) => Promise) | null = null; + let controller: AbortController | null = null; + let timeout: NodeJS.Timeout | null = null; + let timedOut = false; + const textParts: string[] = []; + try { + const timeoutMs = prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined; + controller = new AbortController(); + if (timeoutMs) { + timeout = setTimeout(() => { + timedOut = true; + controller?.abort(); + void cancelActiveTurn?.(`Timed out after ${prepared.timeoutSec}s`).catch(() => {}); + }, timeoutMs); + } + const turn = runtime.startTurn({ + handle: sessionHandle, + text: runPrompt, + mode: "prompt", + requestId: ctx.runId, + timeoutMs, + signal: controller?.signal, + }); + cancelActiveTurn = async (reason: string) => { + await turn.cancel({ reason }); + }; + for await (const event of turn.events) { + if (event.type === "text_delta") textParts.push(event.text); + await emitRuntimeEvent(ctx, event); + } + const terminal = await turn.result; + if (timeout) clearTimeout(timeout); + if (terminal.status === "failed" || terminal.status === "cancelled" || timedOut) { + if (warmHandleMatches(warmHandles.get(prepared.sessionKey), runtime, sessionHandle)) { + warmHandles.delete(prepared.sessionKey); + } + await runtime.close({ + handle: sessionHandle, + reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`, + discardPersistentState: terminal.status === "cancelled" || timedOut, + }).catch(() => {}); + } else if (prepared.mode === "persistent") { + const existing = warmHandles.get(prepared.sessionKey); + if (existing && !warmHandleMatches(existing, runtime, sessionHandle)) { + await runtime.close({ + handle: sessionHandle, + reason: "paperclip duplicate warm handle cleanup", + discardPersistentState: false, + }).catch(() => {}); + } else { + warmHandles.set(prepared.sessionKey, { + runtime, + handle: sessionHandle, + fingerprint: prepared.fingerprint, + lastUsedAt: now(), + }); + } + } + + const errorMessage = timedOut + ? `Timed out after ${prepared.timeoutSec}s` + : resultErrorMessage(terminal); + const terminalStopReason = terminal.status === "failed" ? terminal.error.message : terminal.stopReason; + await emitAcpxLog(ctx, { + type: terminal.status === "completed" ? "acpx.result" : "acpx.error", + summary: terminal.status, + stopReason: terminalStopReason, + message: errorMessage, + }); + return { + exitCode: terminal.status === "completed" ? 0 : 1, + signal: timedOut ? "SIGTERM" : null, + timedOut, + errorMessage, + errorCode: terminal.status === "failed" ? "acpx_turn_failed" : timedOut ? "acpx_timeout" : null, + sessionId: sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName, + sessionParams: buildSessionParams({ prepared, handle: sessionHandle }), + sessionDisplayId: sessionHandle.agentSessionId ?? sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName, + provider: "acpx", + model: null, + billingType: "unknown", + costUsd: null, + resultJson: { + status: terminal.status, + stopReason: terminalStopReason, + permissionMode: prepared.permissionMode, + mode: prepared.mode, + }, + summary: textParts.join("").trim() || terminalStopReason || terminal.status, + clearSession, + }; + } catch (err) { + if (timeout) clearTimeout(timeout); + const classified = classifyError(err); + const message = timedOut ? `Timed out after ${prepared.timeoutSec}s` : err instanceof Error ? err.message : String(err); + const cancel = cancelActiveTurn as ((reason: string) => Promise) | null; + if (cancel) await cancel(message).catch(() => {}); + await runtime.close({ + handle: sessionHandle, + reason: timedOut ? "paperclip timeout cleanup" : "paperclip error cleanup", + discardPersistentState: timedOut, + }).catch(() => {}); + if (warmHandleMatches(warmHandles.get(prepared.sessionKey), runtime, sessionHandle)) { + warmHandles.delete(prepared.sessionKey); + } + await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta }); + return { + exitCode: 1, + signal: timedOut ? "SIGTERM" : null, + timedOut, + errorMessage: message, + errorCode: timedOut ? "acpx_timeout" : classified.errorCode, + errorMeta: classified.errorMeta, + provider: "acpx", + model: null, + clearSession: clearSession || timedOut, + resultJson: { phase: "turn" }, + summary: message, + }; + } + }; +} + +export const execute = createAcpxLocalExecutor(); diff --git a/packages/adapters/acpx-local/src/server/index.ts b/packages/adapters/acpx-local/src/server/index.ts new file mode 100644 index 00000000..7463c952 --- /dev/null +++ b/packages/adapters/acpx-local/src/server/index.ts @@ -0,0 +1,5 @@ +export { execute, createAcpxLocalExecutor } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { getConfigSchema } from "./config-schema.js"; +export { sessionCodec } from "./session-codec.js"; +export { listAcpxSkills, syncAcpxSkills } from "./skills.js"; diff --git a/packages/adapters/acpx-local/src/server/session-codec.ts b/packages/adapters/acpx-local/src/server/session-codec.ts new file mode 100644 index 00000000..2045adcd --- /dev/null +++ b/packages/adapters/acpx-local/src/server/session-codec.ts @@ -0,0 +1,50 @@ +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? { ...(value as Record) } : null; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const runtimeSessionName = readString(record.runtimeSessionName); + const acpSessionId = readString(record.acpSessionId); + const agentSessionId = readString(record.agentSessionId); + const remoteExecution = readRecord(record.remoteExecution); + if (!runtimeSessionName && !acpSessionId && !agentSessionId) return null; + + return { + ...(runtimeSessionName ? { runtimeSessionName } : {}), + ...(readString(record.sessionKey) ? { sessionKey: readString(record.sessionKey) } : {}), + ...(readString(record.acpxRecordId) ? { acpxRecordId: readString(record.acpxRecordId) } : {}), + ...(acpSessionId ? { acpSessionId } : {}), + ...(agentSessionId ? { agentSessionId } : {}), + ...(readString(record.agent) ? { agent: readString(record.agent) } : {}), + ...(readString(record.cwd) ? { cwd: readString(record.cwd) } : {}), + ...(readString(record.mode) ? { mode: readString(record.mode) } : {}), + ...(readString(record.stateDir) ? { stateDir: readString(record.stateDir) } : {}), + ...(readString(record.configFingerprint) ? { configFingerprint: readString(record.configFingerprint) } : {}), + ...(readString(record.workspaceId) ? { workspaceId: readString(record.workspaceId) } : {}), + ...(readString(record.repoUrl) ? { repoUrl: readString(record.repoUrl) } : {}), + ...(readString(record.repoRef) ? { repoRef: readString(record.repoRef) } : {}), + ...(remoteExecution ? { remoteExecution } : {}), + }; + }, + serialize(params: Record | null) { + if (!params) return null; + return this.deserialize(params); + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return ( + readString(params.runtimeSessionName) ?? + readString(params.acpSessionId) ?? + readString(params.agentSessionId) + ); + }, +}; diff --git a/packages/adapters/acpx-local/src/server/skills.ts b/packages/adapters/acpx-local/src/server/skills.ts new file mode 100644 index 00000000..16065b36 --- /dev/null +++ b/packages/adapters/acpx-local/src/server/skills.ts @@ -0,0 +1,106 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterSkillContext, + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, +} from "@paperclipai/adapter-utils/server-utils"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +type AcpxSkillAgent = "claude" | "codex" | "custom"; + +function normalizeAcpxSkillAgent(config: Record): AcpxSkillAgent { + const configured = typeof config.agent === "string" ? config.agent.trim() : ""; + if (configured === "codex" || configured === "custom") return configured; + if (configured === "claude" || configured === "") return "claude"; + return "claude"; +} + +function configuredDetail(agent: AcpxSkillAgent): string { + if (agent === "codex") { + return "Will be linked into the effective CODEX_HOME/skills/ directory for the next ACPX Codex session."; + } + return "Will be mounted into the next ACPX Claude session."; +} + +function unsupportedDetail(): string { + return "Desired state is stored in Paperclip only; custom ACP commands need an explicit skill integration contract before runtime sync is available."; +} + +async function buildAcpxSkillSnapshot(config: Record): Promise { + const acpxAgent = normalizeAcpxSkillAgent(config); + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSet = new Set(desiredSkills); + const supported = acpxAgent !== "custom"; + const warnings: string[] = supported + ? [] + : [ + "Custom ACP commands do not expose a Paperclip skill integration contract yet; selected skills are tracked only.", + ]; + + const entries: AdapterSkillEntry[] = availableEntries.map((entry) => { + const desired = desiredSet.has(entry.key); + return { + key: entry.key, + runtimeName: entry.runtimeName, + desired, + managed: true, + state: desired ? "configured" : "available", + origin: entry.required ? "paperclip_required" : "company_managed", + originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip", + readOnly: false, + sourcePath: entry.source, + targetPath: null, + detail: desired ? (supported ? configuredDetail(acpxAgent) : unsupportedDetail()) : null, + required: Boolean(entry.required), + requiredReason: entry.requiredReason ?? null, + }; + }); + + for (const desiredSkill of desiredSkills) { + if (availableByKey.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + key: desiredSkill, + runtimeName: null, + desired: true, + managed: true, + state: "missing", + origin: "external_unknown", + originLabel: "External or unavailable", + readOnly: false, + sourcePath: null, + targetPath: null, + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + }); + } + + entries.sort((left, right) => left.key.localeCompare(right.key)); + + return { + adapterType: "acpx_local", + supported, + mode: supported ? "ephemeral" : "unsupported", + desiredSkills, + entries, + warnings, + }; +} + +export async function listAcpxSkills(ctx: AdapterSkillContext): Promise { + return buildAcpxSkillSnapshot(ctx.config); +} + +export async function syncAcpxSkills( + ctx: AdapterSkillContext, + _desiredSkills: string[], +): Promise { + return buildAcpxSkillSnapshot(ctx.config); +} diff --git a/packages/adapters/acpx-local/src/server/test.test.ts b/packages/adapters/acpx-local/src/server/test.test.ts new file mode 100644 index 00000000..f5744f54 --- /dev/null +++ b/packages/adapters/acpx-local/src/server/test.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { testEnvironment } from "./test.js"; + +const originalNodeVersion = process.version; + +function setNodeVersion(version: string): void { + Object.defineProperty(process, "version", { + configurable: true, + enumerable: true, + value: version, + }); +} + +afterEach(() => { + setNodeVersion(originalNodeVersion); +}); + +describe("acpx_local environment diagnostics", () => { + it("does not force healthy default Claude diagnostics to warn", async () => { + setNodeVersion("v22.12.0"); + + const result = await testEnvironment({ + adapterType: "acpx_local", + companyId: "test-company", + config: { agent: "claude" }, + }); + + expect(result.status).toBe("pass"); + expect(result.checks).toContainEqual( + expect.objectContaining({ + code: "acpx_agent_selected", + level: "info", + message: "ACP agent selected: claude", + }), + ); + expect(result.checks).toContainEqual( + expect.objectContaining({ + code: "acpx_runtime_scaffold", + level: "info", + }), + ); + expect(result.checks).not.toContainEqual( + expect.objectContaining({ + code: "acpx_runtime_scaffold", + level: "warn", + }), + ); + }); +}); diff --git a/packages/adapters/acpx-local/src/server/test.ts b/packages/adapters/acpx-local/src/server/test.ts new file mode 100644 index 00000000..f19304e8 --- /dev/null +++ b/packages/adapters/acpx-local/src/server/test.ts @@ -0,0 +1,295 @@ +import { createRequire } from "node:module"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { + asString, + parseObject, +} from "@paperclipai/adapter-utils/server-utils"; + +const require = createRequire(import.meta.url); +const MIN_NODE_MAJOR = 22; +const MIN_NODE_MINOR = 12; +const MIN_NODE_PATCH = 0; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function nodeVersionMeetsMinimum(version: string): boolean { + const [major = 0, minor = 0, patch = 0] = version + .replace(/^v/, "") + .split(".") + .map((part) => Number.parseInt(part, 10)); + if (major > MIN_NODE_MAJOR) return true; + if (major < MIN_NODE_MAJOR) return false; + if (minor > MIN_NODE_MINOR) return true; + if (minor < MIN_NODE_MINOR) return false; + return patch >= MIN_NODE_PATCH; +} + +function isNonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function getStringEnv(configEnv: Record, key: string): string | undefined { + const configured = configEnv[key]; + if (typeof configured === "string") return configured; + return process.env[key]; +} + +function credentialSource(configEnv: Record, key: string): string { + return typeof configEnv[key] === "string" ? "adapter config env" : "server environment"; +} + +async function readJsonObject(filePath: string): Promise | null> { + try { + const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown; + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? parsed as Record + : null; + } catch { + return null; + } +} + +function readNestedString(record: Record, pathSegments: string[]): string | null { + let current: unknown = record; + for (const segment of pathSegments) { + if (typeof current !== "object" || current === null || Array.isArray(current)) return null; + current = (current as Record)[segment]; + } + return isNonEmpty(current) ? current.trim() : null; +} + +async function hasClaudeSubscriptionCredentials(configDir: string): Promise { + for (const filename of [".credentials.json", "credentials.json"]) { + const credentials = await readJsonObject(path.join(configDir, filename)); + if (!credentials) continue; + if (readNestedString(credentials, ["claudeAiOauth", "accessToken"])) return true; + } + return false; +} + +async function hasCodexNativeCredentials(codexHome: string): Promise { + const auth = await readJsonObject(path.join(codexHome, "auth.json")); + if (!auth) return false; + return Boolean( + readNestedString(auth, ["accessToken"]) || + readNestedString(auth, ["tokens", "access_token"]) || + readNestedString(auth, ["OPENAI_API_KEY"]), + ); +} + +async function buildCredentialHintChecks( + agent: string, + configEnv: Record, +): Promise { + if (agent === "claude") { + const bedrockFlag = getStringEnv(configEnv, "CLAUDE_CODE_USE_BEDROCK"); + const bedrockBaseUrl = getStringEnv(configEnv, "ANTHROPIC_BEDROCK_BASE_URL"); + const hasBedrock = + bedrockFlag === "1" || + /^true$/i.test(bedrockFlag ?? "") || + isNonEmpty(bedrockBaseUrl); + const bedrockSourceKey = isNonEmpty(bedrockFlag) + ? "CLAUDE_CODE_USE_BEDROCK" + : "ANTHROPIC_BEDROCK_BASE_URL"; + const anthropicApiKey = getStringEnv(configEnv, "ANTHROPIC_API_KEY"); + const claudeConfigDir = isNonEmpty(getStringEnv(configEnv, "CLAUDE_CONFIG_DIR")) + ? path.resolve(getStringEnv(configEnv, "CLAUDE_CONFIG_DIR") as string) + : path.join(os.homedir(), ".claude"); + + if (hasBedrock) { + return [{ + code: "acpx_claude_bedrock_auth_detected", + level: "info", + message: "Claude credential hint: Bedrock auth indicators are configured.", + detail: `Detected in ${credentialSource(configEnv, bedrockSourceKey)}.`, + hint: "Ensure AWS credentials and AWS_REGION are available to the ACPX-launched Claude agent.", + }]; + } + + if (isNonEmpty(anthropicApiKey)) { + return [{ + code: "acpx_claude_anthropic_api_key_detected", + level: "info", + message: "Claude credential hint: ANTHROPIC_API_KEY is set.", + detail: `Detected in ${credentialSource(configEnv, "ANTHROPIC_API_KEY")}.`, + }]; + } + + if (await hasClaudeSubscriptionCredentials(claudeConfigDir)) { + return [{ + code: "acpx_claude_subscription_auth_detected", + level: "info", + message: "Claude credential hint: local Claude subscription credentials were found.", + detail: `Credentials found in ${claudeConfigDir}.`, + }]; + } + + return [{ + code: "acpx_claude_credentials_missing", + level: "info", + message: "Claude credential hint: no Claude API, Bedrock, or local subscription credentials were detected.", + hint: "Set ANTHROPIC_API_KEY, configure Bedrock, or run `claude login` before starting an ACPX Claude agent.", + }]; + } + + if (agent === "codex") { + const openAiApiKey = getStringEnv(configEnv, "OPENAI_API_KEY"); + const codexHome = isNonEmpty(getStringEnv(configEnv, "CODEX_HOME")) + ? path.resolve(getStringEnv(configEnv, "CODEX_HOME") as string) + : path.join(os.homedir(), ".codex"); + + if (isNonEmpty(openAiApiKey)) { + return [{ + code: "acpx_codex_openai_api_key_detected", + level: "info", + message: "Codex credential hint: OPENAI_API_KEY is set.", + detail: `Detected in ${credentialSource(configEnv, "OPENAI_API_KEY")}.`, + }]; + } + + if (await hasCodexNativeCredentials(codexHome)) { + return [{ + code: "acpx_codex_native_auth_detected", + level: "info", + message: "Codex credential hint: local Codex auth configuration was found.", + detail: `Credentials found in ${path.join(codexHome, "auth.json")}.`, + }]; + } + + return [{ + code: "acpx_codex_credentials_missing", + level: "info", + message: "Codex credential hint: no OpenAI API key or local Codex auth configuration was detected.", + hint: "Set OPENAI_API_KEY or run `codex login` before starting an ACPX Codex agent.", + }]; + } + + return []; +} + +function resolvePackage(name: string): AdapterEnvironmentCheck { + try { + const resolved = require.resolve(`${name}/package.json`); + return { + code: `acpx_package_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_present`, + level: "info", + message: `${name} is resolvable.`, + detail: resolved, + }; + } catch { + return { + code: `acpx_package_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_missing`, + level: "error", + message: `${name} is not resolvable from the acpx_local adapter package.`, + hint: "Run pnpm install so the ACPX adapter dependencies are installed.", + }; + } +} + +async function checkDirectory(pathValue: string, code: string, label: string): Promise { + const dir = pathValue.trim(); + if (!dir) return null; + try { + await fs.mkdir(dir, { recursive: true }); + await fs.access(dir); + return { + code, + level: "info", + message: `${label} is writable: ${dir}`, + }; + } catch (err) { + return { + code: `${code}_invalid`, + level: "error", + message: err instanceof Error ? err.message : `${label} is not writable.`, + detail: dir, + }; + } +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const config = parseObject(ctx.config); + const envConfig = parseObject(config.env); + const configEnv: Record = {}; + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") configEnv[key] = value; + } + const checks: AdapterEnvironmentCheck[] = []; + const nodeVersion = process.version; + + checks.push({ + code: nodeVersionMeetsMinimum(nodeVersion) ? "acpx_node_supported" : "acpx_node_unsupported", + level: nodeVersionMeetsMinimum(nodeVersion) ? "info" : "error", + message: nodeVersionMeetsMinimum(nodeVersion) + ? `Node ${nodeVersion} satisfies ACPX's >=22.12.0 requirement.` + : `Node ${nodeVersion} does not satisfy ACPX's >=22.12.0 requirement.`, + hint: nodeVersionMeetsMinimum(nodeVersion) + ? undefined + : "Run acpx_local agents with Node >=22.12.0 or use claude_local/codex_local on Node 20.", + }); + + checks.push(resolvePackage("acpx")); + checks.push(resolvePackage("@agentclientprotocol/claude-agent-acp")); + checks.push(resolvePackage("@zed-industries/codex-acp")); + + const agent = asString(config.agent, "claude"); + if (!["claude", "codex", "custom"].includes(agent)) { + checks.push({ + code: "acpx_agent_invalid", + level: "error", + message: `Unsupported ACP agent: ${agent}`, + hint: "Use agent=claude, agent=codex, or agent=custom.", + }); + } else { + checks.push({ + code: "acpx_agent_selected", + level: "info", + message: `ACP agent selected: ${agent}`, + }); + checks.push(...await buildCredentialHintChecks(agent, configEnv)); + } + + if (agent === "custom" && !asString(config.agentCommand, "")) { + checks.push({ + code: "acpx_custom_command_missing", + level: "error", + message: "agentCommand is required when agent=custom.", + }); + } + + const stateDirCheck = await checkDirectory(asString(config.stateDir, ""), "acpx_state_dir_writable", "ACPX state directory"); + if (stateDirCheck) checks.push(stateDirCheck); + + const permissionMode = asString(config.permissionMode, "approve-all"); + checks.push({ + code: "acpx_permission_mode", + level: "info", + message: `Effective permission mode: ${permissionMode || "approve-all"}`, + }); + + checks.push({ + code: "acpx_runtime_scaffold", + level: "info", + message: "acpx_local runtime execution is available through the bundled ACPX runtime.", + }); + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/acpx-local/src/ui/build-config.ts b/packages/adapters/acpx-local/src/ui/build-config.ts new file mode 100644 index 00000000..445686dc --- /dev/null +++ b/packages/adapters/acpx-local/src/ui/build-config.ts @@ -0,0 +1,139 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { + DEFAULT_ACPX_LOCAL_AGENT, + DEFAULT_ACPX_LOCAL_MODE, + DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS, + DEFAULT_ACPX_LOCAL_PERMISSION_MODE, + DEFAULT_ACPX_LOCAL_TIMEOUT_SEC, +} from "../index.js"; + +function parseCommaArgs(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseEnvVars(text: string): Record { + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + env[key] = value; + } + return env; +} + +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + +function parseJsonObject(text: string): Record | null { + const trimmed = text.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +} + +function readNumber(value: unknown, fallback: number): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +} + +export function buildAcpxLocalConfig(v: CreateConfigValues): Record { + const schemaValues = v.adapterSchemaValues ?? {}; + const ac: Record = { + agent: schemaValues.agent || DEFAULT_ACPX_LOCAL_AGENT, + mode: schemaValues.mode || DEFAULT_ACPX_LOCAL_MODE, + permissionMode: schemaValues.permissionMode || DEFAULT_ACPX_LOCAL_PERMISSION_MODE, + nonInteractivePermissions: + schemaValues.nonInteractivePermissions || DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS, + timeoutSec: readNumber(schemaValues.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC), + }; + + for (const key of [ + "agentCommand", + "cwd", + "stateDir", + "instructionsFilePath", + "promptTemplate", + "bootstrapPromptTemplate", + ]) { + const value = schemaValues[key]; + if (typeof value === "string" && value.trim()) ac[key] = value.trim(); + } + + if (!ac.cwd && v.cwd) ac.cwd = v.cwd; + if (!ac.instructionsFilePath && v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; + if (!ac.promptTemplate && v.promptTemplate) ac.promptTemplate = v.promptTemplate; + if (!ac.bootstrapPromptTemplate && v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt; + + const env = parseEnvBindings(v.envBindings); + const legacy = parseEnvVars(v.envVars); + for (const [key, value] of Object.entries(legacy)) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + env[key] = { type: "plain", value }; + } + } + if (typeof schemaValues.env === "string") { + const schemaEnv = parseJsonObject(schemaValues.env); + if (schemaEnv) Object.assign(env, schemaEnv); + } else if (typeof schemaValues.env === "object" && schemaValues.env !== null && !Array.isArray(schemaValues.env)) { + Object.assign(env, schemaValues.env as Record); + } + if (Object.keys(env).length > 0) ac.env = env; + + if (v.workspaceStrategyType === "git_worktree") { + ac.workspaceStrategy = { + type: "git_worktree", + ...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}), + ...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}), + ...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}), + }; + } + const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? ""); + if (runtimeServices && Array.isArray(runtimeServices.services)) { + ac.workspaceRuntime = runtimeServices; + } + if (v.command) ac.command = v.command; + if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); + return ac; +} diff --git a/packages/adapters/acpx-local/src/ui/index.ts b/packages/adapters/acpx-local/src/ui/index.ts new file mode 100644 index 00000000..629baaad --- /dev/null +++ b/packages/adapters/acpx-local/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseAcpxStdoutLine } from "./parse-stdout.js"; +export { buildAcpxLocalConfig } from "./build-config.js"; diff --git a/packages/adapters/acpx-local/src/ui/parse-stdout.test.ts b/packages/adapters/acpx-local/src/ui/parse-stdout.test.ts new file mode 100644 index 00000000..80fb2671 --- /dev/null +++ b/packages/adapters/acpx-local/src/ui/parse-stdout.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import { parseAcpxStdoutLine } from "./parse-stdout.js"; + +const TS = "2026-04-30T00:00:00.000Z"; + +function emit(payload: Record): string { + return JSON.stringify(payload); +} + +describe("parseAcpxStdoutLine", () => { + it("renders an init entry from acpx.session", () => { + const entries = parseAcpxStdoutLine( + emit({ + type: "acpx.session", + agent: "claude", + acpSessionId: "acp-1", + runtimeSessionName: "runtime-1", + mode: "persistent", + permissionMode: "approve-all", + }), + TS, + ); + expect(entries).toEqual([ + { + kind: "init", + ts: TS, + model: "claude (persistent / approve-all)", + sessionId: "acp-1", + }, + ]); + }); + + it("routes output text_delta to the assistant transcript", () => { + const entries = parseAcpxStdoutLine( + emit({ type: "acpx.text_delta", text: "hello", channel: "output", tag: "agent_message_chunk" }), + TS, + ); + expect(entries).toEqual([ + { kind: "assistant", ts: TS, text: "hello", delta: true }, + ]); + }); + + it("routes thought text_delta to the thinking transcript", () => { + const entries = parseAcpxStdoutLine( + emit({ type: "acpx.text_delta", text: "thinking…", channel: "thought" }), + TS, + ); + expect(entries).toEqual([ + { kind: "thinking", ts: TS, text: "thinking…", delta: true }, + ]); + }); + + it("falls back to stream when channel is missing", () => { + const entries = parseAcpxStdoutLine( + emit({ type: "acpx.text_delta", text: "thinking…", stream: "thought" }), + TS, + ); + expect(entries[0]).toMatchObject({ kind: "thinking" }); + }); + + it("renders status events as system text with optional ctx usage", () => { + expect( + parseAcpxStdoutLine( + emit({ type: "acpx.status", text: "thinking", tag: "agent_thought_chunk" }), + TS, + ), + ).toEqual([{ kind: "system", ts: TS, text: "thinking" }]); + + expect( + parseAcpxStdoutLine( + emit({ type: "acpx.status", tag: "context_window", used: 12000, size: 200000 }), + TS, + ), + ).toEqual([{ kind: "system", ts: TS, text: "context_window (12000/200000 ctx)" }]); + }); + + it("emits a tool_call entry that preserves toolCallId, status, and input", () => { + const entries = parseAcpxStdoutLine( + emit({ + type: "acpx.tool_call", + name: "read", + toolCallId: "tool-1", + status: "running", + text: "read README.md", + }), + TS, + ); + expect(entries).toEqual([ + { + kind: "tool_call", + ts: TS, + name: "read", + toolUseId: "tool-1", + input: { text: "read README.md", status: "running" }, + }, + ]); + }); + + it("emits a paired tool_result entry when a tool_call reports terminal status", () => { + const completed = parseAcpxStdoutLine( + emit({ + type: "acpx.tool_call", + name: "read", + toolCallId: "tool-1", + status: "completed", + text: "ok", + }), + TS, + ); + expect(completed[1]).toEqual({ + kind: "tool_result", + ts: TS, + toolUseId: "tool-1", + toolName: "read", + content: "ok", + isError: false, + }); + + const failed = parseAcpxStdoutLine( + emit({ + type: "acpx.tool_call", + name: "edit", + toolCallId: "tool-2", + status: "failed", + text: "permission denied", + }), + TS, + ); + expect(failed[1]).toMatchObject({ kind: "tool_result", isError: true, content: "permission denied" }); + }); + + it("renders acpx.result with summary fallback to stopReason", () => { + const entries = parseAcpxStdoutLine( + emit({ type: "acpx.result", summary: "completed", stopReason: "end_turn" }), + TS, + ); + expect(entries[0]).toMatchObject({ kind: "result", text: "completed", subtype: "end_turn", isError: false }); + }); + + it("treats acpx.error as a stderr entry", () => { + const entries = parseAcpxStdoutLine( + emit({ type: "acpx.error", message: "auth required", code: "ACP_AUTH" }), + TS, + ); + expect(entries).toEqual([{ kind: "stderr", ts: TS, text: "auth required" }]); + }); + + it("renders unknown acpx.* events as system entries", () => { + const entries = parseAcpxStdoutLine( + emit({ type: "acpx.misc", message: "unhandled" }), + TS, + ); + expect(entries).toEqual([{ kind: "system", ts: TS, text: "unhandled" }]); + }); + + it("falls back to a stdout entry for non-JSON lines", () => { + const entries = parseAcpxStdoutLine("not json", TS); + expect(entries).toEqual([{ kind: "stdout", ts: TS, text: "not json" }]); + }); +}); diff --git a/packages/adapters/acpx-local/src/ui/parse-stdout.ts b/packages/adapters/acpx-local/src/ui/parse-stdout.ts new file mode 100644 index 00000000..019e8f33 --- /dev/null +++ b/packages/adapters/acpx-local/src/ui/parse-stdout.ts @@ -0,0 +1,158 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +function parseJson(line: string): Record | null { + try { + const parsed = JSON.parse(line); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as Record; + } catch { + return null; + } +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function stringify(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function pickToolUseId(parsed: Record): string { + return ( + asString(parsed.toolCallId) || + asString(parsed.toolUseId) || + asString(parsed.id) + ); +} + +function statusText(parsed: Record): string { + const text = asString(parsed.text).trim(); + const tag = asString(parsed.tag).trim(); + const used = asNumber(parsed.used, -1); + const size = asNumber(parsed.size, -1); + const parts: string[] = []; + if (text) parts.push(text); + if (tag && !text) parts.push(tag); + if (used >= 0 && size > 0) parts.push(`(${used}/${size} ctx)`); + return parts.join(" ") || tag || "status"; +} + +export function parseAcpxStdoutLine(line: string, ts: string): TranscriptEntry[] { + const parsed = parseJson(line); + if (!parsed) return [{ kind: "stdout", ts, text: line }]; + + const type = asString(parsed.type); + if (type === "acpx.session") { + const agent = asString(parsed.agent, "acpx"); + const mode = asString(parsed.mode); + const permissionMode = asString(parsed.permissionMode); + const tail = [mode, permissionMode].filter(Boolean).join(" / "); + return [{ + kind: "init", + ts, + model: tail ? `${agent} (${tail})` : agent, + sessionId: + asString(parsed.acpSessionId) || + asString(parsed.sessionId) || + asString(parsed.runtimeSessionName), + }]; + } + + if (type === "acpx.text_delta") { + const text = asString(parsed.text); + if (!text) return []; + const channel = asString(parsed.channel) || asString(parsed.stream); + return [{ + kind: channel === "thought" || channel === "thinking" ? "thinking" : "assistant", + ts, + text, + delta: true, + }]; + } + + if (type === "acpx.tool_call") { + const status = asString(parsed.status); + const text = asString(parsed.text); + const name = asString(parsed.name, "acp_tool"); + const toolUseId = pickToolUseId(parsed); + const input = + parsed.input !== undefined + ? parsed.input + : text || status + ? { ...(text ? { text } : {}), ...(status ? { status } : {}) } + : {}; + const entries: TranscriptEntry[] = [ + { + kind: "tool_call", + ts, + name, + toolUseId: toolUseId || undefined, + input, + }, + ]; + if (status === "completed" || status === "failed" || status === "cancelled") { + entries.push({ + kind: "tool_result", + ts, + toolUseId: toolUseId || name, + toolName: name, + content: text || status, + isError: status !== "completed", + }); + } + return entries; + } + + if (type === "acpx.tool_result") { + return [{ + kind: "tool_result", + ts, + toolUseId: pickToolUseId(parsed) || asString(parsed.name, "acp_tool"), + toolName: asString(parsed.name) || undefined, + content: stringify(parsed.content ?? parsed.output ?? parsed.error), + isError: parsed.isError === true || parsed.error !== undefined, + }]; + } + + if (type === "acpx.status") { + return [{ kind: "system", ts, text: statusText(parsed) }]; + } + + if (type === "acpx.result") { + return [{ + kind: "result", + ts, + text: asString(parsed.summary, asString(parsed.stopReason, asString(parsed.text))), + inputTokens: asNumber(parsed.inputTokens), + outputTokens: asNumber(parsed.outputTokens), + cachedTokens: asNumber(parsed.cachedTokens), + costUsd: asNumber(parsed.costUsd), + subtype: asString(parsed.subtype, asString(parsed.stopReason, "acpx.result")), + isError: parsed.isError === true, + errors: Array.isArray(parsed.errors) + ? parsed.errors.map((error) => stringify(error)).filter(Boolean) + : [], + }]; + } + + if (type === "acpx.error") { + return [{ kind: "stderr", ts, text: asString(parsed.message, line) }]; + } + + if (type.startsWith("acpx.")) { + return [{ kind: "system", ts, text: asString(parsed.message, type) }]; + } + + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/packages/adapters/acpx-local/tsconfig.json b/packages/adapters/acpx-local/tsconfig.json new file mode 100644 index 00000000..e1b71318 --- /dev/null +++ b/packages/adapters/acpx-local/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/adapters/acpx-local/vitest.config.ts b/packages/adapters/acpx-local/vitest.config.ts new file mode 100644 index 00000000..f624398e --- /dev/null +++ b/packages/adapters/acpx-local/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 16211b38..8b196e93 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -30,6 +30,7 @@ export type AgentStatus = (typeof AGENT_STATUSES)[number]; export const AGENT_ADAPTER_TYPES = [ "process", "http", + "acpx_local", "claude_local", "codex_local", "gemini_local", diff --git a/packages/shared/src/environment-support.ts b/packages/shared/src/environment-support.ts index 0ebb3ff9..f63ad707 100644 --- a/packages/shared/src/environment-support.ts +++ b/packages/shared/src/environment-support.ts @@ -31,6 +31,7 @@ export interface EnvironmentCapabilities { } const REMOTE_MANAGED_ADAPTERS = new Set([ + "acpx_local", "claude_local", "codex_local", "cursor", diff --git a/scripts/capture-acpx-skills-screenshots.mjs b/scripts/capture-acpx-skills-screenshots.mjs new file mode 100644 index 00000000..ee184c66 --- /dev/null +++ b/scripts/capture-acpx-skills-screenshots.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import path from "node:path"; +import fs from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const playwrightPkgRoot = path.join(repoRoot, "node_modules/.pnpm/playwright@1.58.2/node_modules/playwright"); +const { chromium } = await import(path.join(playwrightPkgRoot, "index.mjs")); + +const baseUrl = process.env.STORYBOOK_BASE_URL ?? "http://127.0.0.1:6007"; +const outDir = process.env.OUT_DIR ?? path.join(repoRoot, "screenshots/pap-2999"); +await fs.mkdir(outDir, { recursive: true }); + +const stories = [ + { id: "adapters-acpx-local--skills-tab-claude", slug: "skills-claude" }, + { id: "adapters-acpx-local--skills-tab-codex", slug: "skills-codex" }, + { id: "adapters-acpx-local--skills-tab-custom", slug: "skills-custom" }, + { id: "adapters-acpx-local--skills-tab-loading", slug: "skills-loading" }, + { id: "adapters-acpx-local--skills-tab-empty-library", slug: "skills-empty-library" }, +]; + +const themes = [ + { name: "light", apply: false }, + { name: "dark", apply: true }, +]; + +const browser = await chromium.launch(); +try { + const context = await browser.newContext({ viewport: { width: 1280, height: 1100 } }); + const page = await context.newPage(); + for (const story of stories) { + for (const theme of themes) { + const url = `${baseUrl}/iframe.html?args=&id=${story.id}&viewMode=story&globals=theme:${theme.name}`; + await page.goto(url, { waitUntil: "load" }); + await page.waitForTimeout(1500); + const target = path.join(outDir, `${story.slug}-${theme.name}.png`); + await page.screenshot({ path: target, fullPage: true }); + console.log(`captured ${target}`); + } + } +} finally { + await browser.close(); +} diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index b9473434..e016719f 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -11,6 +11,7 @@ const nonServerProjects = [ "@paperclipai/shared", "@paperclipai/db", "@paperclipai/adapter-utils", + "@paperclipai/adapter-acpx-local", "@paperclipai/adapter-codex-local", "@paperclipai/adapter-opencode-local", "@paperclipai/ui", diff --git a/server/package.json b/server/package.json index ab81280e..bd19e53f 100644 --- a/server/package.json +++ b/server/package.json @@ -44,6 +44,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", + "@paperclipai/adapter-acpx-local": "workspace:*", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", diff --git a/server/src/__tests__/acpx-local-adapter-environment.test.ts b/server/src/__tests__/acpx-local-adapter-environment.test.ts new file mode 100644 index 00000000..0883fae0 --- /dev/null +++ b/server/src/__tests__/acpx-local-adapter-environment.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { testEnvironment } from "@paperclipai/adapter-acpx-local/server"; +import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils"; + +function credentialChecks(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentCheck[] { + return checks.filter((check) => check.code.startsWith("acpx_claude_") || check.code.startsWith("acpx_codex_")); +} + +describe("acpx_local environment credential diagnostics", () => { + beforeEach(() => { + vi.stubEnv("ANTHROPIC_API_KEY", ""); + vi.stubEnv("ANTHROPIC_BEDROCK_BASE_URL", ""); + vi.stubEnv("CLAUDE_CODE_USE_BEDROCK", ""); + vi.stubEnv("CLAUDE_CONFIG_DIR", ""); + vi.stubEnv("OPENAI_API_KEY", ""); + vi.stubEnv("CODEX_HOME", ""); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("emits an info-level Claude credential hint when ANTHROPIC_API_KEY is present", async () => { + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "acpx_local", + config: { + agent: "claude", + env: { + ANTHROPIC_API_KEY: "sk-ant-test", + }, + }, + }); + + expect(result.checks).toContainEqual(expect.objectContaining({ + code: "acpx_claude_anthropic_api_key_detected", + level: "info", + })); + expect(result.checks.some((check) => check.code.startsWith("acpx_codex_"))).toBe(false); + }); + + it("emits an info-level Claude missing credential hint without changing diagnostic health", async () => { + const root = path.join(os.tmpdir(), `paperclip-acpx-claude-noauth-${Date.now()}-${Math.random().toString(16).slice(2)}`); + const claudeConfigDir = path.join(root, ".claude"); + + try { + await fs.mkdir(claudeConfigDir, { recursive: true }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "acpx_local", + config: { + agent: "claude", + env: { + CLAUDE_CONFIG_DIR: claudeConfigDir, + }, + }, + }); + + expect(result.checks).toContainEqual(expect.objectContaining({ + code: "acpx_claude_credentials_missing", + level: "info", + })); + expect(credentialChecks(result.checks).every((check) => check.level === "info")).toBe(true); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("emits an info-level Codex credential hint when native auth is present", async () => { + const root = path.join(os.tmpdir(), `paperclip-acpx-codex-auth-${Date.now()}-${Math.random().toString(16).slice(2)}`); + const codexHome = path.join(root, ".codex"); + + try { + await fs.mkdir(codexHome, { recursive: true }); + await fs.writeFile(path.join(codexHome, "auth.json"), JSON.stringify({ accessToken: "token" }), "utf8"); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "acpx_local", + config: { + agent: "codex", + env: { + CODEX_HOME: codexHome, + }, + }, + }); + + expect(result.checks).toContainEqual(expect.objectContaining({ + code: "acpx_codex_native_auth_detected", + level: "info", + })); + expect(result.checks.some((check) => check.code.startsWith("acpx_claude_"))).toBe(false); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("emits an info-level Codex missing credential hint without changing diagnostic health", async () => { + const root = path.join(os.tmpdir(), `paperclip-acpx-codex-noauth-${Date.now()}-${Math.random().toString(16).slice(2)}`); + const codexHome = path.join(root, ".codex"); + + try { + await fs.mkdir(codexHome, { recursive: true }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "acpx_local", + config: { + agent: "codex", + env: { + CODEX_HOME: codexHome, + }, + }, + }); + + expect(result.checks).toContainEqual(expect.objectContaining({ + code: "acpx_codex_credentials_missing", + level: "info", + })); + expect(credentialChecks(result.checks).every((check) => check.level === "info")).toBe(true); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/server/src/__tests__/acpx-local-execute.test.ts b/server/src/__tests__/acpx-local-execute.test.ts new file mode 100644 index 00000000..ff9c46cb --- /dev/null +++ b/server/src/__tests__/acpx-local-execute.test.ts @@ -0,0 +1,603 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; +import { createAcpxLocalExecutor } from "@paperclipai/adapter-acpx-local/server"; +import type { + AcpRuntime, + AcpRuntimeEvent, + AcpRuntimeHandle, + AcpRuntimeOptions, + AcpRuntimeTurn, + AcpRuntimeTurnResult, +} from "acpx/runtime"; + +type LogEntry = { stream: "stdout" | "stderr"; chunk: string }; +type TestAcpRuntimeOptions = AcpRuntimeOptions & { + sessionOptions?: { + systemPrompt?: string | { append: string }; + additionalRoots?: string[]; + }; +}; + +class FakeRuntime implements AcpRuntime { + ensureInputs: Array<{ sessionKey: string; agent: string; mode: "persistent" | "oneshot"; cwd?: string; resumeSessionId?: string }> = []; + startInputs: Array<{ handle: AcpRuntimeHandle; text: string; requestId: string; timeoutMs?: number }> = []; + closeInputs: Array<{ handle: AcpRuntimeHandle; reason: string; discardPersistentState?: boolean }> = []; + cancelInputs: Array<{ handle: AcpRuntimeHandle; reason?: string }> = []; + setModeInputs: Array<{ handle: AcpRuntimeHandle; mode: string }> = []; + setConfigInputs: Array<{ handle: AcpRuntimeHandle; key: string; value: string }> = []; + ensureCount = 0; + turnCount = 0; + nextEnsureError: Error | null = null; + + constructor( + readonly options: TestAcpRuntimeOptions, + readonly events: AcpRuntimeEvent[] = [ + { type: "status", text: "thinking", tag: "agent_thought_chunk" }, + { type: "text_delta", text: "hello ", stream: "output", tag: "agent_message_chunk" }, + { type: "tool_call", text: "read README.md", title: "read", status: "running", toolCallId: "tool-1" }, + { type: "text_delta", text: "world", stream: "output", tag: "agent_message_chunk" }, + ], + readonly terminal: AcpRuntimeTurnResult = { status: "completed", stopReason: "end_turn" }, + ) {} + + async ensureSession(input: { sessionKey: string; agent: string; mode: "persistent" | "oneshot"; cwd?: string; resumeSessionId?: string }): Promise { + this.ensureInputs.push(input); + this.ensureCount += 1; + if (this.nextEnsureError) { + const err = this.nextEnsureError; + this.nextEnsureError = null; + throw err; + } + return { + sessionKey: input.sessionKey, + backend: "acpx", + runtimeSessionName: `runtime-${this.ensureCount}`, + cwd: input.cwd, + acpxRecordId: `record-${this.ensureCount}`, + backendSessionId: `acp-${this.ensureCount}`, + agentSessionId: `agent-${this.ensureCount}`, + }; + } + + startTurn(input: { handle: AcpRuntimeHandle; text: string; requestId: string; timeoutMs?: number }): AcpRuntimeTurn { + this.startInputs.push(input); + this.turnCount += 1; + let closed = false; + const events = this.events; + const terminal = this.terminal; + const cancelInputs = this.cancelInputs; + return { + requestId: input.requestId, + events: { + [Symbol.asyncIterator]: async function* () { + for (const event of events) { + if (closed) return; + yield event; + } + }, + }, + result: Promise.resolve(terminal), + cancel: async (args?: { reason?: string }) => { + cancelInputs.push({ handle: input.handle, reason: args?.reason }); + closed = true; + }, + closeStream: async () => { + closed = true; + }, + }; + } + + runTurn(): AsyncIterable { + throw new Error("not used"); + } + + getCapabilities() { + return { controls: [] }; + } + + getStatus() { + return Promise.resolve({}); + } + + async setMode(input: { handle: AcpRuntimeHandle; mode: string }) { + this.setModeInputs.push(input); + } + + async setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }) { + this.setConfigInputs.push(input); + } + + async cancel(input: { handle: AcpRuntimeHandle; reason?: string }) { + this.cancelInputs.push(input); + } + + async close(input: { handle: AcpRuntimeHandle; reason: string; discardPersistentState?: boolean }) { + this.closeInputs.push(input); + } +} + +async function createRuntimeSkill(root: string, input: { + key?: string; + runtimeName?: string; + body?: string; +}) { + const runtimeName = input.runtimeName ?? "paperclip-test-skill"; + const key = input.key ?? `company/${runtimeName}`; + const source = path.join(root, "skills", runtimeName); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, "SKILL.md"), input.body ?? "---\nrequired: false\n---\nUse the test skill.\n", "utf8"); + return { + key, + runtimeName, + source, + required: false, + }; +} + +function parseStdoutLogs(logs: LogEntry[]) { + return logs + .filter((entry) => entry.stream === "stdout") + .flatMap((entry) => entry.chunk.trim().split(/\n+/).filter(Boolean)) + .map((line) => JSON.parse(line) as Record); +} + +function buildContext(root: string, overrides: Partial = {}): AdapterExecutionContext { + return { + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "ACPX Coder", + adapterType: "acpx_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: "PAP-1", + }, + config: { + agent: "claude", + cwd: root, + stateDir: path.join(root, "state"), + promptTemplate: "Do the assigned work.", + }, + context: { + issueId: "issue-1", + paperclipTaskMarkdown: "Task context", + }, + onLog: async () => {}, + ...overrides, + }; +} + +describe("acpx_local execute", () => { + it("streams ACPX session, status, text, and tool events before returning success", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-success-")); + try { + const runtime = new FakeRuntime({} as AcpRuntimeOptions); + const logs: LogEntry[] = []; + let metaPermissionNote = ""; + const execute = createAcpxLocalExecutor({ + createRuntime: () => runtime, + }); + const result = await execute(buildContext(root, { + onLog: async (stream, chunk) => logs.push({ stream, chunk }), + onMeta: async (meta) => { + metaPermissionNote = meta.commandNotes?.join("\n") ?? ""; + }, + })); + + expect(result.exitCode).toBe(0); + expect(result.summary).toBe("hello world"); + expect(result.sessionParams).toMatchObject({ + agent: "claude", + cwd: root, + mode: "persistent", + acpSessionId: "acp-1", + }); + expect(metaPermissionNote).toContain("Effective ACPX permission mode: approve-all"); + const parsed = parseStdoutLogs(logs); + expect(parsed.map((event) => event.type)).toEqual([ + "acpx.session", + "acpx.status", + "acpx.text_delta", + "acpx.tool_call", + "acpx.text_delta", + "acpx.result", + ]); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("reuses a compatible warm session and starts fresh when cwd changes", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-reuse-")); + const other = path.join(root, "other"); + await fs.mkdir(other); + try { + const runtimes: FakeRuntime[] = []; + const execute = createAcpxLocalExecutor({ + createRuntime: (options) => { + const runtime = new FakeRuntime(options); + runtimes.push(runtime); + return runtime; + }, + }); + + const first = await execute(buildContext(root)); + const second = await execute(buildContext(root, { + runtime: { + sessionId: first.sessionId ?? null, + sessionParams: first.sessionParams ?? null, + sessionDisplayId: first.sessionDisplayId ?? null, + taskKey: "PAP-1", + }, + })); + const third = await execute(buildContext(root, { + runtime: { + sessionId: first.sessionId ?? null, + sessionParams: first.sessionParams ?? null, + sessionDisplayId: first.sessionDisplayId ?? null, + taskKey: "PAP-1", + }, + config: { + agent: "claude", + cwd: other, + stateDir: path.join(root, "state"), + promptTemplate: "Do the assigned work.", + }, + })); + + expect(runtimes).toHaveLength(2); + expect(runtimes[0].ensureCount).toBe(1); + expect(runtimes[0].turnCount).toBe(2); + expect(runtimes[1].ensureCount).toBe(1); + expect(second.sessionParams?.acpSessionId).toBe("acp-1"); + expect(third.sessionParams?.cwd).toBe(other); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("closes duplicate warm handles from concurrent runs for the same session key", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-concurrent-")); + try { + const runtimes: FakeRuntime[] = []; + const warmHandles = new Map(); + const execute = createAcpxLocalExecutor({ + warmHandles, + createRuntime: (options) => { + const runtime = new FakeRuntime(options); + runtimes.push(runtime); + return runtime; + }, + }); + + const [first, second] = await Promise.all([ + execute(buildContext(root, { runId: "run-1" })), + execute(buildContext(root, { runId: "run-2" })), + ]); + + expect(first.exitCode).toBe(0); + expect(second.exitCode).toBe(0); + expect(runtimes).toHaveLength(2); + expect(warmHandles.size).toBe(1); + expect(runtimes.flatMap((runtime) => runtime.closeInputs).filter((input) => + input.reason === "paperclip duplicate warm handle cleanup" + )).toHaveLength(1); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("retries with a fresh session when ACPX cannot resume the saved backend session", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-resume-")); + try { + const runtime = new FakeRuntime({} as AcpRuntimeOptions); + const firstExecute = createAcpxLocalExecutor({ + createRuntime: () => runtime, + warmHandles: new Map(), + }); + const initial = await firstExecute(buildContext(root)); + const compatibleParams = { + ...initial.sessionParams, + runtimeSessionName: "runtime-old", + acpSessionId: "acp-old", + }; + runtime.nextEnsureError = new Error("session/load failed: no session acp-old"); + const logs: LogEntry[] = []; + const execute = createAcpxLocalExecutor({ + createRuntime: () => runtime, + warmHandles: new Map(), + }); + const result = await execute(buildContext(root, { + runtime: { + sessionId: "acp-old", + sessionParams: compatibleParams, + sessionDisplayId: "acp-old", + taskKey: "PAP-1", + }, + onLog: async (stream, chunk) => logs.push({ stream, chunk }), + })); + + expect(result.exitCode).toBe(0); + expect(result.clearSession).toBe(true); + expect(runtime.ensureInputs.at(-2)?.resumeSessionId).toBe("acp-old"); + expect(runtime.ensureInputs.at(-1)?.resumeSessionId).toBeUndefined(); + expect(logs.some((entry) => entry.chunk.includes("retrying with a fresh session"))).toBe(true); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("cancels and closes stale handles on timeout", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-timeout-")); + try { + const neverFinishes = new FakeRuntime( + {} as AcpRuntimeOptions, + [], + { status: "cancelled", stopReason: "cancelled" }, + ); + neverFinishes.startTurn = function (input): AcpRuntimeTurn { + this.startInputs.push(input); + let resolveResult!: (value: AcpRuntimeTurnResult) => void; + const result = new Promise((resolve) => { + resolveResult = resolve; + }); + return { + requestId: input.requestId, + events: { + [Symbol.asyncIterator]: async function* () { + await new Promise((resolve) => setTimeout(resolve, 50)); + }, + }, + result, + cancel: async (args?: { reason?: string }) => { + this.cancelInputs.push({ handle: input.handle, reason: args?.reason }); + resolveResult({ status: "cancelled", stopReason: args?.reason }); + }, + closeStream: async () => {}, + }; + }; + const execute = createAcpxLocalExecutor({ createRuntime: () => neverFinishes }); + const result = await execute(buildContext(root, { + config: { + agent: "claude", + cwd: root, + stateDir: path.join(root, "state"), + promptTemplate: "Do the assigned work.", + timeoutSec: 0.01, + }, + })); + + expect(result.timedOut).toBe(true); + expect(result.errorCode).toBe("acpx_timeout"); + expect(neverFinishes.cancelInputs.length).toBeGreaterThan(0); + expect(neverFinishes.closeInputs.at(-1)?.discardPersistentState).toBe(true); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("returns structured auth errors", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-error-")); + try { + const runtime = new FakeRuntime({} as AcpRuntimeOptions); + runtime.nextEnsureError = new Error("authentication required: login first"); + const execute = createAcpxLocalExecutor({ createRuntime: () => runtime }); + const result = await execute(buildContext(root)); + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("acpx_auth_required"); + expect(result.errorMeta).toMatchObject({ category: "auth" }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("returns structured ACP protocol errors", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-protocol-")); + try { + const runtime = new FakeRuntime({} as AcpRuntimeOptions); + runtime.nextEnsureError = Object.assign(new Error("protocol init failed"), { + code: "ACP_SESSION_INIT_FAILED", + }); + const execute = createAcpxLocalExecutor({ createRuntime: () => runtime }); + const result = await execute(buildContext(root)); + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("acpx_protocol_error"); + expect(result.errorMeta).toMatchObject({ + category: "protocol", + acpCode: "ACP_SESSION_INIT_FAILED", + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("materializes selected skills for ACPX Claude and passes public session metadata", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-claude-skills-")); + try { + const skill = await createRuntimeSkill(root, {}); + let runtime: FakeRuntime | null = null; + let meta: Record | null = null; + const execute = createAcpxLocalExecutor({ + createRuntime: (options) => { + runtime = new FakeRuntime(options); + return runtime; + }, + }); + + const result = await execute(buildContext(root, { + config: { + agent: "claude", + cwd: root, + stateDir: path.join(root, "state"), + promptTemplate: "Do the assigned work.", + paperclipRuntimeSkills: [skill], + paperclipSkillSync: { + desiredSkills: [skill.key], + }, + }, + onMeta: async (payload) => { + meta = payload as Record; + }, + })); + + expect(result.exitCode).toBe(0); + expect(runtime?.options).not.toHaveProperty("sessionOptions"); + const skillRoot = result.sessionParams?.skills && typeof result.sessionParams.skills === "object" + ? (result.sessionParams.skills as { skillRoot?: string | null }).skillRoot + : null; + expect(skillRoot).toContain(path.join("state", "runtime-skills", "claude")); + await expect(fs.lstat(path.join(skillRoot!, skill.runtimeName))).resolves.toMatchObject({}); + expect(result.sessionParams?.skills).toMatchObject({ + mode: "claude", + selectedSkills: [skill.runtimeName], + }); + expect(String(meta?.prompt ?? "")).toContain(`Skill root: ${skillRoot}`); + expect((meta?.commandNotes as string[]).join("\n")).toContain("Materialized 1 Paperclip skill"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("includes skill content in the ACPX Claude session fingerprint", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-claude-fingerprint-")); + try { + const skill = await createRuntimeSkill(root, { body: "---\nrequired: false\n---\nFirst version.\n" }); + const runtimes: FakeRuntime[] = []; + const execute = createAcpxLocalExecutor({ + createRuntime: (options) => { + const runtime = new FakeRuntime(options); + runtimes.push(runtime); + return runtime; + }, + }); + const context = buildContext(root, { + config: { + agent: "claude", + cwd: root, + stateDir: path.join(root, "state"), + promptTemplate: "Do the assigned work.", + paperclipRuntimeSkills: [skill], + paperclipSkillSync: { + desiredSkills: [skill.key], + }, + }, + }); + + const first = await execute(context); + await fs.writeFile(path.join(skill.source, "SKILL.md"), "---\nrequired: false\n---\nSecond version.\n", "utf8"); + const second = await execute({ + ...context, + runtime: { + sessionId: first.sessionId ?? null, + sessionParams: first.sessionParams ?? null, + sessionDisplayId: first.sessionDisplayId ?? null, + taskKey: "PAP-1", + }, + }); + + expect(second.sessionParams?.configFingerprint).not.toBe(first.sessionParams?.configFingerprint); + expect(runtimes.at(-1)?.ensureInputs.at(-1)?.resumeSessionId).toBeUndefined(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("materializes selected skills into the effective ACPX Codex CODEX_HOME", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-codex-skills-")); + try { + const skill = await createRuntimeSkill(root, {}); + const codexHome = path.join(root, "codex-home"); + let runtime: FakeRuntime | null = null; + let meta: Record | null = null; + const execute = createAcpxLocalExecutor({ + createRuntime: (options) => { + runtime = new FakeRuntime(options); + return runtime; + }, + }); + + const result = await execute(buildContext(root, { + config: { + agent: "codex", + cwd: root, + stateDir: path.join(root, "state"), + promptTemplate: "Do the assigned work.", + env: { CODEX_HOME: codexHome }, + paperclipRuntimeSkills: [skill], + paperclipSkillSync: { + desiredSkills: [skill.key], + }, + }, + onMeta: async (payload) => { + meta = payload as Record; + }, + })); + + expect(result.exitCode).toBe(0); + await expect(fs.lstat(path.join(codexHome, "skills", skill.runtimeName))).resolves.toMatchObject({}); + const wrapperPath = runtime?.options.agentRegistry.resolve("codex"); + const wrapper = await fs.readFile(wrapperPath!, "utf8"); + expect(wrapper).not.toContain("CODEX_HOME"); + expect(wrapper).not.toContain(codexHome); + expect((meta?.env as Record).CODEX_HOME).toBe(codexHome); + expect(result.sessionParams?.skills).toMatchObject({ + mode: "codex", + codexHome, + selectedSkills: [skill.runtimeName], + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("keeps ACPX custom skill selection tracked without runtime materialization", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-custom-skills-")); + try { + const skill = await createRuntimeSkill(root, {}); + let runtime: FakeRuntime | null = null; + let meta: Record | null = null; + const execute = createAcpxLocalExecutor({ + createRuntime: (options) => { + runtime = new FakeRuntime(options); + return runtime; + }, + }); + + const result = await execute(buildContext(root, { + config: { + agent: "custom", + agentCommand: "custom-acp", + cwd: root, + stateDir: path.join(root, "state"), + promptTemplate: "Do the assigned work.", + paperclipRuntimeSkills: [skill], + paperclipSkillSync: { + desiredSkills: [skill.key], + }, + }, + onMeta: async (payload) => { + meta = payload as Record; + }, + })); + + expect(result.exitCode).toBe(0); + expect(runtime?.options.sessionOptions).toBeUndefined(); + await expect(fs.lstat(path.join(root, "state", "runtime-skills"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(result.sessionParams?.skills).toMatchObject({ + mode: "custom_unsupported", + desiredSkillNames: [skill.key], + }); + expect((meta?.commandNotes as string[]).join("\n")).toContain("tracked only"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/server/src/__tests__/acpx-local-skill-sync.test.ts b/server/src/__tests__/acpx-local-skill-sync.test.ts new file mode 100644 index 00000000..5da02709 --- /dev/null +++ b/server/src/__tests__/acpx-local-skill-sync.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + listAcpxSkills, + syncAcpxSkills, +} from "@paperclipai/adapter-acpx-local/server"; + +describe("acpx local skill sync", () => { + const paperclipKey = "paperclipai/paperclip/paperclip"; + const createAgentKey = "paperclipai/paperclip/paperclip-create-agent"; + + it("reports ACPX Claude skills as supported runtime-mounted state", async () => { + const snapshot = await listAcpxSkills({ + agentId: "agent-1", + companyId: "company-1", + adapterType: "acpx_local", + config: { + agent: "claude", + paperclipSkillSync: { + desiredSkills: [paperclipKey], + }, + }, + }); + + expect(snapshot.adapterType).toBe("acpx_local"); + expect(snapshot.supported).toBe(true); + expect(snapshot.mode).toBe("ephemeral"); + expect(snapshot.desiredSkills).toContain(paperclipKey); + expect(snapshot.desiredSkills).toContain(createAgentKey); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("ACPX Claude session"); + expect(snapshot.warnings).toEqual([]); + }); + + it("reports ACPX Codex skills with Codex home runtime detail", async () => { + const snapshot = await syncAcpxSkills({ + agentId: "agent-2", + companyId: "company-1", + adapterType: "acpx_local", + config: { + agent: "codex", + paperclipSkillSync: { + desiredSkills: ["paperclip"], + }, + }, + }, ["paperclip"]); + + expect(snapshot.supported).toBe(true); + expect(snapshot.mode).toBe("ephemeral"); + expect(snapshot.desiredSkills).toContain(paperclipKey); + expect(snapshot.desiredSkills).not.toContain("paperclip"); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("CODEX_HOME/skills/"); + expect(snapshot.warnings).toEqual([]); + }); + + it("keeps ACPX custom skill selection tracked but unsupported", async () => { + const snapshot = await listAcpxSkills({ + agentId: "agent-3", + companyId: "company-1", + adapterType: "acpx_local", + config: { + agent: "custom", + paperclipSkillSync: { + desiredSkills: [paperclipKey], + }, + }, + }); + + expect(snapshot.supported).toBe(false); + expect(snapshot.mode).toBe("unsupported"); + expect(snapshot.desiredSkills).toContain(paperclipKey); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.desired).toBe(true); + expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("stored in Paperclip only"); + expect(snapshot.warnings).toContain( + "Custom ACP commands do not expose a Paperclip skill integration contract yet; selected skills are tracked only.", + ); + }); +}); diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index eede2af2..6fa67915 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -202,6 +202,11 @@ describe("adapter routes", () => { const codexLocal = res.body.find((a: any) => a.type === "codex_local"); expect(codexLocal).toBeDefined(); expect(codexLocal.capabilities.supportsSkills).toBe(true); + + // acpx_local exposes runtime-aware skill snapshots for Claude/Codex/custom ACP agents + const acpxLocal = res.body.find((a: any) => a.type === "acpx_local"); + expect(acpxLocal).toBeDefined(); + expect(acpxLocal.capabilities.supportsSkills).toBe(true); }); it("uses the active adapter when resolving config schema for a paused builtin override", async () => { @@ -225,6 +230,31 @@ describe("adapter routes", () => { }); }); + it("serves the built-in acpx_local config schema", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters/acpx_local/config-schema"); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "agent", + default: "claude", + options: expect.arrayContaining([ + expect.objectContaining({ value: "claude" }), + expect.objectContaining({ value: "codex" }), + expect.objectContaining({ value: "custom" }), + ]), + }), + expect.objectContaining({ + key: "permissionMode", + default: "approve-all", + }), + ]), + ); + }); + it("rejects signed-in users without org access", async () => { const app = createApp({ userId: "outsider-1", diff --git a/server/src/__tests__/adapter-session-codecs.test.ts b/server/src/__tests__/adapter-session-codecs.test.ts index 27efe287..10905e51 100644 --- a/server/src/__tests__/adapter-session-codecs.test.ts +++ b/server/src/__tests__/adapter-session-codecs.test.ts @@ -13,6 +13,7 @@ import { sessionCodec as opencodeSessionCodec, isOpenCodeUnknownSessionError, } from "@paperclipai/adapter-opencode-local/server"; +import { sessionCodec as acpxSessionCodec } from "@paperclipai/adapter-acpx-local/server"; describe("adapter session codecs", () => { it("normalizes claude session params with cwd", () => { @@ -107,6 +108,50 @@ describe("adapter session codecs", () => { }); expect(geminiSessionCodec.getDisplayId?.(serialized ?? null)).toBe("gemini-session-1"); }); + + it("preserves acpx session params required for compatibility checks", () => { + const parsed = acpxSessionCodec.deserialize({ + sessionKey: "paperclip:company:agent:task:fingerprint", + runtimeSessionName: "runtime-session-1", + acpxRecordId: "record-1", + acpSessionId: "acp-session-1", + agentSessionId: "agent-session-1", + agent: "claude", + cwd: "/tmp/acpx", + mode: "persistent", + stateDir: "/tmp/acpx-state", + configFingerprint: "fingerprint", + workspaceId: "workspace-1", + repoUrl: "https://example.com/repo.git", + repoRef: "main", + remoteExecution: { + environmentId: "environment-1", + leaseId: "lease-1", + }, + }); + + expect(parsed).toMatchObject({ + sessionKey: "paperclip:company:agent:task:fingerprint", + runtimeSessionName: "runtime-session-1", + acpxRecordId: "record-1", + acpSessionId: "acp-session-1", + agentSessionId: "agent-session-1", + agent: "claude", + cwd: "/tmp/acpx", + mode: "persistent", + stateDir: "/tmp/acpx-state", + configFingerprint: "fingerprint", + workspaceId: "workspace-1", + repoUrl: "https://example.com/repo.git", + repoRef: "main", + remoteExecution: { + environmentId: "environment-1", + leaseId: "lease-1", + }, + }); + expect(acpxSessionCodec.serialize(parsed)).toEqual(parsed); + expect(acpxSessionCodec.getDisplayId?.(parsed)).toBe("runtime-session-1"); + }); }); describe("codex resume recovery detection", () => { diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 4dd60ce8..84537a9b 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -362,6 +362,99 @@ describe.sequential("agent skill routes", () => { expect(res.status, JSON.stringify(res.body)).toBe(200); }); + it("passes ACPX Claude config through the agent skill listing route", async () => { + mockAgentService.getById.mockResolvedValue({ + ...makeAgent("acpx_local"), + adapterConfig: { agent: "claude" }, + }); + mockSecretService.resolveAdapterConfigForRuntime.mockResolvedValueOnce({ + config: { agent: "claude" }, + }); + mockAdapter.listSkills.mockResolvedValue({ + adapterType: "acpx_local", + supported: true, + mode: "ephemeral", + desiredSkills: ["paperclipai/paperclip/paperclip"], + entries: [], + warnings: [], + }); + + const res = await requestApp( + await createApp(), + (baseUrl) => request(baseUrl) + .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"), + ); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", { + materializeMissing: false, + }); + expect(mockAdapter.listSkills).toHaveBeenCalledWith( + expect.objectContaining({ + adapterType: "acpx_local", + config: expect.objectContaining({ + agent: "claude", + paperclipRuntimeSkills: expect.any(Array), + }), + }), + ); + }); + + it("persists ACPX Codex desired skills through the agent skill sync route", async () => { + mockAgentService.getById.mockResolvedValue({ + ...makeAgent("acpx_local"), + adapterConfig: { agent: "codex" }, + }); + mockAgentService.update.mockImplementationOnce(async (_id: string, patch: Record) => ({ + ...makeAgent("acpx_local"), + adapterConfig: patch.adapterConfig ?? {}, + })); + mockSecretService.resolveAdapterConfigForRuntime.mockResolvedValueOnce({ + config: { + agent: "codex", + paperclipSkillSync: { + desiredSkills: ["paperclipai/paperclip/paperclip"], + }, + }, + }); + mockAdapter.syncSkills.mockResolvedValue({ + adapterType: "acpx_local", + supported: true, + mode: "ephemeral", + desiredSkills: ["paperclipai/paperclip/paperclip"], + entries: [], + warnings: [], + }); + + const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) + .post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1") + .send({ desiredSkills: ["paperclip"] })); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentService.update).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + agent: "codex", + paperclipSkillSync: expect.objectContaining({ + desiredSkills: ["paperclipai/paperclip/paperclip"], + }), + }), + }), + expect.any(Object), + ); + expect(mockAdapter.syncSkills).toHaveBeenCalledWith( + expect.objectContaining({ + adapterType: "acpx_local", + config: expect.objectContaining({ + agent: "codex", + paperclipRuntimeSkills: expect.any(Array), + }), + }), + ["paperclipai/paperclip/paperclip"], + ); + }); + it("keeps runtime materialization for persistent skill adapters", async () => { mockAgentService.getById.mockResolvedValue(makeAgent("cursor")); mockAdapter.listSkills.mockResolvedValue({ diff --git a/server/src/__tests__/redaction.test.ts b/server/src/__tests__/redaction.test.ts index e22eb597..e5b00cff 100644 --- a/server/src/__tests__/redaction.test.ts +++ b/server/src/__tests__/redaction.test.ts @@ -84,4 +84,51 @@ describe("redaction", () => { expect(result).not.toContain(githubToken); expect(result).not.toContain(jwt); }); + + it("redacts inline secrets from command metadata without hiding safe command text", () => { + const input = { + command: "custom-acp --token ghp_example_secret env OPENAI_API_KEY=sk-live-example custom-acp", + commandArgs: ["--safe", "ok", "--token", "ghp_arg_secret", "--api-key=sk-inline-example"], + env: { + PAPERCLIP_RESOLVED_COMMAND: "env OPENAI_API_KEY=sk-live-example custom-acp --token ghp_example_secret", + SAFE_VALUE: "visible", + }, + }; + + const result = redactEventPayload(input); + + expect(result?.command).toBe( + `custom-acp --token ${REDACTED_EVENT_VALUE} env OPENAI_API_KEY=${REDACTED_EVENT_VALUE} custom-acp`, + ); + expect(result?.commandArgs).toEqual([ + "--safe", + "ok", + "--token", + REDACTED_EVENT_VALUE, + `--api-key=${REDACTED_EVENT_VALUE}`, + ]); + expect(result?.env).toEqual({ + PAPERCLIP_RESOLVED_COMMAND: + `env OPENAI_API_KEY=${REDACTED_EVENT_VALUE} custom-acp --token ${REDACTED_EVENT_VALUE}`, + SAFE_VALUE: "visible", + }); + }); + + it("redacts non-string command args after secret flags", () => { + const result = redactEventPayload({ + commandArgs: ["--api-key", { nested: "secret-value" }, "safe-next"], + }); + + expect(result?.commandArgs).toEqual(["--api-key", REDACTED_EVENT_VALUE, "safe-next"]); + }); + + it("does not treat bare args payloads as command args", () => { + const result = redactEventPayload({ + args: ["--api-key", "not-a-command-secret"], + argv: ["--api-key", "command-secret"], + }); + + expect(result?.args).toEqual(["--api-key", "not-a-command-secret"]); + expect(result?.argv).toEqual(["--api-key", REDACTED_EVENT_VALUE]); + }); }); diff --git a/server/src/adapters/builtin-adapter-types.ts b/server/src/adapters/builtin-adapter-types.ts index 463a5694..3028ae0c 100644 --- a/server/src/adapters/builtin-adapter-types.ts +++ b/server/src/adapters/builtin-adapter-types.ts @@ -2,6 +2,7 @@ * Adapter types shipped with Paperclip. External plugins must not replace these. */ export const BUILTIN_ADAPTER_TYPES = new Set([ + "acpx_local", "claude_local", "codex_local", "cursor", diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 94c38e29..359aecd4 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,5 +1,14 @@ import type { AdapterModelProfileDefinition, ServerAdapterModule } from "./types.js"; import { getAdapterSessionManagement } from "@paperclipai/adapter-utils"; +import { + execute as acpxExecute, + testEnvironment as acpxTestEnvironment, + sessionCodec as acpxSessionCodec, + getConfigSchema as getAcpxConfigSchema, + listAcpxSkills, + syncAcpxSkills, +} from "@paperclipai/adapter-acpx-local/server"; +import { agentConfigurationDoc as acpxAgentConfigurationDoc } from "@paperclipai/adapter-acpx-local"; import { execute as claudeExecute, listClaudeSkills, @@ -154,6 +163,22 @@ const claudeLocalAdapter: ServerAdapterModule = { getQuotaWindows: claudeGetQuotaWindows, }; +const acpxLocalAdapter: ServerAdapterModule = { + type: "acpx_local", + execute: acpxExecute, + testEnvironment: acpxTestEnvironment, + listSkills: listAcpxSkills, + syncSkills: syncAcpxSkills, + sessionCodec: acpxSessionCodec, + sessionManagement: getAdapterSessionManagement("acpx_local") ?? undefined, + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, + agentConfigurationDoc: acpxAgentConfigurationDoc, + getConfigSchema: getAcpxConfigSchema, +}; + const codexLocalAdapter: ServerAdapterModule = { type: "codex_local", execute: codexExecute, @@ -335,6 +360,7 @@ const pausedOverrides = new Set(); function registerBuiltInAdapters() { for (const adapter of [ + acpxLocalAdapter, claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, diff --git a/server/src/adapters/utils.ts b/server/src/adapters/utils.ts index da3767dd..a2682f23 100644 --- a/server/src/adapters/utils.ts +++ b/server/src/adapters/utils.ts @@ -64,7 +64,8 @@ export function buildInvocationEnvForLogs( const resolvedCommand = options.resolvedCommand?.trim(); if (resolvedCommand) { - merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = resolvedCommand; + merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = + serverUtils.redactCommandTextForLogs(resolvedCommand); } return redactEnvForLogs(merged); diff --git a/server/src/redaction.ts b/server/src/redaction.ts index 75ebd56e..f3877a8b 100644 --- a/server/src/redaction.ts +++ b/server/src/redaction.ts @@ -1,12 +1,13 @@ +import { redactCommandText } from "@paperclipai/adapter-utils"; + const SECRET_PAYLOAD_KEY_RE = /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; +const COMMAND_PAYLOAD_KEY_RE = + /(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i; +const COMMAND_ARGS_PAYLOAD_KEY_RE = /^(commandArgs|command_?args|argv)$/i; const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/; -const JWT_TEXT_RE = /\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]{8,})?\b/g; -const OPENAI_KEY_TEXT_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g; -const GITHUB_TOKEN_TEXT_RE = /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g; -const AUTHORIZATION_BEARER_TEXT_RE = /(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi; -const ENV_SECRET_ASSIGNMENT_TEXT_RE = - /(\b[A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|AUTHORIZATION|JWT)[A-Za-z0-9_]*\s*=\s*)[^\s"'`]+/gi; +const CLI_SECRET_FLAG_RE = + /^-{1,2}(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)$/i; const JSON_SECRET_FIELD_TEXT_RE = /((?:"|')?(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:"|')?\s*:\s*(?:"|'))[^"'`\r\n]+((?:"|'))/gi; const ESCAPED_JSON_SECRET_FIELD_TEXT_RE = @@ -38,9 +39,33 @@ function isPlainBinding(value: unknown): value is { type: "plain"; value: unknow return value.type === "plain" && "value" in value; } +function sanitizeCommandArgs(args: unknown[]): unknown[] { + let redactNext = false; + return args.map((arg) => { + if (redactNext) { + redactNext = false; + return REDACTED_EVENT_VALUE; + } + if (typeof arg !== "string") return sanitizeValue(arg); + if (CLI_SECRET_FLAG_RE.test(arg.trim())) { + redactNext = true; + return arg; + } + return redactSensitiveText(arg); + }); +} + export function sanitizeRecord(record: Record): Record { const redacted: Record = {}; for (const [key, value] of Object.entries(record)) { + if (COMMAND_ARGS_PAYLOAD_KEY_RE.test(key) && Array.isArray(value)) { + redacted[key] = sanitizeCommandArgs(value); + continue; + } + if (COMMAND_PAYLOAD_KEY_RE.test(key) && typeof value === "string") { + redacted[key] = redactSensitiveText(value); + continue; + } if (SECRET_PAYLOAD_KEY_RE.test(key)) { if (isSecretRefBinding(value)) { redacted[key] = sanitizeValue(value); @@ -69,12 +94,10 @@ export function redactEventPayload(payload: Record | null): Rec } export function redactSensitiveText(input: string): string { - return input - .replace(AUTHORIZATION_BEARER_TEXT_RE, `$1${REDACTED_EVENT_VALUE}`) - .replace(JSON_SECRET_FIELD_TEXT_RE, `$1${REDACTED_EVENT_VALUE}$2`) - .replace(ESCAPED_JSON_SECRET_FIELD_TEXT_RE, `$1${REDACTED_EVENT_VALUE}$2`) - .replace(ENV_SECRET_ASSIGNMENT_TEXT_RE, `$1${REDACTED_EVENT_VALUE}`) - .replace(OPENAI_KEY_TEXT_RE, REDACTED_EVENT_VALUE) - .replace(GITHUB_TOKEN_TEXT_RE, REDACTED_EVENT_VALUE) - .replace(JWT_TEXT_RE, REDACTED_EVENT_VALUE); + return redactCommandText( + input + .replace(JSON_SECRET_FIELD_TEXT_RE, `$1${REDACTED_EVENT_VALUE}$2`) + .replace(ESCAPED_JSON_SECRET_FIELD_TEXT_RE, `$1${REDACTED_EVENT_VALUE}$2`), + REDACTED_EVENT_VALUE, + ); } diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 2dbe8ad8..6b663a85 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -72,6 +72,12 @@ import { redactCurrentUserValue } from "../log-redaction.js"; import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server"; +import { + DEFAULT_ACPX_LOCAL_AGENT, + DEFAULT_ACPX_LOCAL_MODE, + DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS, + DEFAULT_ACPX_LOCAL_PERMISSION_MODE, +} from "@paperclipai/adapter-acpx-local"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL, @@ -110,6 +116,7 @@ export function agentRoutes( // Legacy hardcoded maps — used as fallback when adapter module does not // declare capability flags explicitly. const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { + acpx_local: "instructionsFilePath", claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", droid_local: "instructionsFilePath", @@ -826,6 +833,21 @@ export function agentRoutes( adapterConfig: Record, ): Record { const next = { ...adapterConfig }; + if (adapterType === "acpx_local") { + if (!asNonEmptyString(next.agent)) { + next.agent = DEFAULT_ACPX_LOCAL_AGENT; + } + if (!asNonEmptyString(next.mode)) { + next.mode = DEFAULT_ACPX_LOCAL_MODE; + } + if (!asNonEmptyString(next.permissionMode)) { + next.permissionMode = DEFAULT_ACPX_LOCAL_PERMISSION_MODE; + } + if (!asNonEmptyString(next.nonInteractivePermissions)) { + next.nonInteractivePermissions = DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS; + } + return ensureGatewayDeviceKey(adapterType, next); + } if (adapterType === "codex_local") { if (!asNonEmptyString(next.model)) { next.model = DEFAULT_CODEX_LOCAL_MODEL; diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 49ceac60..0f2f70db 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -34,6 +34,7 @@ export async function resolveEnvironmentExecutionTarget(input: { if (input.environment.driver === "sandbox") { if ( + input.adapterType !== "acpx_local" && input.adapterType !== "codex_local" && input.adapterType !== "claude_local" && input.adapterType !== "gemini_local" && @@ -106,6 +107,7 @@ export async function resolveEnvironmentExecutionTarget(input: { if ( ( input.adapterType !== "codex_local" && + input.adapterType !== "acpx_local" && input.adapterType !== "claude_local" && input.adapterType !== "gemini_local" && input.adapterType !== "opencode_local" && diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index a76b15a8..f7c0a220 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -121,6 +121,7 @@ import { recoveryService } from "./recovery/service.js"; import { productivityReviewService } from "./productivity-review.js"; import { withAgentStartLock } from "./agent-start-lock.js"; import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js"; +import { redactEventPayload } from "../redaction.js"; import { hasSessionCompactionThresholds, resolveSessionCompactionPolicy, @@ -3118,9 +3119,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const boundedPayload = event.payload ? boundHeartbeatRunEventPayloadForStorage(event.payload) : event.payload; - const sanitizedPayload = boundedPayload - ? redactCurrentUserValue(boundedPayload, currentUserRedactionOptions) - : boundedPayload; + const secretSanitizedPayload = boundedPayload ? redactEventPayload(boundedPayload) : boundedPayload; + const sanitizedPayload = secretSanitizedPayload + ? redactCurrentUserValue(secretSanitizedPayload, currentUserRedactionOptions) + : secretSanitizedPayload; await db.insert(heartbeatRunEvents).values({ companyId: run.companyId, diff --git a/ui/package.json b/ui/package.json index dda6e715..6160df07 100644 --- a/ui/package.json +++ b/ui/package.json @@ -34,6 +34,7 @@ "@dnd-kit/utilities": "^3.2.2", "@lexical/link": "0.35.0", "@mdxeditor/editor": "^3.52.4", + "@paperclipai/adapter-acpx-local": "workspace:*", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", diff --git a/ui/src/adapters/acpx-local/index.ts b/ui/src/adapters/acpx-local/index.ts new file mode 100644 index 00000000..d8ae9675 --- /dev/null +++ b/ui/src/adapters/acpx-local/index.ts @@ -0,0 +1,11 @@ +import type { UIAdapterModule } from "../types"; +import { parseAcpxStdoutLine, buildAcpxLocalConfig } from "@paperclipai/adapter-acpx-local/ui"; +import { SchemaConfigFields } from "../schema-config-fields"; + +export const acpxLocalUIAdapter: UIAdapterModule = { + type: "acpx_local", + label: "ACPX (local)", + parseStdoutLine: parseAcpxStdoutLine, + ConfigFields: SchemaConfigFields, + buildAdapterConfig: buildAcpxLocalConfig, +}; diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index fe809273..d75da557 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -49,9 +49,18 @@ export interface AdapterDisplayInfo { recommended?: boolean; comingSoon?: boolean; disabledLabel?: string; + experimental?: boolean; + hideFromVisualSelection?: boolean; } const adapterDisplayMap: Record = { + acpx_local: { + label: "ACPX", + description: "Experimental local ACPX multi-agent adapter", + icon: Bot, + experimental: true, + hideFromVisualSelection: true, + }, claude_local: { label: "Claude Code", description: "Local Claude agent", diff --git a/ui/src/adapters/metadata.test.ts b/ui/src/adapters/metadata.test.ts index 70b7ef3c..c08c2f21 100644 --- a/ui/src/adapters/metadata.test.ts +++ b/ui/src/adapters/metadata.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { isEnabledAdapterType, listAdapterOptions } from "./metadata"; +import { + isEnabledAdapterType, + isValidAdapterType, + isVisualAdapterChoice, + listAdapterOptions, +} from "./metadata"; import type { UIAdapterModule } from "./types"; const externalAdapter: UIAdapterModule = { @@ -22,6 +27,7 @@ describe("adapter metadata", () => { label: "external_test", comingSoon: false, hidden: false, + experimental: false, }, ]); }); @@ -30,4 +36,27 @@ describe("adapter metadata", () => { expect(isEnabledAdapterType("process")).toBe(false); expect(isEnabledAdapterType("http")).toBe(false); }); -}); \ No newline at end of file + + it("keeps ACPX selectable from explicit configuration but out of visual pickers", () => { + expect(isEnabledAdapterType("acpx_local")).toBe(true); + expect(isValidAdapterType("acpx_local")).toBe(true); + expect(isVisualAdapterChoice("acpx_local")).toBe(false); + + expect( + listAdapterOptions((type) => type, [ + { + ...externalAdapter, + type: "acpx_local", + }, + ]), + ).toEqual([ + { + value: "acpx_local", + label: "acpx_local", + comingSoon: false, + hidden: false, + experimental: true, + }, + ]); + }); +}); diff --git a/ui/src/adapters/metadata.ts b/ui/src/adapters/metadata.ts index 297a7237..d19d0093 100644 --- a/ui/src/adapters/metadata.ts +++ b/ui/src/adapters/metadata.ts @@ -15,6 +15,7 @@ export interface AdapterOptionMetadata { label: string; comingSoon: boolean; hidden: boolean; + experimental: boolean; } export function listKnownAdapterTypes(): string[] { @@ -43,6 +44,15 @@ export function isValidAdapterType(type: string): boolean { return true; } +/** + * Check whether an adapter should appear in card-style visual pickers. + * Experimental adapters can remain selectable from explicit configuration + * dropdowns without being recommended during onboarding or setup flows. + */ +export function isVisualAdapterChoice(type: string): boolean { + return !getAdapterDisplay(type).hideFromVisualSelection; +} + /** * Build option metadata for a list of adapters (for dropdowns). * `labelFor` callback allows callers to override labels; defaults to display registry. @@ -57,6 +67,7 @@ export function listAdapterOptions( label: getLabel(adapter.type), comingSoon: !!getAdapterDisplay(adapter.type).comingSoon, hidden: isAdapterTypeHidden(adapter.type), + experimental: !!getAdapterDisplay(adapter.type).experimental, })); } diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index e8e24eaf..d53eaaae 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -1,4 +1,5 @@ import type { UIAdapterModule } from "./types"; +import { acpxLocalUIAdapter } from "./acpx-local"; import { claudeLocalUIAdapter } from "./claude-local"; import { codexLocalUIAdapter } from "./codex-local"; import { cursorLocalUIAdapter } from "./cursor"; @@ -49,6 +50,7 @@ setDynamicParserResultNotifier(notifyAdapterChange); function registerBuiltInUIAdapters() { for (const adapter of [ + acpxLocalUIAdapter, claudeLocalUIAdapter, codexLocalUIAdapter, geminiLocalUIAdapter, diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts index 631c01f3..786776fe 100644 --- a/ui/src/adapters/use-adapter-capabilities.ts +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -16,6 +16,7 @@ const ALL_FALSE: AdapterCapabilities = { * return correct values on first render before the /api/adapters call resolves. */ const KNOWN_DEFAULTS: Record = { + acpx_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false }, claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true }, codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true }, cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index f1af84a6..c36463ab 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -52,7 +52,7 @@ import { ReportsToPicker } from "./ReportsToPicker"; import { EnvVarEditor } from "./EnvVarEditor"; import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config"; import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata"; -import { getAdapterLabel } from "../adapters/adapter-display-registry"; +import { getAdapterDisplay, getAdapterLabel } from "../adapters/adapter-display-registry"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch"; import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; @@ -1239,6 +1239,7 @@ function AdapterTypeDropdown({ disabledTypes: Set; }) { const [open, setOpen] = useState(false); + const selectedDisplay = getAdapterDisplay(value); const adapterList = useMemo( () => listAdapterOptions((type) => adapterLabels[type] ?? getAdapterLabel(type)).filter( @@ -1251,9 +1252,10 @@ function AdapterTypeDropdown({ @@ -1280,6 +1282,7 @@ function AdapterTypeDropdown({ {item.value === "opencode_local" ? : null} {item.label} + {item.experimental && } {item.comingSoon && ( Coming soon @@ -1291,6 +1294,14 @@ function AdapterTypeDropdown({ ); } +function ExperimentalBadge() { + return ( + + Experimental + + ); +} + function ModelDropdown({ models, value, diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 4ff672c6..6400038d 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -17,6 +17,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { listUIAdapters } from "../adapters"; +import { isVisualAdapterChoice } from "../adapters/metadata"; import { getAdapterDisplay } from "../adapters/adapter-display-registry"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; @@ -57,7 +58,11 @@ export function NewAgentDialog() { // This automatically includes external/plugin adapters. const adapterGrid = useMemo(() => { const registered = listUIAdapters() - .filter((a) => isAgentAdapterType(a.type) && !disabledTypes.has(a.type)); + .filter((a) => + isAgentAdapterType(a.type) && + !disabledTypes.has(a.type) && + isVisualAdapterChoice(a.type) + ); // Sort: recommended first, then alphabetical return registered diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 9ec608f3..5000d759 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -25,6 +25,7 @@ import { } from "../lib/model-utils"; import { getUIAdapter } from "../adapters"; import { listUIAdapters } from "../adapters"; +import { isVisualAdapterChoice } from "../adapters/metadata"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { getAdapterDisplay } from "../adapters/adapter-display-registry"; @@ -209,7 +210,11 @@ export function OnboardingWizard() { const { recommendedAdapters, moreAdapters } = useMemo(() => { const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]); const all = listUIAdapters() - .filter((a) => !SYSTEM_ADAPTER_TYPES.has(a.type) && !disabledTypes.has(a.type)) + .filter((a) => + !SYSTEM_ADAPTER_TYPES.has(a.type) && + !disabledTypes.has(a.type) && + isVisualAdapterChoice(a.type) + ) .map((a) => ({ ...getAdapterDisplay(a.type), type: a.type })); return { diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 0eca9441..d1dddfed 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -27,6 +27,7 @@ import { PageTabBar } from "../components/PageTabBar"; import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { useAdapterCapabilities } from "@/adapters/use-adapter-capabilities"; +import { redactCommandText as redactCommandSecretText } from "@paperclipai/adapter-utils"; import { MarkdownEditor } from "../components/MarkdownEditor"; import { assetsApi } from "../api/assets"; import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters"; @@ -115,6 +116,7 @@ const RUN_LOG_PAGE_BYTES = 256_000; const REDACTED_ENV_VALUE = "***REDACTED***"; const SECRET_ENV_KEY_RE = /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; +const COMMAND_ENV_KEY_RE = /(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i; const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/; function redactPathText(value: string, censorUsernameInLogs: boolean) { @@ -125,6 +127,10 @@ function redactPathValue(value: T, censorUsernameInLogs: boolean): T { return redactHomePathUserSegmentsInValue(value, { enabled: censorUsernameInLogs }); } +function redactCommandText(value: string, censorUsernameInLogs: boolean): string { + return redactPathText(redactCommandSecretText(value, REDACTED_ENV_VALUE), censorUsernameInLogs); +} + function shouldRedactSecretValue(key: string, value: unknown): boolean { if (SECRET_ENV_KEY_RE.test(key)) return true; if (typeof value !== "string") return false; @@ -142,6 +148,7 @@ function redactEnvValue(key: string, value: unknown, censorUsernameInLogs: boole } if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE; if (value === null || value === undefined) return ""; + if (typeof value === "string" && COMMAND_ENV_KEY_RE.test(key)) return redactCommandText(value, censorUsernameInLogs); if (typeof value === "string") return redactPathText(value, censorUsernameInLogs); try { return JSON.stringify(redactPathValue(value, censorUsernameInLogs)); @@ -302,7 +309,7 @@ export function RunInvocationCard({ payload: Record; censorUsernameInLogs: boolean; }) { - const commandLine = [ + const rawCommandLine = [ typeof payload.command === "string" ? payload.command : null, ...(Array.isArray(payload.commandArgs) ? payload.commandArgs.filter((value): value is string => typeof value === "string") @@ -310,6 +317,7 @@ export function RunInvocationCard({ ] .filter((value): value is string => Boolean(value)) .join(" "); + const commandLine = rawCommandLine ? redactCommandText(rawCommandLine, censorUsernameInLogs) : ""; const hasAdvancedDetails = commandLine.length > 0 @@ -2466,7 +2474,7 @@ function PromptEditorSkeleton() { ); } -function AgentSkillsTab({ +export function AgentSkillsTab({ agent, companyId, }: { @@ -2649,11 +2657,18 @@ function AgentSkillsTab({ }, [skillSnapshot?.mode]); const unsupportedSkillMessage = useMemo(() => { if (skillSnapshot?.mode !== "unsupported") return null; + if ( + agent.adapterType === "acpx_local" && + typeof agent.adapterConfig.agent === "string" && + agent.adapterConfig.agent === "custom" + ) { + return "Paperclip cannot manage skills for custom ACP commands yet."; + } if (agent.adapterType === "openclaw_gateway") { return "Paperclip cannot manage OpenClaw skills here. Visit your OpenClaw instance to manage this agent's skills."; } return "Paperclip cannot manage skills for this adapter yet. Manage them in the adapter directly."; - }, [agent.adapterType, skillSnapshot?.mode]); + }, [agent.adapterConfig.agent, agent.adapterType, skillSnapshot?.mode]); const hasUnsavedChanges = !arraysEqual(skillDraft, lastSavedSkills); const saveStatusLabel = syncSkills.isPending ? "Saving changes..." diff --git a/ui/storybook/.storybook/preview.tsx b/ui/storybook/.storybook/preview.tsx index 3d0397d2..8871f536 100644 --- a/ui/storybook/.storybook/preview.tsx +++ b/ui/storybook/.storybook/preview.tsx @@ -26,6 +26,12 @@ import "@mdxeditor/editor/style.css"; import "./tailwind-entry.css"; import "./styles.css"; +// Install fetch monkeypatch eagerly so any module-load-time fetches (e.g. schema +// caches in adapter config renderers) hit our fixtures before they reach the +// network. Some renderers issue a fetch from useEffect on first paint, which +// can otherwise race the StorybookProviders mount. +installStorybookApiFixtures(); + function installStorybookApiFixtures() { if (typeof window === "undefined") return; const currentWindow = window as typeof window & { @@ -148,6 +154,16 @@ function installStorybookApiFixtures() { return Response.json([]); } + const adapterSchemaMatch = url.pathname.match(/^\/api\/adapters\/([^/]+)\/config-schema$/); + if (adapterSchemaMatch) { + const [, adapterType] = adapterSchemaMatch; + const schemas = (window as typeof window & { + __paperclipStorybookAdapterSchemas?: Record; + }).__paperclipStorybookAdapterSchemas; + const schema = schemas?.[adapterType]; + if (schema) return Response.json(schema); + } + const companyResourceMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/([^/]+)$/); if (companyResourceMatch) { const [, companyId, resource] = companyResourceMatch; @@ -233,7 +249,6 @@ function StorybookProviders({ useEffect(() => { applyStorybookTheme(theme); - installStorybookApiFixtures(); }, [theme]); return ( diff --git a/ui/storybook/stories/acpx-local.stories.tsx b/ui/storybook/stories/acpx-local.stories.tsx new file mode 100644 index 00000000..93d30ed2 --- /dev/null +++ b/ui/storybook/stories/acpx-local.stories.tsx @@ -0,0 +1,896 @@ +import { useMemo, useState, type ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useQueryClient } from "@tanstack/react-query"; +import type { AdapterConfigSchema, CreateConfigValues } from "@paperclipai/adapter-utils"; +import { parseAcpxStdoutLine } from "@paperclipai/adapter-acpx-local/ui"; +import type { + Agent, + AgentSkillSnapshot, + CompanySkillListItem, +} from "@paperclipai/shared"; +import { SchemaConfigFields } from "@/adapters/schema-config-fields"; +import type { TranscriptEntry } from "@/adapters"; +import { RunTranscriptView } from "@/components/transcript/RunTranscriptView"; +import { AgentSkillsTab } from "@/pages/AgentDetail"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { queryKeys } from "@/lib/queryKeys"; + +type SchemaWindow = typeof window & { + __paperclipStorybookAdapterSchemas?: Record; +}; + +// Mirrors packages/adapters/acpx-local/src/server/config-schema.ts. Inlined so the +// storybook bundle does not pull node-only imports from the adapter server entry. +const acpxLocalConfigSchema: AdapterConfigSchema = { + fields: [ + { + key: "agent", + label: "ACP agent", + type: "select", + default: "claude", + required: true, + options: [ + { value: "claude", label: "Claude via ACPX" }, + { value: "codex", label: "Codex via ACPX" }, + { value: "custom", label: "Custom ACP command" }, + ], + hint: "Choose the ACP agent launched through ACPX.", + }, + { + key: "agentCommand", + label: "Agent command", + type: "text", + hint: "Required for custom agents; optional override for built-in Claude or Codex ACP commands.", + }, + { + key: "mode", + label: "Session mode", + type: "select", + default: "persistent", + options: [ + { value: "persistent", label: "Persistent" }, + { value: "oneshot", label: "One shot" }, + ], + }, + { + key: "permissionMode", + label: "Permission mode", + type: "select", + default: "approve-all", + options: [ + { value: "approve-all", label: "Approve all" }, + { value: "default", label: "ACP default" }, + ], + hint: "Defaults to maximum permissions: ACPX permission requests are auto-approved.", + }, + { + key: "nonInteractivePermissions", + label: "Non-interactive permissions", + type: "select", + default: "deny", + options: [ + { value: "deny", label: "Deny" }, + { value: "fail", label: "Fail" }, + ], + }, + { + key: "cwd", + label: "Working directory", + type: "text", + hint: "Absolute fallback directory. Paperclip execution workspaces can override this at runtime.", + }, + { + key: "stateDir", + label: "State directory", + type: "text", + hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.", + }, + { + key: "instructionsFilePath", + label: "Instructions file", + type: "text", + hint: "Optional absolute path to markdown instructions injected into the run prompt.", + }, + { key: "promptTemplate", label: "Prompt template", type: "textarea" }, + { key: "bootstrapPromptTemplate", label: "Bootstrap prompt template", type: "textarea" }, + { key: "timeoutSec", label: "Timeout seconds", type: "number", default: 0 }, + { + key: "env", + label: "Environment JSON", + type: "textarea", + hint: "Optional JSON object of environment values or secret bindings.", + }, + ], +}; + +function installAcpxSchemaMock(): void { + if (typeof window === "undefined") return; + const win = window as SchemaWindow; + win.__paperclipStorybookAdapterSchemas = { + ...(win.__paperclipStorybookAdapterSchemas ?? {}), + acpx_local: acpxLocalConfigSchema, + }; +} + +function ConfigSection({ title, description, children }: { title: string; description?: string; children: ReactNode }) { + return ( + + + {title} + {description && ( +

{description}

+ )} +
+ +
{children}
+
+
+ ); +} + +function AcpxLocalConfigStory() { + installAcpxSchemaMock(); + + const [values, setValues] = useState(() => ({ + name: "", + role: "", + title: "", + capabilities: "", + icon: "code", + adapterType: "acpx_local", + command: "", + promptTemplate: "", + bootstrapPromptTemplate: "", + instructionsFilePath: "", + extraArgs: "", + envVars: "", + envBindings: {}, + runtimeServicesJson: "", + runtimeDesiredState: "manual", + runtimeServiceStates: {}, + heartbeatEnabled: false, + intervalSec: 900, + wakeOnDemand: true, + cooldownSec: 60, + maxConcurrentRuns: 1, + pauseOnIdle: false, + idleTimeoutSec: 0, + runtimeMaxStuckHeartbeats: 0, + adapterSchemaValues: {}, + } as unknown as CreateConfigValues)); + + return ( +
+
+ + UX preview + +

Agent config — acpx_local

+

+ Renders the schema-driven adapter config block exactly as the operator sees it inside the agent edit form. + Defaults reflect Phase 3 of PAP-2944: maximum-permission auto-approve, persistent session mode, Claude as the + default ACP agent. +

+
+ + + setValues((current) => ({ ...current, ...patch }))} + config={{}} + eff={(_group, _field, original) => original} + mark={() => {}} + models={[]} + /> + + + +
+          {JSON.stringify(values.adapterSchemaValues ?? {}, null, 2)}
+        
+
+
+ ); +} + +const ACPX_TS_BASE = new Date("2026-04-30T15:30:00.000Z").getTime(); + +function ts(offsetMs: number): string { + return new Date(ACPX_TS_BASE + offsetMs).toISOString(); +} + +function flattenLines(lines: Array<{ payload: Record; offsetMs: number }>): TranscriptEntry[] { + const entries: TranscriptEntry[] = []; + for (const { payload, offsetMs } of lines) { + const parsed = parseAcpxStdoutLine(JSON.stringify(payload), ts(offsetMs)); + entries.push(...parsed); + } + return entries; +} + +function useAcpxTranscript(): TranscriptEntry[] { + return useMemo( + () => + flattenLines([ + { + offsetMs: 0, + payload: { + type: "acpx.session", + agent: "claude", + mode: "persistent", + permissionMode: "approve-all", + acpSessionId: "acp_session_42a8c1", + runtimeSessionName: "acpx-claude-PAP-1812", + }, + }, + { + offsetMs: 800, + payload: { + type: "acpx.status", + tag: "context_window", + used: 12000, + size: 200000, + }, + }, + { + offsetMs: 1200, + payload: { + type: "acpx.text_delta", + text: "Looking at the failing test in `runtime-state.test.ts` — ", + channel: "thought", + }, + }, + { + offsetMs: 1500, + payload: { + type: "acpx.text_delta", + text: "the assertion expects `pendingRestart` but the new state machine uses `restartScheduled`.\n", + channel: "thought", + }, + }, + { + offsetMs: 1900, + payload: { + type: "acpx.text_delta", + text: "I'll inspect the test file to confirm the change.\n\n", + channel: "output", + tag: "agent_message_chunk", + }, + }, + { + offsetMs: 2200, + payload: { + type: "acpx.tool_call", + name: "read", + toolCallId: "tool_read_01", + status: "running", + text: "server/src/runtime-state.test.ts", + input: { path: "server/src/runtime-state.test.ts" }, + }, + }, + { + offsetMs: 3500, + payload: { + type: "acpx.tool_call", + name: "read", + toolCallId: "tool_read_01", + status: "completed", + text: "Read 142 lines", + }, + }, + { + offsetMs: 3700, + payload: { + type: "acpx.text_delta", + text: + "The test still references the old `pendingRestart` field. I'll update the assertion to use the renamed `restartScheduled` flag.\n\n", + channel: "output", + }, + }, + { + offsetMs: 4200, + payload: { + type: "acpx.tool_call", + name: "edit", + toolCallId: "tool_edit_02", + status: "running", + input: { + path: "server/src/runtime-state.test.ts", + find: "expect(state.pendingRestart).toBe(true)", + replace: "expect(state.restartScheduled).toBe(true)", + }, + }, + }, + { + offsetMs: 5400, + payload: { + type: "acpx.tool_call", + name: "edit", + toolCallId: "tool_edit_02", + status: "completed", + text: "1 replacement", + }, + }, + { + offsetMs: 5800, + payload: { + type: "acpx.status", + text: "Running vitest for runtime-state.test.ts", + }, + }, + { + offsetMs: 6100, + payload: { + type: "acpx.tool_call", + name: "command", + toolCallId: "tool_run_03", + status: "running", + input: { command: "pnpm exec vitest run server/src/runtime-state.test.ts" }, + }, + }, + { + offsetMs: 9100, + payload: { + type: "acpx.tool_call", + name: "command", + toolCallId: "tool_run_03", + status: "completed", + text: + "Test Files 1 passed (1)\nTests 6 passed (6)\nDuration 2.31s", + }, + }, + { + offsetMs: 9400, + payload: { + type: "acpx.text_delta", + text: + "**Test passes.** Updated `runtime-state.test.ts` to assert against `restartScheduled` instead of the renamed `pendingRestart` field.\n\n", + channel: "output", + }, + }, + { + offsetMs: 9600, + payload: { + type: "acpx.text_delta", + text: + "Next I'll update the issue with a summary and hand it back to QA for verification.", + channel: "output", + }, + }, + { + offsetMs: 9800, + payload: { + type: "acpx.status", + tag: "context_window", + used: 18450, + size: 200000, + }, + }, + { + offsetMs: 10000, + payload: { + type: "acpx.result", + summary: "completed", + stopReason: "end_turn", + inputTokens: 18450, + outputTokens: 412, + cachedTokens: 12000, + costUsd: 0.024, + subtype: "end_turn", + }, + }, + ]), + [], + ); +} + +function AcpxLocalTranscriptStory() { + const entries = useAcpxTranscript(); + + return ( +
+
+ + UX preview + +

Run transcript — acpx_local streamed events

+

+ Demonstrates how a streamed acpx_local run renders through the existing transcript pipeline. Events flow + through parseAcpxStdoutLine (session init, thought delta, assistant delta, tool call/result + pairs, context window status, final result) and into RunTranscriptView in nice mode. +

+
+ + + + Run Transcript (nice mode) +

+ Streaming, comfortable density. Mirrors the agent detail page transcript surface. +

+
+ + + +
+ + + + Run Transcript (compact density) +

+ Same parsed events, compact density — matches the live-run widget on the issue thread. +

+
+ + + +
+
+ ); +} + +const SKILLS_COMPANY_ID = "company-storybook"; + +const acpxSkillsCompanyLibrary: CompanySkillListItem[] = [ + { + id: "skill-paperclip", + companyId: SKILLS_COMPANY_ID, + key: "paperclip", + slug: "paperclip", + name: "Paperclip", + description: + "Coordination skill: heartbeats, checkout, comments, and routine API patterns for Paperclip agents.", + sourceType: "local_path", + sourceLocator: "skills/paperclip", + sourceRef: null, + trustLevel: "scripts_executables", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + createdAt: new Date("2026-04-12T09:00:00.000Z"), + updatedAt: new Date("2026-04-22T15:30:00.000Z"), + attachedAgentCount: 4, + editable: false, + editableReason: "Required by Paperclip", + sourceLabel: "Paperclip", + sourceBadge: "paperclip", + sourcePath: "skills/paperclip", + }, + { + id: "skill-design-guide", + companyId: SKILLS_COMPANY_ID, + key: "design-guide", + slug: "design-guide", + name: "Design guide", + description: + "Paperclip UI design system reference: tokens, typography, status colors, and reusable component patterns.", + sourceType: "local_path", + sourceLocator: "skills/design-guide", + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + createdAt: new Date("2026-04-15T10:00:00.000Z"), + updatedAt: new Date("2026-04-25T12:00:00.000Z"), + attachedAgentCount: 2, + editable: true, + editableReason: null, + sourceLabel: "Local", + sourceBadge: "local", + sourcePath: "skills/design-guide", + }, + { + id: "skill-mobile-qa", + companyId: SKILLS_COMPANY_ID, + key: "mobile-app-qa", + slug: "mobile-app-qa", + name: "Mobile app QA", + description: + "Exploratory QA flows for mobile/web apps using Chrome automation. Captures bugs and writes a final report.", + sourceType: "local_path", + sourceLocator: "skills/mobile-app-qa", + sourceRef: null, + trustLevel: "assets", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + createdAt: new Date("2026-04-18T11:00:00.000Z"), + updatedAt: new Date("2026-04-26T09:30:00.000Z"), + attachedAgentCount: 1, + editable: true, + editableReason: null, + sourceLabel: "Local", + sourceBadge: "local", + sourcePath: "skills/mobile-app-qa", + }, +]; + +function buildAcpxAgent({ + agentId, + acpAgent, + desiredSkills, +}: { + agentId: string; + acpAgent: "claude" | "codex" | "custom"; + desiredSkills: string[]; +}): Agent { + return { + id: agentId, + companyId: SKILLS_COMPANY_ID, + name: `ACPX ${acpAgent === "custom" ? "Custom" : acpAgent === "codex" ? "Codex" : "Claude"}`, + urlKey: `acpx-${acpAgent}`, + role: "engineer", + title: `ACPX ${acpAgent} agent`, + icon: "code", + status: "idle", + reportsTo: null, + capabilities: "Routes work through the ACPX adapter for skill-tagged agent flows.", + adapterType: "acpx_local", + adapterConfig: { + agent: acpAgent, + mode: "persistent", + permissionMode: "approve-all", + paperclipSkillSync: { + desiredSkills, + }, + }, + runtimeConfig: {}, + budgetMonthlyCents: 100_000, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date("2026-04-30T12:00:00.000Z"), + updatedAt: new Date("2026-04-30T12:00:00.000Z"), + } as Agent; +} + +function buildAcpxClaudeSnapshot(): AgentSkillSnapshot { + return { + adapterType: "acpx_local", + supported: true, + mode: "ephemeral", + desiredSkills: ["paperclip", "design-guide"], + warnings: [], + entries: [ + { + key: "paperclip", + runtimeName: "paperclip", + desired: true, + managed: true, + required: true, + requiredReason: "Paperclip coordination skill is mandatory for control-plane agents.", + state: "configured", + origin: "paperclip_required", + originLabel: "Required by Paperclip", + readOnly: false, + sourcePath: "skills/paperclip", + targetPath: null, + detail: "Will be mounted into the next ACPX Claude session.", + }, + { + key: "design-guide", + runtimeName: "design-guide", + desired: true, + managed: true, + required: false, + state: "configured", + origin: "company_managed", + originLabel: "Managed by Paperclip", + readOnly: false, + sourcePath: "skills/design-guide", + targetPath: null, + detail: "Will be mounted into the next ACPX Claude session.", + }, + { + key: "mobile-app-qa", + runtimeName: "mobile-app-qa", + desired: false, + managed: true, + required: false, + state: "available", + origin: "company_managed", + originLabel: "Managed by Paperclip", + readOnly: false, + sourcePath: "skills/mobile-app-qa", + targetPath: null, + detail: null, + }, + ], + }; +} + +function buildAcpxCodexSnapshot(): AgentSkillSnapshot { + return { + adapterType: "acpx_local", + supported: true, + mode: "ephemeral", + desiredSkills: ["paperclip"], + warnings: [], + entries: [ + { + key: "paperclip", + runtimeName: "paperclip", + desired: true, + managed: true, + required: true, + requiredReason: "Paperclip coordination skill is mandatory for control-plane agents.", + state: "configured", + origin: "paperclip_required", + originLabel: "Required by Paperclip", + readOnly: false, + sourcePath: "skills/paperclip", + targetPath: null, + detail: "Will be linked into the effective CODEX_HOME/skills/ directory for the next ACPX Codex session.", + }, + { + key: "design-guide", + runtimeName: "design-guide", + desired: false, + managed: true, + required: false, + state: "available", + origin: "company_managed", + originLabel: "Managed by Paperclip", + readOnly: false, + sourcePath: "skills/design-guide", + targetPath: null, + detail: null, + }, + { + key: "mobile-app-qa", + runtimeName: "mobile-app-qa", + desired: false, + managed: true, + required: false, + state: "available", + origin: "company_managed", + originLabel: "Managed by Paperclip", + readOnly: false, + sourcePath: "skills/mobile-app-qa", + targetPath: null, + detail: null, + }, + ], + }; +} + +function buildAcpxCustomSnapshot(): AgentSkillSnapshot { + return { + adapterType: "acpx_local", + supported: false, + mode: "unsupported", + desiredSkills: ["design-guide"], + warnings: [ + "Custom ACP commands do not expose a Paperclip skill integration contract yet; selected skills are tracked only.", + ], + entries: [ + { + key: "paperclip", + runtimeName: "paperclip", + desired: false, + managed: true, + required: true, + requiredReason: "Paperclip coordination skill is mandatory for control-plane agents.", + state: "available", + origin: "paperclip_required", + originLabel: "Required by Paperclip", + readOnly: false, + sourcePath: "skills/paperclip", + targetPath: null, + detail: null, + }, + { + key: "design-guide", + runtimeName: "design-guide", + desired: true, + managed: true, + required: false, + state: "configured", + origin: "company_managed", + originLabel: "Managed by Paperclip", + readOnly: false, + sourcePath: "skills/design-guide", + targetPath: null, + detail: + "Desired state is stored in Paperclip only; custom ACP commands need an explicit skill integration contract before runtime sync is available.", + }, + { + key: "mobile-app-qa", + runtimeName: "mobile-app-qa", + desired: false, + managed: true, + required: false, + state: "available", + origin: "company_managed", + originLabel: "Managed by Paperclip", + readOnly: false, + sourcePath: "skills/mobile-app-qa", + targetPath: null, + detail: null, + }, + ], + }; +} + +function StoryFrame({ + title, + subtitle, + children, +}: { + title: string; + subtitle: string; + children: ReactNode; +}) { + return ( +
+
+ + UX preview + +

{title}

+

{subtitle}

+
+ + + + Agent detail — Skills tab + + {children} + +
+ ); +} + +function AcpxSkillsState({ + agent, + snapshot, + library, +}: { + agent: Agent; + snapshot: AgentSkillSnapshot; + library: CompanySkillListItem[]; +}) { + const queryClient = useQueryClient(); + queryClient.setQueryData(queryKeys.companySkills.list(SKILLS_COMPANY_ID), library); + queryClient.setQueryData(queryKeys.agents.skills(agent.id), snapshot); + return ; +} + +function AcpxClaudeSkillsStory() { + const agent = buildAcpxAgent({ + agentId: "agent-acpx-claude", + acpAgent: "claude", + desiredSkills: ["paperclip", "design-guide"], + }); + return ( + + + + ); +} + +function AcpxCodexSkillsStory() { + const agent = buildAcpxAgent({ + agentId: "agent-acpx-codex", + acpAgent: "codex", + desiredSkills: ["paperclip"], + }); + return ( + + + + ); +} + +function AcpxCustomSkillsStory() { + const agent = buildAcpxAgent({ + agentId: "agent-acpx-custom", + acpAgent: "custom", + desiredSkills: ["design-guide"], + }); + return ( + + + + ); +} + +function AcpxClaudeSkillsLoadingStory() { + const agent = buildAcpxAgent({ + agentId: "agent-acpx-claude-loading", + acpAgent: "claude", + desiredSkills: [], + }); + return ( + + + + ); +} + +function AcpxClaudeSkillsEmptyLibraryStory() { + const agent = buildAcpxAgent({ + agentId: "agent-acpx-claude-empty", + acpAgent: "claude", + desiredSkills: [], + }); + const emptySnapshot: AgentSkillSnapshot = { + adapterType: "acpx_local", + supported: true, + mode: "ephemeral", + desiredSkills: [], + warnings: [], + entries: [], + }; + return ( + + + + ); +} + +const meta: Meta = { + title: "Adapters / acpx_local", + parameters: { + layout: "fullscreen", + }, +}; + +export default meta; + +export const ConfigForm: StoryObj = { + name: "Agent config form", + render: () => , +}; + +export const Transcript: StoryObj = { + name: "Streamed run transcript", + render: () => , +}; + +export const SkillsTabClaude: StoryObj = { + name: "Skills tab — ACPX Claude", + render: () => , +}; + +export const SkillsTabCodex: StoryObj = { + name: "Skills tab — ACPX Codex", + render: () => , +}; + +export const SkillsTabCustom: StoryObj = { + name: "Skills tab — ACPX custom (unsupported)", + render: () => , +}; + +export const SkillsTabLoading: StoryObj = { + name: "Skills tab — loading", + render: () => , +}; + +export const SkillsTabEmptyLibrary: StoryObj = { + name: "Skills tab — empty company library", + render: () => , +}; diff --git a/vitest.config.ts b/vitest.config.ts index c886095d..85fd42d4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ "packages/shared", "packages/db", "packages/adapter-utils", + "packages/adapters/acpx-local", "packages/adapters/claude-local", "packages/adapters/codex-local", "packages/adapters/cursor-local",