diff --git a/cli/src/__tests__/network-bind.test.ts b/cli/src/__tests__/network-bind.test.ts index d75452abafd..513e3853a10 100644 --- a/cli/src/__tests__/network-bind.test.ts +++ b/cli/src/__tests__/network-bind.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { resolveRuntimeBind, validateConfiguredBindMode } from "@paperclipai/shared"; import { buildPresetServerConfig } from "../config/server-bind.js"; +const ORIGINAL_PATH = process.env.PATH; + describe("network bind helpers", () => { it("rejects non-loopback bind modes in local_trusted", () => { expect( @@ -50,13 +52,18 @@ describe("network bind helpers", () => { it("falls back to loopback when no tailscale address is available for tailnet presets", () => { delete process.env.PAPERCLIP_TAILNET_BIND_HOST; + process.env.PATH = ""; - const preset = buildPresetServerConfig("tailnet", { - port: 3100, - allowedHostnames: [], - serveUi: true, - }); + try { + const preset = buildPresetServerConfig("tailnet", { + port: 3100, + allowedHostnames: [], + serveUi: true, + }); - expect(preset.server.host).toBe("127.0.0.1"); + expect(preset.server.host).toBe("127.0.0.1"); + } finally { + process.env.PATH = ORIGINAL_PATH; + } }); }); diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts index 0c694f4be83..b7c01eb5cd7 100644 --- a/cli/src/__tests__/onboard.test.ts +++ b/cli/src/__tests__/onboard.test.ts @@ -7,6 +7,7 @@ import type { PaperclipConfig } from "../config/schema.js"; const ORIGINAL_ENV = { ...process.env }; const ORIGINAL_CWD = process.cwd(); +const ORIGINAL_PATH = process.env.PATH; function createExistingConfigFixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-")); @@ -171,8 +172,13 @@ describe("onboard", () => { it("keeps tailnet quickstart on loopback until tailscale is available", async () => { const configPath = createFreshConfigPath(); delete process.env.PAPERCLIP_TAILNET_BIND_HOST; + process.env.PATH = ""; - await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" }); + try { + await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" }); + } finally { + process.env.PATH = ORIGINAL_PATH; + } const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig; expect(raw.server.deploymentMode).toBe("authenticated"); diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index f41465e2928..341f3b1117e 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -554,10 +554,12 @@ pnpm paperclipai dashboard get See full command reference in `doc/CLI.md`. -## OpenClaw Invite Onboarding Endpoints +## Agent Invite Onboarding Endpoints Agent-oriented invite onboarding now exposes machine-readable API docs: +The board UI generates agent onboarding prompts from the add-agent modal (`+` in the agent sidebar), so agent onboarding sits with the rest of agent creation rather than company member invite settings. + - `GET /api/invites/:token` returns invite summary plus onboarding and skills index links. - `GET /api/invites/:token/onboarding` returns onboarding manifest details (registration endpoint, claim endpoint template, skill install hints). - `GET /api/invites/:token/onboarding.txt` returns a plain-text onboarding doc intended for both human operators and agents (llm.txt-style handoff), including optional inviter message and suggested network host candidates. @@ -575,7 +577,7 @@ pnpm smoke:openclaw-join What it validates: - invite creation for agent-only join -- agent join request using `adapterType=openclaw` +- agent join request using `adapterType=openclaw_gateway` - board approval + one-time API key claim semantics - callback delivery on wakeup to a dockerized OpenClaw-style webhook receiver diff --git a/doc/spec/invite-flow.md b/doc/spec/invite-flow.md index 7a092a8a059..ac71d79d99b 100644 --- a/doc/spec/invite-flow.md +++ b/doc/spec/invite-flow.md @@ -6,7 +6,7 @@ Date: 2026-04-13 This document maps the current invite creation and acceptance states implemented in: - `ui/src/pages/CompanyInvites.tsx` -- `ui/src/pages/CompanySettings.tsx` +- `ui/src/components/NewAgentDialog.tsx` - `ui/src/pages/InviteLanding.tsx` - `server/src/routes/access.ts` - `server/src/lib/join-request-dedupe.ts` @@ -23,9 +23,9 @@ This document maps the current invite creation and acceptance states implemented ```mermaid flowchart TD - Board[Board user on invite screen] + Board[Board user on invite or add-agent screen] HumanInvite[Create human company invite] - OpenClawInvite[Generate OpenClaw invite prompt] + AgentInvite[Generate agent onboarding prompt] Active[Invite state: active] Revoked[Invite state: revoked] Expired[Invite state: expired] @@ -44,7 +44,7 @@ flowchart TD OpenClawReplay[Special replay path:
accepted invite can be POSTed again
for openclaw_gateway only] Board --> HumanInvite --> Active - Board --> OpenClawInvite --> Active + Board --> AgentInvite --> Active Active --> Revoked: revoke Active --> Expired: expiresAt passes @@ -100,10 +100,10 @@ stateDiagram-v2 LatestInviteVisible --> Ready: navigate away or refresh } - CompanySelection --> OpenClawPromptReady: Company settings prompt generator - OpenClawPromptReady --> OpenClawPromptPending: Generate OpenClaw Invite Prompt - OpenClawPromptPending --> OpenClawSnippetVisible: prompt generated - OpenClawPromptPending --> OpenClawPromptReady: generation failed + CompanySelection --> AgentPromptReady: Add-agent modal prompt generator + AgentPromptReady --> AgentPromptPending: Generate agent onboarding prompt + AgentPromptPending --> AgentSnippetVisible: prompt generated + AgentPromptPending --> AgentPromptReady: generation failed ``` ## Invite Landing Screen States @@ -244,21 +244,21 @@ sequenceDiagram sequenceDiagram autonumber actor Board as Board user - participant Settings as Company Settings UI + participant AddAgent as Add agent modal participant API as Access routes participant Invites as invites table - actor Gateway as OpenClaw gateway agent + actor Gateway as External agent participant Join as join_requests table actor Approver as Company admin participant Agents as agents table participant Keys as agent_api_keys table - Board->>Settings: Generate OpenClaw invite prompt - Settings->>API: POST /api/companies/:companyId/openclaw-invite-prompt + Board->>AddAgent: Generate agent onboarding prompt + AddAgent->>API: POST /api/companies/:companyId/invites (allowedJoinTypes=agent) API->>Invites: Insert active agent invite - API-->>Settings: Prompt text + invite token + API-->>AddAgent: Prompt text + invite token - Gateway->>API: POST /api/invites/:token/accept (agent, openclaw_gateway) + Gateway->>API: POST /api/invites/:token/accept (agent, adapter-specific payload) API->>Invites: Mark invite accepted API->>Join: Insert pending_approval join request + claimSecretHash API-->>Gateway: requestId + claimSecret + claimApiKeyPath diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 62375d7da21..13e9e9120da 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -138,6 +138,13 @@ async function createTarballFromDirectory(input: { const excludeArgs = ["._*", ...(input.exclude ?? [])].flatMap((entry) => ["--exclude", entry]); await execTar([ "-c", + // Prevent macOS bsdtar from embedding LIBARCHIVE.xattr.* PAX extended + // headers for extended attributes (e.g. com.apple.provenance). GNU tar on + // Linux does not recognise these proprietary headers and fails extraction + // with "This does not look like a tar archive". COPYFILE_DISABLE=1 (set in + // execTar) already suppresses AppleDouble ._* sidecar files; --no-xattrs + // additionally suppresses the inline PAX xattr entries. + "--no-xattrs", ...(input.followSymlinks ? ["-h"] : []), "-f", input.archivePath, diff --git a/packages/adapters/acpx-local/src/server/execute.test.ts b/packages/adapters/acpx-local/src/server/execute.test.ts index b009b1bcb13..4d1f28afd59 100644 --- a/packages/adapters/acpx-local/src/server/execute.test.ts +++ b/packages/adapters/acpx-local/src/server/execute.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import type { AcpRuntimeOptions } from "acpx/runtime"; import { createAcpxLocalExecutor } from "./execute.js"; const tempRoots: string[] = []; @@ -376,6 +377,126 @@ describe("acpx_local runtime skill isolation", () => { expect(envFiles.filter((contents) => contents.includes("PAPERCLIP_API_KEY='second-key'"))).toHaveLength(1); }); + it("enriches acpx.error diagnostics and child stderr when ensureSession rejects", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const runStderrDir = path.join(stateDir, "run-stderr"); + await fs.mkdir(runStderrDir, { recursive: true }); + const stderrTail = "claude-agent-acp: SDK init failed (auth missing)"; + await fs.writeFile(path.join(runStderrDir, "run-1.log"), `${stderrTail}\n`, "utf8"); + + class FakeAcpRuntimeError extends Error { + readonly code = "ACP_SESSION_INIT_FAILED"; + readonly cause: Error; + readonly retryable = false; + constructor(message: string, cause: Error) { + super(message); + this.name = "AcpRuntimeError"; + this.cause = cause; + } + } + + const logs: Array<{ stream: string; text: string }> = []; + const execute = createAcpxLocalExecutor({ + createRuntime: () => ({ + ensureSession: async () => { + throw new FakeAcpRuntimeError( + "session/new failed: backend rejected initialize", + new Error("upstream timeout"), + ); + }, + startTurn: () => ({ + events: (async function* () {})(), + result: Promise.resolve({ status: "completed", stopReason: "end_turn" }), + cancel: async () => {}, + }), + close: async () => {}, + }) as never, + }); + + const result = await execute({ + runId: "run-1", + agent: { id: "agent-1", companyId: "company-1" }, + runtime: {}, + config: { + agent: "custom", + agentCommand: "node ./fake-acp.js", + stateDir, + }, + context: {}, + onLog: async (stream: "stdout" | "stderr", text: string) => { + logs.push({ stream, text }); + }, + onMeta: async () => {}, + } as never); + + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("acpx_session_init_failed"); + const meta = result.errorMeta ?? {}; + expect(meta.errorName).toBe("AcpRuntimeError"); + expect(meta.acpCode).toBe("ACP_SESSION_INIT_FAILED"); + expect(meta.causeMessage).toBe("upstream timeout"); + expect(meta.retryable).toBe(false); + expect(typeof meta.stackPreview).toBe("string"); + expect(meta.phase).toBe("ensure_session"); + + const errorLogLine = logs.find((entry) => entry.stream === "stdout" && entry.text.includes("\"type\":\"acpx.error\"")); + expect(errorLogLine).toBeTruthy(); + const errorPayload = JSON.parse(errorLogLine!.text.trim()); + expect(errorPayload.phase).toBe("ensure_session"); + expect(errorPayload.errorName).toBe("AcpRuntimeError"); + expect(errorPayload.acpCode).toBe("ACP_SESSION_INIT_FAILED"); + expect(errorPayload.causeMessage).toBe("upstream timeout"); + expect(errorPayload.childStderrTail).toContain("SDK init failed"); + + const stderrLog = logs.find((entry) => entry.stream === "stderr" && entry.text.includes("ACPX child stderr tail")); + expect(stderrLog).toBeTruthy(); + expect(stderrLog!.text).toContain(stderrTail); + }); + + it("writes wrapper that redirects child stderr to a per-run log file", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + + const runtimeOptions: AcpRuntimeOptions[] = []; + const execute = createAcpxLocalExecutor({ + createRuntime: (options) => { + runtimeOptions.push(options as unknown as AcpRuntimeOptions); + return buildRuntime() as never; + }, + }); + + const result = await execute({ + runId: "run-stderr-1", + agent: { id: "agent-1", companyId: "company-1" }, + runtime: {}, + config: { + agent: "custom", + agentCommand: "node ./fake-acp.js", + stateDir, + }, + context: {}, + onLog: async () => {}, + onMeta: async () => {}, + } as never); + + expect(result.exitCode).toBe(0); + const verboseFlags = runtimeOptions.map((options) => (options as { verbose?: boolean }).verbose); + // verbose is scoped to the claude agent (PAPA-388); the custom agent here + // should not opt in to ACPX runtime verbose session-event logs. + expect(verboseFlags.every((flag) => flag === false)).toBe(true); + + const wrappers = await fs.readdir(path.join(stateDir, "wrappers")); + const wrapperFile = wrappers.find((name) => name.endsWith(".sh")); + expect(wrapperFile).toBeTruthy(); + const wrapper = await fs.readFile(path.join(stateDir, "wrappers", wrapperFile!), "utf8"); + expect(wrapper).toContain("stderr_dir="); + expect(wrapper).toContain("run-stderr"); + expect(wrapper).toContain("PAPERCLIP_RUN_ID"); + expect(wrapper).toContain("tee -a"); + expect(wrapper).toContain("exec node ./fake-acp.js"); + }); + it("passes Paperclip env through the ACP agent wrapper instead of process.env", async () => { let observedApiKeyDuringStream: string | undefined; const execute = createAcpxLocalExecutor({ @@ -422,4 +543,160 @@ describe("acpx_local runtime skill isolation", () => { else process.env.PAPERCLIP_API_KEY = previousApiKey; } }); + + it("writes a Paperclip-managed .claude/settings.local.json for the claude agent so it can reach the Paperclip API", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + + const { meta } = await runExecutor( + { agent: "claude", stateDir, cwd }, + { context: { paperclipWorkspace: { cwd, agentHome: path.join(root, "agent-home") } } }, + ); + + const settingsPath = path.join(cwd, ".claude", "settings.local.json"); + const written = JSON.parse(await fs.readFile(settingsPath, "utf8")) as { + permissions?: { + allow?: unknown; + additionalDirectories?: unknown; + defaultMode?: unknown; + }; + }; + expect(written.permissions?.defaultMode).toBe("default"); + const allow = written.permissions?.allow; + expect(Array.isArray(allow)).toBe(true); + expect(allow).toContain("Bash(curl:*)"); + expect(allow).toContain(`Bash(${cwd}/scripts/paperclip-issue-update.sh:*)`); + const additionalDirectories = written.permissions?.additionalDirectories as string[] | undefined; + expect(Array.isArray(additionalDirectories)).toBe(true); + expect(additionalDirectories).toContain(stateDir); + expect(additionalDirectories).toContain(path.join(root, "agent-home")); + + const note = (meta[0]?.commandNotes as string[] | undefined)?.find((entry) => + entry.includes("Paperclip-managed Claude settings"), + ); + expect(note).toBeTruthy(); + }); + + it("merges Paperclip allowlist into an existing .claude/settings.local.json without losing user entries", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(path.join(cwd, ".claude"), { recursive: true }); + await fs.writeFile( + path.join(cwd, ".claude", "settings.local.json"), + JSON.stringify( + { + statusLine: { type: "command", command: "preserve-me" }, + permissions: { + allow: ["Bash(npm test:*)"], + additionalDirectories: ["/Users/example/custom"], + defaultMode: "acceptEdits", + }, + }, + null, + 2, + ), + "utf8", + ); + + await runExecutor( + { agent: "claude", stateDir, cwd }, + { context: { paperclipWorkspace: { cwd } } }, + ); + + const written = JSON.parse( + await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"), + ) as { + statusLine?: unknown; + permissions?: { + allow?: string[]; + additionalDirectories?: string[]; + defaultMode?: string; + }; + }; + expect(written.statusLine).toEqual({ type: "command", command: "preserve-me" }); + expect(written.permissions?.defaultMode).toBe("acceptEdits"); + expect(written.permissions?.allow).toContain("Bash(npm test:*)"); + expect(written.permissions?.allow).toContain("Bash(curl:*)"); + expect(written.permissions?.additionalDirectories).toContain("/Users/example/custom"); + expect(written.permissions?.additionalDirectories).toContain(stateDir); + }); + + it("overrides a user-supplied dontAsk defaultMode so ACPX can route Bash through canUseTool", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(path.join(cwd, ".claude"), { recursive: true }); + await fs.writeFile( + path.join(cwd, ".claude", "settings.local.json"), + JSON.stringify({ permissions: { defaultMode: "dontAsk" } }, null, 2), + "utf8", + ); + + const { meta } = await runExecutor( + { agent: "claude", stateDir, cwd }, + { context: { paperclipWorkspace: { cwd } } }, + ); + + const written = JSON.parse( + await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"), + ) as { permissions?: { defaultMode?: string } }; + expect(written.permissions?.defaultMode).toBe("default"); + + const overrideNote = (meta[0]?.commandNotes as string[] | undefined)?.find((entry) => + entry.includes("overrode user dontAsk"), + ); + expect(overrideNote).toBeTruthy(); + }); + + it("opts the claude agent into ACPX runtime verbose logs but leaves codex/custom agents quiet", async () => { + const root = await makeTempRoot(); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + + const verboseByAgent: Record = {}; + for (const agent of ["claude", "codex", "custom"] as const) { + const runtimeOptions: AcpRuntimeOptions[] = []; + const execute = createAcpxLocalExecutor({ + createRuntime: (options) => { + runtimeOptions.push(options as AcpRuntimeOptions); + return buildRuntime() as never; + }, + }); + const result = await execute({ + runId: `run-${agent}`, + agent: { id: `agent-${agent}`, companyId: "company-1" }, + runtime: {}, + config: + agent === "custom" + ? { agent, agentCommand: "node ./fake-acp.js", stateDir: path.join(root, `state-${agent}`), cwd } + : { agent, stateDir: path.join(root, `state-${agent}`), cwd }, + context: { paperclipWorkspace: { cwd } }, + onLog: async () => {}, + onMeta: async () => {}, + } as never); + expect(result.exitCode).toBe(0); + verboseByAgent[agent] = (runtimeOptions[0] as { verbose?: boolean } | undefined)?.verbose; + } + + expect(verboseByAgent.claude).toBe(true); + expect(verboseByAgent.codex).toBe(false); + expect(verboseByAgent.custom).toBe(false); + }); + + it("does not touch .claude/settings.local.json for the codex agent", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + + await runExecutor( + { agent: "codex", stateDir, cwd }, + { context: { paperclipWorkspace: { cwd } } }, + ); + + expect(await pathExists(path.join(cwd, ".claude", "settings.local.json"))).toBe(false); + }); }); diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapters/acpx-local/src/server/execute.ts index 4914af4499e..d465b02c2e1 100644 --- a/packages/adapters/acpx-local/src/server/execute.ts +++ b/packages/adapters/acpx-local/src/server/execute.ts @@ -94,6 +94,8 @@ interface AcpxPreparedRuntime { remoteExecutionIdentity: Record | null; skillPromptInstructions: string; skillsIdentity: Record; + childStderrLogPath: string | null; + paperclipClaudeSettings: PaperclipClaudeSettingsResult | null; } const defaultWarmHandles = new Map(); @@ -564,11 +566,105 @@ function buildSessionParams(input: { }; } +interface PaperclipClaudeSettingsResult { + filePath: string; + allow: string[]; + additionalDirectories: string[]; + defaultMode: string; + overrodeDontAsk: boolean; +} + +function uniqueSorted(values: Array): string[] { + return [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0))].sort(); +} + +// Phase 4.1 (PAPA-388): the Claude Code SDK that `claude-agent-acp` runs uses +// `settingSources: ["user", "project", "local"]`. By writing a per-worktree +// `.claude/settings.local.json` we override the user's potentially-restrictive +// `~/.claude/settings.json` (e.g. `defaultMode: "dontAsk"`, which silently +// denies every non-allowlisted tool and never reaches `canUseTool`), and we +// widen the SDK's Read sandbox to include the Paperclip state dirs the agent +// needs to talk to its own control plane. +async function writePaperclipClaudeSettings(input: { + cwd: string; + stateDir: string; + agentHome: string; + companyId: string; +}): Promise { + const filePath = path.join(input.cwd, ".claude", "settings.local.json"); + const instanceRoot = defaultPaperclipInstanceDir(); + const companyRoot = path.join(instanceRoot, "companies", input.companyId); + const paperclipAdditionalDirectories = uniqueSorted([ + input.stateDir, + input.agentHome, + companyRoot, + ]); + const paperclipAllow = uniqueSorted([ + "Bash(curl:*)", + "Bash(env:*)", + "Bash(env)", + `Bash(${input.cwd}/scripts/paperclip-issue-update.sh:*)`, + `Bash(${input.cwd}/scripts/paperclip:*)`, + ]); + + let existing: Record = {}; + const existingRaw = await fs.readFile(filePath, "utf8").catch(() => null); + if (existingRaw) { + try { + const parsed = JSON.parse(existingRaw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) existing = parsed as Record; + } catch { + // Malformed settings file — leave it alone in `existing` and our merge will replace it with a valid one. + } + } + const existingPerms = + existing.permissions && typeof existing.permissions === "object" && !Array.isArray(existing.permissions) + ? (existing.permissions as Record) + : {}; + const existingAllow = Array.isArray(existingPerms.allow) + ? (existingPerms.allow as unknown[]).filter((value): value is string => typeof value === "string") + : []; + const existingAdditionalDirectories = Array.isArray(existingPerms.additionalDirectories) + ? (existingPerms.additionalDirectories as unknown[]).filter((value): value is string => typeof value === "string") + : []; + const mergedAllow = uniqueSorted([...existingAllow, ...paperclipAllow]); + const mergedAdditionalDirectories = uniqueSorted([ + ...existingAdditionalDirectories, + ...paperclipAdditionalDirectories, + ]); + const existingDefaultMode = + typeof existingPerms.defaultMode === "string" ? (existingPerms.defaultMode as string) : ""; + const defaultMode = + existingDefaultMode && existingDefaultMode !== "dontAsk" ? existingDefaultMode : "default"; + const overrodeDontAsk = existingDefaultMode === "dontAsk"; + + const nextPermissions: Record = { + ...existingPerms, + allow: mergedAllow, + additionalDirectories: mergedAdditionalDirectories, + defaultMode, + }; + const next: Record = { ...existing, permissions: nextPermissions }; + await writeFileAtomically({ + target: filePath, + contents: `${JSON.stringify(next, null, 2)}\n`, + mode: 0o600, + }); + return { + filePath, + allow: mergedAllow, + additionalDirectories: mergedAdditionalDirectories, + defaultMode, + overrodeDontAsk, + }; +} + async function writeAgentWrapper(input: { stateDir: string; acpxAgent: string; agentCommandShell: string; env: Record; + childStderrDir: string; }): Promise<{ wrapperPath: string; envFilePath: string }> { const wrappersDir = path.join(input.stateDir, "wrappers"); await fs.mkdir(wrappersDir, { recursive: true }); @@ -580,6 +676,7 @@ async function writeAgentWrapper(input: { agent: input.acpxAgent, command: input.agentCommandShell, env: envLines, + childStderrDir: input.childStderrDir, }); const wrapperPath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.sh`); const envFilePath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.env`); @@ -592,6 +689,11 @@ async function writeAgentWrapper(input: { " source \"$env_file\"", " set +a", "fi", + `stderr_dir=${shellQuote(input.childStderrDir)}`, + "if [[ -n \"${PAPERCLIP_RUN_ID:-}\" ]]; then", + " mkdir -p \"$stderr_dir\"", + " exec 2> >(tee -a \"$stderr_dir/$PAPERCLIP_RUN_ID.log\" >&2)", + "fi", `exec ${input.agentCommandShell} "$@"`, "", ].join("\n"); @@ -723,10 +825,20 @@ async function buildRuntime(input: { if (typeof value === "string") env[key] = value; } if (!hasExplicitApiKey && authToken) env.PAPERCLIP_API_KEY = authToken; + // For the claude agent, set model via ANTHROPIC_MODEL at startup rather than + // via session/set_config_option — the ACP server's set_config_option handler + // validates the value against its internal available-models list and rejects + // bare model IDs (e.g. "claude-opus-4-7") that don't exactly match a model + // entry in some versions. ANTHROPIC_MODEL is read during initialization, so + // it reliably sets the model before any turns are run. + if (requestedModel && acpxAgent === "claude" && !env.ANTHROPIC_MODEL) { + env.ANTHROPIC_MODEL = requestedModel; + } let skillPromptInstructions = ""; let skillsIdentity: Record = { mode: "unsupported" }; const skillCommandNotes: string[] = []; + let paperclipClaudeSettings: PaperclipClaudeSettingsResult | null = null; if (acpxAgent === "claude") { const preparedSkills = await prepareClaudeSkillRuntime({ stateDir, @@ -736,6 +848,17 @@ async function buildRuntime(input: { skillPromptInstructions = preparedSkills.promptInstructions; skillsIdentity = preparedSkills.identity; skillCommandNotes.push(...preparedSkills.commandNotes); + paperclipClaudeSettings = await writePaperclipClaudeSettings({ + cwd, + stateDir, + agentHome, + companyId: agent.companyId, + }); + skillCommandNotes.push( + `Wrote Paperclip-managed Claude settings to ${paperclipClaudeSettings.filePath} (defaultMode=${paperclipClaudeSettings.defaultMode}${ + paperclipClaudeSettings.overrodeDontAsk ? "; overrode user dontAsk" : "" + }, +${paperclipClaudeSettings.additionalDirectories.length} read root(s), +${paperclipClaudeSettings.allow.length} allow rule(s)).`, + ); } else if (acpxAgent === "codex") { const preparedSkills = await prepareCodexSkillRuntime({ companyId: agent.companyId, @@ -757,12 +880,15 @@ async function buildRuntime(input: { const builtInCommand = resolveBuiltInAgentCommand(acpxAgent); const agentCommand = configuredCommand || builtInCommand || null; const agentCommandShell = configuredCommand || (builtInCommand ? shellQuote(builtInCommand) : ""); + const childStderrDir = path.join(stateDir, "run-stderr"); + const childStderrLogPath = agentCommand ? path.join(childStderrDir, `${runId}.log`) : null; const wrapper = agentCommand ? await writeAgentWrapper({ stateDir, acpxAgent, agentCommandShell, env, + childStderrDir, }) : null; const wrapperPath = wrapper?.wrapperPath ?? null; @@ -781,6 +907,13 @@ async function buildRuntime(input: { remoteExecutionIdentity, skillsIdentity, skillPromptInstructions, + paperclipClaudeSettings: paperclipClaudeSettings + ? { + allow: paperclipClaudeSettings.allow, + additionalDirectories: paperclipClaudeSettings.additionalDirectories, + defaultMode: paperclipClaudeSettings.defaultMode, + } + : null, }); const taskKey = asString(input.ctx.runtime.taskKey, "") || wakeTaskId || workspaceId || "default"; const sessionKey = `paperclip:${agent.companyId}:${agent.id}:${taskKey}:${fingerprint}`; @@ -817,12 +950,19 @@ async function buildRuntime(input: { ...skillsIdentity, commandNotes: skillCommandNotes, }, + childStderrLogPath, + paperclipClaudeSettings, }; } function sessionConfigOptions(prepared: AcpxPreparedRuntime): Array<{ key: string; value: string }> { const options: Array<{ key: string; value: string }> = []; - if (prepared.requestedModel) options.push({ key: "model", value: prepared.requestedModel }); + // Model for the claude agent is pre-set via ANTHROPIC_MODEL env var at + // startup; skip set_config_option to avoid ACP-server model-name validation + // that rejects bare IDs like "claude-opus-4-7" in some runtime versions. + if (prepared.requestedModel && prepared.acpxAgent !== "claude") { + options.push({ key: "model", value: prepared.requestedModel }); + } if (prepared.requestedThinkingEffort) { options.push({ key: prepared.acpxAgent === "codex" ? "reasoning_effort" : "effort", @@ -999,33 +1139,151 @@ function resultErrorMessage(result: AcpRuntimeTurnResult): string | null { return result.error.message; } -function classifyError(err: unknown): Pick { - const message = err instanceof Error ? err.message : String(err); +type AcpxExecutionPhase = "ensure_session" | "configure_session" | "turn"; + +function describeErrorDiagnostics(err: unknown): { + errorName: string; + acpCode: string | null; + causeMessage: string | null; + retryable: boolean | null; + stackPreview: string | null; +} { + const errorName = + err instanceof Error ? err.name || err.constructor.name : typeof 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 acpCode = + isAcpRuntimeError(err) || (maybeCode?.startsWith("ACP_") ?? false) ? maybeCode : null; + const cause = + err && typeof err === "object" && (err as { cause?: unknown }).cause !== undefined + ? (err as { cause?: unknown }).cause + : undefined; + const causeMessage = + cause instanceof Error + ? cause.message + : typeof cause === "string" + ? cause + : null; + const retryable = + err && typeof err === "object" && typeof (err as { retryable?: unknown }).retryable === "boolean" + ? (err as { retryable: boolean }).retryable + : null; + const stack = err instanceof Error && typeof err.stack === "string" ? err.stack : ""; + const stackPreview = stack ? stack.split("\n").slice(0, 6).join("\n") : null; + return { errorName, acpCode, causeMessage, retryable, stackPreview }; +} + +function classifyError( + err: unknown, + phase?: AcpxExecutionPhase, +): Pick { + const message = err instanceof Error ? err.message : String(err); + const diagnostics = describeErrorDiagnostics(err); + const { acpCode, errorName, causeMessage, retryable, stackPreview } = diagnostics; + const baseMeta: Record = { + errorName, + ...(acpCode ? { acpCode } : {}), + ...(causeMessage ? { causeMessage } : {}), + ...(retryable !== null ? { retryable } : {}), + ...(stackPreview ? { stackPreview } : {}), + ...(phase ? { phase } : {}), + }; 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 } : {}) }, + errorMeta: { category: "auth", ...baseMeta }, + }; + } + const phaseCode = (() => { + if (acpCode === "ACP_SESSION_INIT_FAILED") return "acpx_session_init_failed"; + if (acpCode === "ACP_TURN_FAILED") return "acpx_turn_failed"; + if (acpCode === "ACP_BACKEND_MISSING") return "acpx_backend_missing"; + if (acpCode === "ACP_BACKEND_UNAVAILABLE") return "acpx_backend_unavailable"; + if (phase === "ensure_session") return "acpx_session_init_failed"; + if (phase === "configure_session") return "acpx_session_config_failed"; + if (phase === "turn") return "acpx_turn_failed"; + return null; + })(); + if (phaseCode) { + return { + errorCode: phaseCode, + errorMeta: { category: acpCode ? "protocol" : "runtime", ...baseMeta }, }; } if (acpCode) { return { errorCode: "acpx_protocol_error", - errorMeta: { category: "protocol", acpCode }, + errorMeta: { category: "protocol", ...baseMeta }, }; } return { errorCode: "acpx_runtime_error", - errorMeta: { category: "runtime" }, + errorMeta: { category: "runtime", ...baseMeta }, }; } +async function readChildStderrTail(input: { + logPath: string | null; + maxBytes?: number; +}): Promise { + if (!input.logPath) return null; + const maxBytes = input.maxBytes ?? 4096; + let handle: fs.FileHandle | null = null; + try { + const stat = await fs.stat(input.logPath); + if (stat.size === 0) return null; + handle = await fs.open(input.logPath, "r"); + const readBytes = Math.min(stat.size, maxBytes); + const buffer = Buffer.alloc(readBytes); + await handle.read(buffer, 0, readBytes, Math.max(0, stat.size - readBytes)); + const tail = buffer.toString("utf8").trim(); + return tail.length > 0 ? tail : null; + } catch { + return null; + } finally { + if (handle) await handle.close().catch(() => {}); + } +} + +async function emitAcpxFailure(input: { + ctx: AdapterExecutionContext; + prepared: AcpxPreparedRuntime; + err: unknown; + phase: AcpxExecutionPhase; + // Replace the err-derived message in both the stderr-tail log header and the + // acpx.error payload. Used by the turn path to surface "Timed out after Ns" + // instead of the raw underlying error message. + messageOverride?: string; +}): Promise<{ + classified: Pick; + message: string; + childStderrTail: string | null; +}> { + const { ctx, prepared, err, phase, messageOverride } = input; + const rawMessage = err instanceof Error ? err.message : String(err); + const message = messageOverride ?? rawMessage; + const classified = classifyError(err, phase); + const childStderrTail = await readChildStderrTail({ logPath: prepared.childStderrLogPath }); + if (childStderrTail) { + await ctx.onLog( + "stderr", + `[paperclip] ACPX child stderr tail (${phase}):\n${childStderrTail}\n`, + ); + } + await emitAcpxLog(ctx, { + type: "acpx.error", + message, + phase, + ...classified.errorMeta, + ...(childStderrTail ? { childStderrTail } : {}), + }); + return { classified, message, childStderrTail }; +} + 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); @@ -1136,6 +1394,11 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { permissionMode: prepared.permissionMode, nonInteractivePermissions: prepared.nonInteractivePermissions, timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined, + // Scope ACPX runtime verbose logs to the claude agent only — that's the + // surface we know needs the extra session-event detail (PAPA-388). codex + // and custom agents already emit their own per-tool output and don't + // benefit from doubling the log volume. + verbose: prepared.acpxAgent === "claude", }; const runtime = cached?.runtime ?? createRuntime(runtimeOptions); if (cached) clearWarmHandleTimer(cached); @@ -1177,9 +1440,12 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { } } } catch (err) { - const classified = classifyError(err); - const message = err instanceof Error ? err.message : String(err); - await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta }); + const { classified, message } = await emitAcpxFailure({ + ctx, + prepared, + err, + phase: "ensure_session", + }); return { exitCode: 1, signal: null, @@ -1216,9 +1482,12 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { onLog: ctx.onLog, }); } catch (err) { - const classified = classifyError(err); - const message = err instanceof Error ? err.message : String(err); - await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta }); + const { classified, message } = await emitAcpxFailure({ + ctx, + prepared, + err, + phase: "configure_session", + }); await runtime.close({ handle: sessionHandle, reason: "paperclip config cleanup", @@ -1271,7 +1540,13 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { commandNotes: [ `ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`, `Effective ACPX permission mode: ${prepared.permissionMode}.`, - ...(prepared.requestedModel ? [`Requested ACPX model: ${prepared.requestedModel}.`] : []), + ...(prepared.requestedModel + ? [ + prepared.acpxAgent === "claude" + ? `Requested ACPX model: ${prepared.requestedModel} (set via ANTHROPIC_MODEL env at startup).` + : `Requested ACPX model: ${prepared.requestedModel}.`, + ] + : []), ...(prepared.requestedThinkingEffort ? [`Requested ACPX thinking effort: ${prepared.requestedThinkingEffort}.`] : []), ...(prepared.fastMode ? ["Requested ACPX Codex fast mode."] : []), ...(Array.isArray(prepared.skillsIdentity.commandNotes) @@ -1414,10 +1689,11 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { }; } 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 messageOverride = timedOut ? `Timed out after ${prepared.timeoutSec}s` : undefined; const cancel = cancelActiveTurn as ((reason: string) => Promise) | null; - if (cancel) await cancel(message).catch(() => {}); + const preEmitMessage = + messageOverride ?? (err instanceof Error ? err.message : String(err)); + if (cancel) await cancel(preEmitMessage).catch(() => {}); await runtime.close({ handle: sessionHandle, reason: timedOut ? "paperclip timeout cleanup" : "paperclip error cleanup", @@ -1428,7 +1704,13 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { clearWarmHandleTimer(existing); warmHandles.delete(prepared.sessionKey); } - await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta }); + const { classified, message } = await emitAcpxFailure({ + ctx, + prepared, + err, + phase: "turn", + messageOverride, + }); return { exitCode: 1, signal: timedOut ? "SIGTERM" : null, diff --git a/packages/plugins/sandbox-providers/cloudflare/src/config.ts b/packages/plugins/sandbox-providers/cloudflare/src/config.ts index 1ed62a266f9..8bd6bab5bdc 100644 --- a/packages/plugins/sandbox-providers/cloudflare/src/config.ts +++ b/packages/plugins/sandbox-providers/cloudflare/src/config.ts @@ -1,7 +1,7 @@ import type { CloudflareDriverConfig } from "./types.js"; const DEFAULT_REQUESTED_CWD = "/workspace/paperclip"; -const DEFAULT_SLEEP_AFTER = "10m"; +const DEFAULT_SLEEP_AFTER = "1h"; const DEFAULT_TIMEOUT_MS = 300_000; const DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS = 300_000; const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]); diff --git a/packages/plugins/sandbox-providers/cloudflare/src/manifest.ts b/packages/plugins/sandbox-providers/cloudflare/src/manifest.ts index 21f3ddda74f..52b18660d5e 100644 --- a/packages/plugins/sandbox-providers/cloudflare/src/manifest.ts +++ b/packages/plugins/sandbox-providers/cloudflare/src/manifest.ts @@ -49,8 +49,9 @@ const manifest: PaperclipPluginManifestV1 = { }, sleepAfter: { type: "string", - default: "10m", - description: "Idle timeout passed to getSandbox(). Ignored when keepAlive is true.", + default: "1h", + description: + "Idle timeout passed to getSandbox() on lease creation. Defaults to 1 hour so a fresh sandbox survives normal Claude/Codex heartbeats. Ignored when keepAlive is true.", }, normalizeId: { type: "boolean", diff --git a/packages/plugins/sandbox-providers/cloudflare/src/plugin.test.ts b/packages/plugins/sandbox-providers/cloudflare/src/plugin.test.ts index 84a6077b2f1..5e3908bd5c4 100644 --- a/packages/plugins/sandbox-providers/cloudflare/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/cloudflare/src/plugin.test.ts @@ -62,7 +62,7 @@ describe("Cloudflare sandbox provider plugin", () => { bridgeAuthToken: "secret-ref://bridge-token", reuseLease: true, keepAlive: true, - sleepAfter: "10m", + sleepAfter: "1h", normalizeId: false, requestedCwd: "/workspace/custom", sessionStrategy: "default", @@ -145,6 +145,29 @@ describe("Cloudflare sandbox provider plugin", () => { }); }); + it("defaults the sleepAfter passed to the bridge to 1h so long runs don't idle out", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + providerLeaseId: "pc-run-1-abcd1234", + metadata: { provider: "cloudflare", remoteCwd: "/workspace/paperclip", resumedLease: false }, + }), + ); + + await plugin.definition.onEnvironmentAcquireLease?.({ + driverKey: "cloudflare", + companyId: "company-1", + environmentId: "env-1", + runId: "run-1", + requestedCwd: "/workspace/paperclip", + config: { + bridgeBaseUrl: "https://bridge.example.workers.dev", + bridgeAuthToken: "resolved-token", + }, + }); + + expect(requestBodyAt()).toMatchObject({ sleepAfter: "1h" }); + }); + it("returns expired lease semantics when resume reports lost state", async () => { fetchMock.mockResolvedValueOnce( jsonResponse( diff --git a/packages/plugins/sandbox-providers/e2b/src/manifest.ts b/packages/plugins/sandbox-providers/e2b/src/manifest.ts index 11b235909bc..a245e80c739 100644 --- a/packages/plugins/sandbox-providers/e2b/src/manifest.ts +++ b/packages/plugins/sandbox-providers/e2b/src/manifest.ts @@ -39,8 +39,9 @@ const manifest: PaperclipPluginManifestV1 = { }, timeoutMs: { type: "number", - description: "Sandbox timeout in milliseconds.", - default: 300000, + description: + "Sandbox lifetime in milliseconds, refreshed on each command. Defaults to 1 hour. Raise this if your runs commonly idle longer than the default between commands.", + default: 3600000, }, reuseLease: { type: "boolean", diff --git a/packages/plugins/sandbox-providers/e2b/src/plugin.test.ts b/packages/plugins/sandbox-providers/e2b/src/plugin.test.ts index 99b881c87c7..3f982284f27 100644 --- a/packages/plugins/sandbox-providers/e2b/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/e2b/src/plugin.test.ts @@ -379,6 +379,59 @@ describe("E2B sandbox provider plugin", () => { }); }); + it("refreshes the sandbox lifetime on every execute so long runs don't die mid-command", async () => { + const sandbox = createMockSandbox(); + mockConnect.mockResolvedValue(sandbox); + + await plugin.definition.onEnvironmentExecute?.({ + driverKey: "e2b", + companyId: "company-1", + environmentId: "env-1", + config: { + template: "base", + apiKey: "resolved-key", + timeoutMs: 1_800_000, + reuseLease: false, + }, + lease: { providerLeaseId: "sandbox-123", metadata: {} }, + command: "printf", + args: ["hello"], + cwd: "/workspace", + env: {}, + timeoutMs: 1000, + }); + + expect(sandbox.setTimeout).toHaveBeenCalledWith(1_800_000); + }); + + it("still runs the command when the setTimeout refresh fails transiently", async () => { + const sandbox = createMockSandbox(); + sandbox.setTimeout.mockRejectedValueOnce(new Error("transient e2b api error")); + mockConnect.mockResolvedValue(sandbox); + + const result = await plugin.definition.onEnvironmentExecute?.({ + driverKey: "e2b", + companyId: "company-1", + environmentId: "env-1", + config: { + template: "base", + apiKey: "resolved-key", + timeoutMs: 1_800_000, + reuseLease: false, + }, + lease: { providerLeaseId: "sandbox-123", metadata: {} }, + command: "printf", + args: ["hello"], + cwd: "/workspace", + env: {}, + timeoutMs: 1000, + }); + + expect(sandbox.setTimeout).toHaveBeenCalledWith(1_800_000); + expect(sandbox.commands.run).toHaveBeenCalled(); + expect(result?.exitCode).toBe(0); + }); + it("cleans up staged stdin even when writing it fails", async () => { const sandbox = createMockSandbox(); const failure = new Error("write failed"); diff --git a/packages/plugins/sandbox-providers/e2b/src/plugin.ts b/packages/plugins/sandbox-providers/e2b/src/plugin.ts index daf1548645e..e20dfd68c42 100644 --- a/packages/plugins/sandbox-providers/e2b/src/plugin.ts +++ b/packages/plugins/sandbox-providers/e2b/src/plugin.ts @@ -34,11 +34,11 @@ function parseDriverConfig(raw: Record): E2bDriverConfig { const template = typeof raw.template === "string" && raw.template.trim().length > 0 ? raw.template.trim() : "base"; - const timeoutMs = Number(raw.timeoutMs ?? 300_000); + const timeoutMs = Number(raw.timeoutMs ?? 3_600_000); return { template, apiKey: typeof raw.apiKey === "string" && raw.apiKey.trim().length > 0 ? raw.apiKey.trim() : null, - timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : 300_000, + timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : 3_600_000, reuseLease: raw.reuseLease === true, }; } @@ -391,6 +391,18 @@ const plugin = definePlugin({ const config = parseDriverConfig(params.config); const sandbox = await connectSandbox(config, params.lease.providerLeaseId); + // Refresh the sandbox death clock on every command. E2B's `timeoutMs` is + // the absolute sandbox lifetime from create/connect; without this, a run + // longer than `config.timeoutMs` will have its sandbox killed mid-command + // and the next call throws "Sandbox is probably not running anymore". + // The refresh is best-effort: the sandbox is already healthy at this + // point, so a transient API error on setTimeout should not block the + // command from running. Worst case the existing lifetime stands. + try { + await sandbox.setTimeout(config.timeoutMs); + } catch { + // ignore — keep going with the existing sandbox lifetime + } const baseCommand = buildLoginShellScript({ command: params.command, args: params.args ?? [], diff --git a/server/src/__tests__/access-routes-permissions-upgrade.test.ts b/server/src/__tests__/access-routes-permissions-upgrade.test.ts index 9db328d4519..b7180581a5d 100644 --- a/server/src/__tests__/access-routes-permissions-upgrade.test.ts +++ b/server/src/__tests__/access-routes-permissions-upgrade.test.ts @@ -115,7 +115,7 @@ describeEmbeddedPostgres("access routes permissions upgrade compatibility", () = .where(eq(companyMemberships.id, owner.id)) .then((rows) => rows[0]!); expect(unchanged.membershipRole).toBe("owner"); - }); + }, 10_000); it("keeps custom grants when the role-only member route changes a member role", async () => { const { company, owner } = await createCompanyWithOwner(db); diff --git a/server/src/__tests__/acpx-local-execute.test.ts b/server/src/__tests__/acpx-local-execute.test.ts index 148e52a0519..6e3922e3723 100644 --- a/server/src/__tests__/acpx-local-execute.test.ts +++ b/server/src/__tests__/acpx-local-execute.test.ts @@ -574,7 +574,7 @@ describe("acpx_local execute", () => { 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.errorCode).toBe("acpx_session_init_failed"); expect(result.errorMeta).toMatchObject({ category: "protocol", acpCode: "ACP_SESSION_INIT_FAILED", diff --git a/server/src/__tests__/companies-service.test.ts b/server/src/__tests__/companies-service.test.ts new file mode 100644 index 00000000000..8977b041865 --- /dev/null +++ b/server/src/__tests__/companies-service.test.ts @@ -0,0 +1,50 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { companies, createDb } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { companyService } from "../services/companies.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("companyService", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-service-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("retries generated issue prefixes when Drizzle wraps the unique constraint error", async () => { + await db.insert(companies).values({ + name: "Aron Existing", + issuePrefix: "ARO", + }); + + const created = await companyService(db).create({ + name: "Aron & Sharon", + }); + + expect(created.issuePrefix).toBe("AROA"); + + const rows = await db.select({ issuePrefix: companies.issuePrefix }).from(companies); + expect(rows.map((row) => row.issuePrefix).sort()).toEqual(["ARO", "AROA"]); + }); +}); diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 6cda7fb1743..606f69bca95 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -766,13 +766,19 @@ describe("heartbeat comment wake batching", () => { gateway.releaseFirstWait(); - await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000); + await waitFor(() => gateway.getAgentPayloads().length >= 2, 90_000); await waitFor(async () => { const runs = await db .select() .from(heartbeatRuns) - .where(eq(heartbeatRuns.agentId, agentId)); - return runs.length === 2 && runs.every((run) => run.status === "succeeded"); + .where(eq(heartbeatRuns.agentId, agentId)) + .orderBy(asc(heartbeatRuns.createdAt)); + const [initialRun, promotedRun] = runs; + return ( + initialRun?.id === firstRun?.id && + initialRun.status === "succeeded" && + promotedRun?.status === "succeeded" + ); }, 90_000); const reopenedIssue = await db diff --git a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts index f2560bcdd5e..62b488dd280 100644 --- a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts @@ -533,7 +533,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () = .where(eq(heartbeatRuns.id, secondWake!.id)) .then((rows) => rows[0] ?? null); return run?.status === "succeeded"; - }); + }, 10_000); expect(secondRunSucceeded).toBe(true); expect(mockAdapterExecute.mock.calls.length).toBeGreaterThanOrEqual(2); } finally { diff --git a/server/src/__tests__/heartbeat-local-environment.test.ts b/server/src/__tests__/heartbeat-local-environment.test.ts index 673297a1c81..145a6b6b860 100644 --- a/server/src/__tests__/heartbeat-local-environment.test.ts +++ b/server/src/__tests__/heartbeat-local-environment.test.ts @@ -1,18 +1,12 @@ import { randomUUID } from "node:crypto"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { agents, - agentRuntimeState, - agentWakeupRequests, - activityLog, companies, - companySkills, createDb, environmentLeases, environments, - heartbeatRunEvents, - heartbeatRuns, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -73,16 +67,20 @@ describeEmbeddedPostgres("heartbeat local environment lifecycle", () => { }, 20_000); afterEach(async () => { - await db.delete(environmentLeases); - await db.delete(environments); - await db.delete(activityLog); - await db.delete(heartbeatRunEvents); - await db.delete(heartbeatRuns); - await db.delete(agentWakeupRequests); - await db.delete(agentRuntimeState); - await db.delete(companySkills); - await db.delete(agents); - await db.delete(companies); + await db.execute(sql.raw(` + TRUNCATE TABLE + "environment_leases", + "environments", + "activity_log", + "heartbeat_run_events", + "heartbeat_runs", + "agent_wakeup_requests", + "agent_runtime_state", + "company_skills", + "agents", + "companies" + RESTART IDENTITY CASCADE + `)); }); afterAll(async () => { diff --git a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts index f55ffb9e754..eecf0bf21a2 100644 --- a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts +++ b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts @@ -2,21 +2,15 @@ import { randomUUID } from "node:crypto"; import { eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { - activityLog, agents, - agentRuntimeState, agentWakeupRequests, companies, - companySkills, createDb, documentRevisions, documents, - heartbeatRunEvents, heartbeatRuns, issueComments, issueDocuments, - issueRelations, - issueTreeHolds, issues, } from "@paperclipai/db"; import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; @@ -89,35 +83,39 @@ async function waitForCondition(fn: () => Promise, timeoutMs = 3_000) { } async function cleanupHeartbeatInvalidationFixture(db: ReturnType) { - for (let attempt = 0; attempt < 5; attempt += 1) { + for (let attempt = 0; attempt < 10; attempt += 1) { try { - await db.delete(companySkills); - await db.delete(issueComments); - await db.delete(issueDocuments); - await db.delete(documentRevisions); - await db.delete(documents); - await db.delete(issueRelations); - await db.delete(issueTreeHolds); - await db.delete(issues); - await db.delete(heartbeatRunEvents); - await db.delete(activityLog); - await db.delete(heartbeatRuns); - await db.delete(agentWakeupRequests); - await db.delete(agentRuntimeState); - await db.delete(agents); - await db.delete(companies); + await db.execute(sql.raw(` + TRUNCATE TABLE + "company_skills", + "issue_comments", + "issue_documents", + "document_revisions", + "documents", + "issue_relations", + "issue_tree_holds", + "issues", + "heartbeat_run_events", + "activity_log", + "heartbeat_runs", + "agent_wakeup_requests", + "agent_runtime_state", + "agents", + "companies" + RESTART IDENTITY CASCADE + `)); return; } catch (error) { const isLateCommentRace = error instanceof Error && error.message.includes("issue_comments_issue_id_issues_id_fk"); - if (!isLateCommentRace || attempt === 4) { + if (!isLateCommentRace || attempt === 9) { throw error; } // Heartbeat completion can write issue-thread comments shortly after the // run leaves queued/running. Retry the dependent deletes once those land. - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 100)); } } } diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index d2d575009c7..7d8407b608f 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -39,7 +39,7 @@ describe("buildInviteOnboardingTextDocument", () => { allowedHostnames: [], }); - expect(text).toContain("Paperclip OpenClaw Gateway Onboarding"); + expect(text).toContain("Paperclip Agent Onboarding"); expect(text).toContain("/api/invites/token-123/accept"); expect(text).toContain("/api/join-requests/{requestId}/claim-api-key"); expect(text).toContain("/api/invites/token-123/onboarding.txt"); @@ -48,14 +48,13 @@ describe("buildInviteOnboardingTextDocument", () => { expect(text).toContain("http://localhost:3100"); expect(text).toContain("host.docker.internal"); expect(text).toContain("paperclipApiUrl"); - expect(text).toContain("adapterType \"openclaw_gateway\""); + expect(text).toContain('"adapterType": "openclaw_gateway"'); expect(text).toContain("headers.x-openclaw-token"); expect(text).toContain("Do NOT use /v1/responses or /hooks/*"); expect(text).toContain("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl"); - expect(text).toContain("~/.openclaw/workspace/paperclip-claimed-api-key.json"); expect(text).toContain("PAPERCLIP_API_KEY"); - expect(text).toContain("saved token field"); - expect(text).toContain("Gateway token unexpectedly short"); + expect(text).toContain("Use your runtime's normal skill or instruction installation path."); + expect(text).toContain("Decide which Paperclip adapter type matches your runtime."); }); it("includes loopback diagnostics for authenticated/private onboarding", () => { diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index d4641b67805..44b1fbaf6f4 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -1554,16 +1554,17 @@ function buildInviteOnboardingManifest( ), onboarding: { instructions: - "Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).", + "Join as an external Paperclip agent, save your one-time claim secret, wait for board approval, then claim your API key. Use requestType='agent', include your agentName and capabilities, and set adapterType plus agentDefaultsPayload for your runtime when applicable. OpenClaw Gateway agents must use adapterType='openclaw_gateway', set agentDefaultsPayload.url to a ws:// or wss:// gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token.", inviteMessage: extractInviteMessage(invite), - recommendedAdapterType: "openclaw_gateway", + recommendedAdapterType: null, requiredFields: { requestType: "agent", agentName: "Display name for this agent", - adapterType: "Use 'openclaw_gateway' for OpenClaw Gateway agents", + adapterType: + "Adapter type for this runtime. Use 'openclaw_gateway' only for OpenClaw Gateway agents.", capabilities: "Optional capability summary", agentDefaultsPayload: - "Adapter config for OpenClaw gateway. MUST include url (ws:// or wss://) and headers.x-openclaw-token (or legacy x-openclaw-auth). Optional fields: paperclipApiUrl, waitTimeoutMs, sessionKeyStrategy, sessionKey, role, scopes, disableDeviceAuth, devicePrivateKeyPem." + "Runtime-specific adapter config. OpenClaw Gateway agents must include url (ws:// or wss://) and headers.x-openclaw-token. Other runtimes should include the config their adapter expects." }, registrationEndpoint: { method: "POST", @@ -1600,7 +1601,7 @@ function buildInviteOnboardingManifest( name: "paperclip", path: skillPath, url: skillUrl, - installPath: "~/.openclaw/skills/paperclip/SKILL.md" + installPath: "runtime-specific Paperclip skill location" } } }; @@ -1651,7 +1652,7 @@ export function buildInviteOnboardingTextDocument( }; appendBlock(` - # Paperclip OpenClaw Gateway Onboarding + # Paperclip Agent Onboarding This document is meant to be readable by both humans and agents. @@ -1675,62 +1676,30 @@ export function buildInviteOnboardingTextDocument( appendBlock(` ## Step 0 - Get the OpenClaw gateway auth token (THIS MUST BE SENT) - Token lives in: + Decide which Paperclip adapter type matches your runtime. - ~/.openclaw/openclaw.json -> gateway.auth.token - Extract: - - TOKEN="$(node -p 'require(process.env.HOME+\"/.openclaw/openclaw.json\").gateway.auth.token')" - test -n "$TOKEN" || (echo "Missing TOKEN" && exit 1) - test "\${#TOKEN}" -ge 16 || (echo "Gateway token unexpectedly short (\${#TOKEN})" && exit 1) - - 3) IMPORTANT: Don't accidentally drop the token when generating JSON - If you build JSON with Node, pass the token explicitly (argv), don't rely on an un-exported env var. - - Safe payload build looks sort of like this (substitute where necessary): - - BODY="$(node -e ' - const token = process.argv[1]; - if (!token) process.exit(2); - const body = { - requestType: "agent", - agentName: "OpenClaw", - adapterType: "openclaw_gateway", - capabilities: "OpenClaw agent adapter", - agentDefaultsPayload: { - url: "ws://127.0.0.1:18789", - paperclipApiUrl: "http://host.docker.internal:3100", - headers: { "x-openclaw-token": token }, - waitTimeoutMs: 120000, - sessionKeyStrategy: "issue", - role: "operator", - scopes: ["operator.admin"] - } - }; - process.stdout.write(JSON.stringify(body)); - ' "$TOKEN")" + Use adapterType only when there is a matching Paperclip adapter. Put runtime-specific settings in agentDefaultsPayload. ## Step 1: Submit agent join request ${onboarding.registrationEndpoint.method} ${ onboarding.registrationEndpoint.url } - IMPORTANT: You MUST include agentDefaultsPayload.headers.x-openclaw-token with your gateway token. - Legacy x-openclaw-auth is also accepted, but x-openclaw-token is preferred. - Use adapterType "openclaw_gateway" and a ws:// or wss:// gateway URL. - Pairing mode requirement: - - Keep device auth enabled (recommended). If devicePrivateKeyPem is omitted, Paperclip generates and persists one during join so pairing approvals are stable. - - You may set disableDeviceAuth=true only for special environments that cannot support pairing. - - First run may return "pairing required" once; approve the pending pairing request in OpenClaw, then retry. - Do NOT use /v1/responses or /hooks/* in this gateway join flow. - Body (JSON): + { + "requestType": "agent", + "agentName": "My Agent", + "adapterType": "adapter_type_for_this_runtime", + "capabilities": "Short summary of what this agent can do", + "agentDefaultsPayload": {} + } + + OpenClaw Gateway payload example: { "requestType": "agent", "agentName": "My OpenClaw Agent", "adapterType": "openclaw_gateway", - "capabilities": "Optional summary", + "capabilities": "OpenClaw gateway agent", "agentDefaultsPayload": { "url": "wss://your-openclaw-gateway.example", "paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100", @@ -1742,6 +1711,8 @@ export function buildInviteOnboardingTextDocument( } } + For OpenClaw Gateway, include agentDefaultsPayload.headers.x-openclaw-token with your gateway token. Legacy x-openclaw-auth is also accepted, but x-openclaw-token is preferred. Do NOT use /v1/responses or /hooks/* in this gateway join flow. + Expected response includes: - request id - one-time claimSecret @@ -1760,35 +1731,18 @@ export function buildInviteOnboardingTextDocument( "claimSecret": "" } - On successful claim, save the full JSON response to: - - - ~/.openclaw/workspace/paperclip-claimed-api-key.json - chmod 600 ~/.openclaw/workspace/paperclip-claimed-api-key.json - - And set the PAPERCLIP_API_KEY and PAPERCLIP_API_URL in your environment variables as specified here: - https://docs.openclaw.ai/help/environment - - e.g. - - { - env: { - PAPERCLIP_API_KEY: "...", - PAPERCLIP_API_URL: "...", - }, - } - - Then set PAPERCLIP_API_KEY and PAPERCLIP_API_URL from the saved token field for every heartbeat run. + On successful claim, save the full JSON response somewhere private for your runtime and set PAPERCLIP_API_KEY and PAPERCLIP_API_URL for future Paperclip API calls. Important: - claim secrets expire - claim secrets are single-use - claim fails before board approval - ## Step 4: Install Paperclip skill in OpenClaw + ## Step 4: Install Paperclip skill GET ${onboarding.skill.url} Install path: ${onboarding.skill.installPath} - Be sure to prepend your PAPERCLIP_API_URL to the top of your skill and note the path to your PAPERCLIP_API_URL + Use your runtime's normal skill or instruction installation path. ## Text onboarding URL ${onboarding.textInstructions.url} diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index 8704b986cd2..e69abb9e222 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -125,16 +125,18 @@ export function companyService(db: Db) { } function isIssuePrefixConflict(error: unknown) { - const constraint = typeof error === "object" && error !== null && "constraint" in error - ? (error as { constraint?: string }).constraint - : typeof error === "object" && error !== null && "constraint_name" in error - ? (error as { constraint_name?: string }).constraint_name - : undefined; - return typeof error === "object" - && error !== null - && "code" in error - && (error as { code?: string }).code === "23505" - && constraint === "companies_issue_prefix_idx"; + const seen = new Set(); + let current = error; + while (typeof current === "object" && current !== null && !seen.has(current)) { + seen.add(current); + const maybe = current as { code?: string; constraint?: string; constraint_name?: string; cause?: unknown }; + const constraint = maybe.constraint ?? maybe.constraint_name; + if (maybe.code === "23505" && constraint === "companies_issue_prefix_idx") { + return true; + } + current = maybe.cause; + } + return false; } async function createCompanyWithUniquePrefix(data: typeof companies.$inferInsert) { diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index ddd5dacbdae..e710d07b9c6 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -110,10 +110,11 @@ const adapterDisplayMap: Record = { }, openclaw_gateway: { label: "OpenClaw Gateway", - description: "Invoke OpenClaw via gateway protocol", + description: "External gateway adapter", icon: Bot, comingSoon: true, - disabledLabel: "Configure OpenClaw within the App", + disabledLabel: "Invite external agents from the add-agent modal", + hideFromVisualSelection: true, }, process: { label: "Process", diff --git a/ui/src/api/companies-query.ts b/ui/src/api/companies-query.ts new file mode 100644 index 00000000000..c01c481a936 --- /dev/null +++ b/ui/src/api/companies-query.ts @@ -0,0 +1,25 @@ +import type { Company } from "@paperclipai/shared"; +import { companiesApi } from "./companies"; +import { ApiError } from "./client"; +import { queryKeys } from "../lib/queryKeys"; + +export type CompanyListResult = { companies: Company[]; unauthorized: boolean }; + +// Single source of truth for the `["companies"]` query. Both CompanyProvider and +// the invite landing page read this cache entry, so they must agree on the shape — +// returning a bare `Company[]` from one and this wrapped object from the other +// silently corrupts the shared cache and crashes whichever reads the other's shape. +export const companiesListQueryOptions = { + queryKey: queryKeys.companies.all, + queryFn: async (): Promise => { + try { + return { companies: await companiesApi.list(), unauthorized: false }; + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + return { companies: [], unauthorized: true }; + } + throw err; + } + }, + retry: false, +} as const; diff --git a/ui/src/components/InstanceSidebar.test.tsx b/ui/src/components/InstanceSidebar.test.tsx index 2463116b618..e49d940b0ca 100644 --- a/ui/src/components/InstanceSidebar.test.tsx +++ b/ui/src/components/InstanceSidebar.test.tsx @@ -223,9 +223,14 @@ describe("InstanceSidebar", () => { await flushReact(); await findPluginLinks(container, 1); - const topLevelLinks = Array.from( - container.querySelectorAll('a[href^="/instance/settings/"]'), - ); + await vi.waitFor(() => { + const links = Array.from( + container.querySelectorAll('a[href^="/instance/settings/"]'), + ); + expect(links.some((a) => a.getAttribute("href") === "/instance/settings/plugins/linear")).toBe(true); + }); + + const topLevelLinks = Array.from(container.querySelectorAll('a[href^="/instance/settings/"]')); const hrefs = topLevelLinks.map((a) => a.getAttribute("href")); const pluginsIndex = hrefs.indexOf("/instance/settings/plugins"); @@ -266,6 +271,9 @@ describe("InstanceSidebar", () => { queryClient = rendered.queryClient; await flushReact(); + await vi.waitFor(() => { + expect(mockPluginsApi.list).toHaveBeenCalled(); + }); const pluginLinks = Array.from(container.querySelectorAll('a[href^="/instance/settings/plugins/"]')); expect(pluginLinks).toHaveLength(0); }); diff --git a/ui/src/components/NewAgentDialog.test.tsx b/ui/src/components/NewAgentDialog.test.tsx new file mode 100644 index 00000000000..b1c63b9e1c2 --- /dev/null +++ b/ui/src/components/NewAgentDialog.test.tsx @@ -0,0 +1,210 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NewAgentDialog } from "./NewAgentDialog"; + +const createCompanyInviteMock = vi.hoisted(() => vi.fn()); +const getInviteOnboardingMock = vi.hoisted(() => vi.fn()); +const listAgentsMock = vi.hoisted(() => vi.fn()); +const listAdaptersMock = vi.hoisted(() => vi.fn()); +const navigateMock = vi.hoisted(() => vi.fn()); +const closeNewAgentMock = vi.hoisted(() => vi.fn()); +const openNewIssueMock = vi.hoisted(() => vi.fn()); +const pushToastMock = vi.hoisted(() => vi.fn()); +const clipboardWriteTextMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/router", () => ({ + useNavigate: () => navigateMock, +})); + +vi.mock("../context/DialogContext", () => ({ + useDialog: () => ({ + newAgentOpen: true, + closeNewAgent: closeNewAgentMock, + openNewIssue: openNewIssueMock, + }), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + }), +})); + +vi.mock("../context/ToastContext", () => ({ + useToast: () => ({ pushToast: pushToastMock }), +})); + +vi.mock("../api/access", () => ({ + accessApi: { + createCompanyInvite: (companyId: string, input: unknown) => + createCompanyInviteMock(companyId, input), + getInviteOnboarding: (token: string) => getInviteOnboardingMock(token), + }, +})); + +vi.mock("../api/agents", () => ({ + agentsApi: { + list: (companyId: string) => listAgentsMock(companyId), + }, +})); + +vi.mock("../api/adapters", () => ({ + adaptersApi: { + list: () => listAdaptersMock(), + }, +})); + +vi.mock("../adapters", () => ({ + listUIAdapters: () => [{ type: "claude_local" }, { type: "openclaw_gateway" }], +})); + +vi.mock("../adapters/metadata", () => ({ + isVisualAdapterChoice: (type: string) => type !== "openclaw_gateway", +})); + +vi.mock("../adapters/use-disabled-adapters", () => ({ + useDisabledAdaptersSync: () => new Set(), +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("NewAgentDialog", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + + listAgentsMock.mockResolvedValue([ + { id: "agent-ceo", role: "ceo" }, + ]); + listAdaptersMock.mockResolvedValue([]); + createCompanyInviteMock.mockResolvedValue({ + id: "invite-1", + token: "agent-token", + inviteUrl: "https://paperclip.local/invite/agent-token", + expiresAt: "2026-04-20T00:00:00.000Z", + allowedJoinTypes: "agent", + humanRole: null, + onboardingTextUrl: "https://paperclip.local/api/invites/agent-token/onboarding.txt", + onboardingTextPath: "/api/invites/agent-token/onboarding.txt", + }); + getInviteOnboardingMock.mockResolvedValue({ + onboarding: { + connectivity: { + connectionCandidates: ["https://paperclip.local"], + testResolutionEndpoint: { + url: "https://paperclip.local/api/invites/agent-token/test-resolution", + }, + }, + }, + }); + + Object.defineProperty(globalThis.navigator, "clipboard", { + configurable: true, + value: { writeText: clipboardWriteTextMock }, + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("generates an external agent onboarding prompt inside the add-agent modal", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Add a new agent"); + expect(container.textContent).toContain("Invite an external agent"); + + const inviteButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.startsWith("Invite an external agent"), + ); + + await act(async () => { + inviteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("Generate a one-time onboarding prompt"); + expect(container.textContent).not.toContain("Company Invites"); + + const generateButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Generate onboarding prompt", + ); + + await act(async () => { + generateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + await flushReact(); + + expect(createCompanyInviteMock).toHaveBeenCalledWith("company-1", { + allowedJoinTypes: "agent", + humanRole: null, + agentMessage: null, + }); + expect(getInviteOnboardingMock).toHaveBeenCalledWith("agent-token"); + expect(clipboardWriteTextMock).toHaveBeenCalledWith( + expect.stringContaining("You're invited to join a Paperclip company as an agent."), + ); + expect(container.textContent).toContain("Agent onboarding prompt"); + expect(container.textContent).toContain("Send this prompt to the external agent"); + expect(container.textContent).not.toContain("Optional message for the agent"); + expect(container.textContent).not.toContain("Generate onboarding prompt"); + expect(pushToastMock).toHaveBeenCalledWith({ + title: "Agent invite created", + body: "Agent onboarding prompt ready below and copied to clipboard.", + tone: "success", + }); + + const backButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Back", + ); + + await act(async () => { + backButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(container.textContent).toContain("Optional message for the agent"); + expect(container.textContent).toContain("Generate onboarding prompt"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 6400038de00..6062d4438de 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -1,8 +1,9 @@ -import { useState, useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@/lib/router"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; +import { accessApi } from "../api/access"; import { agentsApi } from "../api/agents"; import { adaptersApi } from "../api/adapters"; import { queryKeys } from "@/lib/queryKeys"; @@ -11,15 +12,21 @@ import { DialogContent, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; import { ArrowLeft, Bot, + Check, + MailPlus, + Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; +import { buildAgentOnboardingPrompt } from "@/lib/agent-onboarding-prompt"; import { listUIAdapters } from "../adapters"; import { isVisualAdapterChoice } from "../adapters/metadata"; import { getAdapterDisplay } from "../adapters/adapter-display-registry"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; +import { useToast } from "../context/ToastContext"; /** * Adapter types that are suitable for agent creation (excludes internal @@ -27,6 +34,8 @@ import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; */ const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]); +type NewAgentDialogMode = "choices" | "runtime" | "invite" | "prompt"; + function isAgentAdapterType(type: string): boolean { return !SYSTEM_ADAPTER_TYPES.has(type); } @@ -34,10 +43,30 @@ function isAgentAdapterType(type: string): boolean { export function NewAgentDialog() { const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog(); const { selectedCompanyId } = useCompany(); + const { pushToast } = useToast(); const navigate = useNavigate(); - const [showAdvancedCards, setShowAdvancedCards] = useState(false); + const queryClient = useQueryClient(); + const [mode, setMode] = useState("choices"); + const [agentMessage, setAgentMessage] = useState(""); + const [latestAgentPrompt, setLatestAgentPrompt] = useState(null); + const [latestAgentPromptCopied, setLatestAgentPromptCopied] = useState(false); const disabledTypes = useDisabledAdaptersSync(); + function resetDialogState() { + setMode("choices"); + setAgentMessage(""); + setLatestAgentPrompt(null); + setLatestAgentPromptCopied(false); + } + + useEffect(() => { + if (!latestAgentPromptCopied) return; + const timeout = window.setTimeout(() => { + setLatestAgentPromptCopied(false); + }, 1600); + return () => window.clearTimeout(timeout); + }, [latestAgentPromptCopied]); + // Fetch registered adapters from server (syncs disabled store + provides data) const { data: serverAdapters } = useQuery({ queryKey: queryKeys.adapters.all, @@ -53,6 +82,7 @@ export function NewAgentDialog() { }); const ceoAgent = (agents ?? []).find((a) => a.role === "ceo"); + const inviteHistoryQueryKey = queryKeys.access.invites(selectedCompanyId ?? "", "all", 5); // Build the adapter grid from the UI registry merged with display metadata. // This automatically includes external/plugin adapters. @@ -95,28 +125,110 @@ export function NewAgentDialog() { } function handleAdvancedConfig() { - setShowAdvancedCards(true); + setMode("runtime"); + } + + function handleInviteExternalAgent() { + setMode("invite"); } function handleAdvancedAdapterPick(adapterType: string) { closeNewAgent(); - setShowAdvancedCards(false); + resetDialogState(); navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`); } + async function copyText(text: string, unavailableBody: string) { + try { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // Fall through to the unavailable message below. + } + + pushToast({ + title: "Clipboard unavailable", + body: unavailableBody, + tone: "warn", + }); + return false; + } + + const createAgentInviteMutation = useMutation({ + mutationFn: () => + accessApi.createCompanyInvite(selectedCompanyId!, { + allowedJoinTypes: "agent", + humanRole: null, + agentMessage: agentMessage.trim() || null, + }), + onSuccess: async (invite) => { + const base = window.location.origin.replace(/\/+$/, ""); + const onboardingTextLink = + invite.onboardingTextUrl ?? + invite.onboardingTextPath ?? + `/api/invites/${invite.token}/onboarding.txt`; + const onboardingTextUrl = onboardingTextLink.startsWith("http") + ? onboardingTextLink + : `${base}${onboardingTextLink}`; + + let prompt: string; + try { + const manifest = await accessApi.getInviteOnboarding(invite.token); + prompt = buildAgentOnboardingPrompt({ + onboardingTextUrl, + connectionCandidates: + manifest.onboarding.connectivity?.connectionCandidates ?? null, + testResolutionUrl: + manifest.onboarding.connectivity?.testResolutionEndpoint?.url ?? + null, + }); + } catch { + prompt = buildAgentOnboardingPrompt({ + onboardingTextUrl, + connectionCandidates: null, + testResolutionUrl: null, + }); + } + + setLatestAgentPrompt(prompt); + setLatestAgentPromptCopied(false); + setMode("prompt"); + const copied = await copyText(prompt, "Copy the agent onboarding prompt manually from the field below."); + + await queryClient.invalidateQueries({ queryKey: inviteHistoryQueryKey }); + pushToast({ + title: "Agent invite created", + body: copied ? "Agent onboarding prompt ready below and copied to clipboard." : "Agent onboarding prompt ready below.", + tone: "success", + }); + }, + onError: (error) => { + pushToast({ + title: "Failed to create agent invite", + body: error instanceof Error ? error.message : "Unknown error", + tone: "error", + }); + }, + }); + return ( { if (!open) { - setShowAdvancedCards(false); + resetDialogState(); closeNewAgent(); } }} > {/* Header */}
@@ -126,7 +238,7 @@ export function NewAgentDialog() { size="icon-xs" className="text-muted-foreground" onClick={() => { - setShowAdvancedCards(false); + resetDialogState(); closeNewAgent(); }} > @@ -134,8 +246,8 @@ export function NewAgentDialog() {
-
- {!showAdvancedCards ? ( +
+ {mode === "choices" ? ( <> {/* Recommendation */}
@@ -143,9 +255,8 @@ export function NewAgentDialog() {

- We recommend letting your CEO handle agent setup — they know the - org structure and can configure reporting, permissions, and - adapters. + Ask a leader to propose the hire, configure a runtime yourself, + or send an onboarding prompt to an external agent.

@@ -154,28 +265,34 @@ export function NewAgentDialog() { Ask the CEO to create a new agent - {/* Advanced link */} -
- +
+ +
+ +

+ (OpenClaw, Hermes, or any agent that can call the invite API.) +

+
- ) : ( + ) : mode === "runtime" ? ( <>

- Choose your adapter type for advanced setup. + Choose the runtime Paperclip should start or resume directly.

@@ -207,6 +324,92 @@ export function NewAgentDialog() { ))}
+ ) : mode === "invite" ? ( +
+
+ +
+

Invite an external agent

+

+ Generate a one-time onboarding prompt that any compatible agent can use to request access, wait for approval, and claim its Paperclip API key. +

+
+
+ +