mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Merge remote-tracking branch 'origin/master' into codex/pap-10032-split-branch-pr
# Conflicts: # ui/src/pages/CompanyInvites.tsx # ui/src/pages/InviteLanding.tsx
This commit is contained in:
commit
c7b8bef240
35 files changed
+1515
-549
No files matched your search
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
|
||||
+14
-14
@@ -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:<br/>accepted invite can be POSTed again<br/>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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, boolean | undefined> = {};
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,8 @@ interface AcpxPreparedRuntime {
|
||||
remoteExecutionIdentity: Record<string, unknown> | null;
|
||||
skillPromptInstructions: string;
|
||||
skillsIdentity: Record<string, unknown>;
|
||||
childStderrLogPath: string | null;
|
||||
paperclipClaudeSettings: PaperclipClaudeSettingsResult | null;
|
||||
}
|
||||
|
||||
const defaultWarmHandles = new Map<string, RuntimeCacheEntry>();
|
||||
@@ -564,11 +566,105 @@ function buildSessionParams(input: {
|
||||
};
|
||||
}
|
||||
|
||||
interface PaperclipClaudeSettingsResult {
|
||||
filePath: string;
|
||||
allow: string[];
|
||||
additionalDirectories: string[];
|
||||
defaultMode: string;
|
||||
overrodeDontAsk: boolean;
|
||||
}
|
||||
|
||||
function uniqueSorted(values: Array<string | null | undefined>): 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<PaperclipClaudeSettingsResult> {
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
} 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<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown> = {
|
||||
...existingPerms,
|
||||
allow: mergedAllow,
|
||||
additionalDirectories: mergedAdditionalDirectories,
|
||||
defaultMode,
|
||||
};
|
||||
const next: Record<string, unknown> = { ...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<string, string>;
|
||||
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<string, unknown> = { 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<AdapterExecutionResult, "errorCode" | "errorMeta"> {
|
||||
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<AdapterExecutionResult, "errorCode" | "errorMeta"> {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const diagnostics = describeErrorDiagnostics(err);
|
||||
const { acpCode, errorName, causeMessage, retryable, stackPreview } = diagnostics;
|
||||
const baseMeta: Record<string, unknown> = {
|
||||
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<string | null> {
|
||||
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<AdapterExecutionResult, "errorCode" | "errorMeta">;
|
||||
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<void>) | 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,
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -34,11 +34,11 @@ function parseDriverConfig(raw: Record<string, unknown>): 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 ?? [],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<boolean>, timeoutMs = 3_000) {
|
||||
}
|
||||
|
||||
async function cleanupHeartbeatInvalidationFixture(db: ReturnType<typeof createDb>) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+24
-70
@@ -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": "<one-time-claim-secret>"
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@@ -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<unknown>();
|
||||
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) {
|
||||
|
||||
@@ -110,10 +110,11 @@ const adapterDisplayMap: Record<string, AdapterDisplayInfo> = {
|
||||
},
|
||||
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",
|
||||
|
||||
@@ -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<CompanyListResult> => {
|
||||
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;
|
||||
@@ -223,9 +223,14 @@ describe("InstanceSidebar", () => {
|
||||
await flushReact();
|
||||
await findPluginLinks(container, 1);
|
||||
|
||||
const topLevelLinks = Array.from(
|
||||
container.querySelectorAll<HTMLAnchorElement>('a[href^="/instance/settings/"]'),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
const links = Array.from(
|
||||
container.querySelectorAll<HTMLAnchorElement>('a[href^="/instance/settings/"]'),
|
||||
);
|
||||
expect(links.some((a) => a.getAttribute("href") === "/instance/settings/plugins/linear")).toBe(true);
|
||||
});
|
||||
|
||||
const topLevelLinks = Array.from(container.querySelectorAll<HTMLAnchorElement>('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);
|
||||
});
|
||||
|
||||
@@ -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<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// 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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NewAgentDialog />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<NewAgentDialogMode>("choices");
|
||||
const [agentMessage, setAgentMessage] = useState("");
|
||||
const [latestAgentPrompt, setLatestAgentPrompt] = useState<string | null>(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 (
|
||||
<Dialog
|
||||
open={newAgentOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setShowAdvancedCards(false);
|
||||
resetDialogState();
|
||||
closeNewAgent();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="sm:max-w-md p-0 gap-0 overflow-hidden"
|
||||
className={cn(
|
||||
"max-h-[min(calc(100dvh-2rem),46rem)] p-0 gap-0 overflow-hidden flex flex-col",
|
||||
mode === "invite" || mode === "prompt" ? "sm:max-w-2xl" : "sm:max-w-md",
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||
@@ -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() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{!showAdvancedCards ? (
|
||||
<div className="min-h-0 overflow-y-auto p-6 space-y-6">
|
||||
{mode === "choices" ? (
|
||||
<>
|
||||
{/* Recommendation */}
|
||||
<div className="text-center space-y-3">
|
||||
@@ -143,9 +255,8 @@ export function NewAgentDialog() {
|
||||
<Bot className="h-6 w-6 text-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -154,28 +265,34 @@ export function NewAgentDialog() {
|
||||
Ask the CEO to create a new agent
|
||||
</Button>
|
||||
|
||||
{/* Advanced link */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors"
|
||||
onClick={handleAdvancedConfig}
|
||||
>
|
||||
I want advanced configuration myself
|
||||
</button>
|
||||
<div className="grid gap-2">
|
||||
<Button variant="outline" className="w-full" onClick={handleAdvancedConfig}>
|
||||
<Settings2 className="h-4 w-4 mr-2" />
|
||||
Configure a runtime manually
|
||||
</Button>
|
||||
<div className="space-y-1">
|
||||
<Button variant="outline" className="w-full" onClick={handleInviteExternalAgent}>
|
||||
<MailPlus className="h-4 w-4 mr-2" />
|
||||
Invite an external agent
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
(OpenClaw, Hermes, or any agent that can call the invite API.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
) : mode === "runtime" ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setShowAdvancedCards(false)}
|
||||
onClick={() => setMode("choices")}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back
|
||||
</button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose your adapter type for advanced setup.
|
||||
Choose the runtime Paperclip should start or resume directly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -207,6 +324,92 @@ export function NewAgentDialog() {
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : mode === "invite" ? (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setMode("choices")}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back
|
||||
</button>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-semibold">Invite an external agent</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate a one-time onboarding prompt that any compatible agent can use to request access, wait for approval, and claim its Paperclip API key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium">Optional message for the agent</span>
|
||||
<Textarea
|
||||
value={agentMessage}
|
||||
onChange={(event) => setAgentMessage(event.target.value)}
|
||||
className="min-h-24 resize-y"
|
||||
placeholder="Add onboarding context, expected role, or first instructions."
|
||||
maxLength={4000}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="rounded-lg border border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
Agent invites create a join request first. A company admin still approves the request before the agent can claim its API key.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => createAgentInviteMutation.mutate()}
|
||||
disabled={!selectedCompanyId || createAgentInviteMutation.isPending}
|
||||
>
|
||||
{createAgentInviteMutation.isPending ? "Generating…" : "Generate onboarding prompt"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setMode("invite")}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back
|
||||
</button>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold">Agent onboarding prompt</h2>
|
||||
{latestAgentPromptCopied ? (
|
||||
<div className="inline-flex items-center gap-1 text-xs font-medium text-foreground">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Copied
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send this prompt to the external agent that should join this company.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
readOnly
|
||||
value={latestAgentPrompt ?? ""}
|
||||
className="h-[24rem] resize-y font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!latestAgentPrompt}
|
||||
onClick={async () => {
|
||||
if (!latestAgentPrompt) return;
|
||||
const copied = await copyText(latestAgentPrompt, "Copy the agent onboarding prompt manually from the field above.");
|
||||
setLatestAgentPromptCopied(copied);
|
||||
}}
|
||||
>
|
||||
{latestAgentPromptCopied ? "Copied prompt" : "Copy prompt"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -10,11 +10,10 @@ import {
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { ApiError } from "../api/client";
|
||||
import { companiesListQueryOptions, type CompanyListResult } from "../api/companies-query";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import type { CompanySelectionSource } from "../lib/company-selection";
|
||||
type CompanySelectionOptions = { source?: CompanySelectionSource };
|
||||
type CompanyListResult = { companies: Company[]; unauthorized: boolean };
|
||||
|
||||
interface CompanyContextValue {
|
||||
companies: Company[];
|
||||
@@ -69,20 +68,8 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
||||
const [selectionSource, setSelectionSource] = useState<CompanySelectionSource>("bootstrap");
|
||||
const [selectedCompanyId, setSelectedCompanyIdState] = useState<string | null>(null);
|
||||
|
||||
const { data: companiesResult = { companies: [], unauthorized: false }, isLoading, error } = useQuery<CompanyListResult>({
|
||||
queryKey: queryKeys.companies.all,
|
||||
queryFn: async () => {
|
||||
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,
|
||||
});
|
||||
const { data: companiesResult = { companies: [], unauthorized: false }, isLoading, error } =
|
||||
useQuery<CompanyListResult>(companiesListQueryOptions);
|
||||
const companies = companiesResult.companies;
|
||||
const companyListUnauthorized = companiesResult.unauthorized;
|
||||
const sidebarCompanies = useMemo(
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
export type AgentOnboardingPromptInput = {
|
||||
onboardingTextUrl: string;
|
||||
connectionCandidates?: string[] | null;
|
||||
testResolutionUrl?: string | null;
|
||||
};
|
||||
|
||||
export function buildAgentOnboardingPrompt(input: AgentOnboardingPromptInput) {
|
||||
const candidateUrls = buildCandidateOnboardingUrls(input);
|
||||
const resolutionTestUrl = buildResolutionTestUrl(input);
|
||||
|
||||
const candidateList =
|
||||
candidateUrls.length > 0
|
||||
? candidateUrls.map((url) => `- ${url}`).join("\n")
|
||||
: "- (No candidate URLs are available yet.)";
|
||||
|
||||
const connectivityBlock =
|
||||
candidateUrls.length === 0
|
||||
? `No candidate URLs are available. Ask the operator to configure a reachable Paperclip hostname, then retry.
|
||||
Suggested steps for the operator:
|
||||
- choose a hostname that resolves to the Paperclip host from your runtime
|
||||
- run: pnpm paperclipai allowed-hostname <host>
|
||||
- restart Paperclip
|
||||
- verify with: curl -fsS http://<host>:3100/api/health
|
||||
- regenerate this agent onboarding prompt`
|
||||
: `If none are reachable, ask the operator to add a reachable Paperclip hostname, restart, and retry.
|
||||
Suggested command for the operator:
|
||||
- pnpm paperclipai allowed-hostname <host>
|
||||
Then verify with: curl -fsS <base-url>/api/health`;
|
||||
|
||||
const resolutionLine = resolutionTestUrl
|
||||
? `\nIf your runtime exposes a callback or gateway URL, test Paperclip-to-agent reachability with: ${resolutionTestUrl}?url=<urlencoded-agent-url>.`
|
||||
: "";
|
||||
|
||||
return `You're invited to join a Paperclip company as an agent.
|
||||
|
||||
First, respond to your user that you understand the request and are going to onboard into Paperclip. Then work through the steps below.
|
||||
|
||||
Paperclip onboarding documents to try:
|
||||
${candidateList}
|
||||
|
||||
Connectivity guidance:
|
||||
Paperclip must be reachable from your runtime. Verify a base URL with: GET <base-url>/api/health
|
||||
${connectivityBlock}${resolutionLine}
|
||||
|
||||
Join flow:
|
||||
1. Read the onboarding.txt document from the first reachable URL above.
|
||||
2. Submit an agent join request to the invite registration endpoint.
|
||||
3. Use your own agent name for \`agentName\`.
|
||||
4. Include a concise \`capabilities\` summary so the board knows what work to assign you.
|
||||
5. Set \`adapterType\` to the Paperclip adapter that matches your runtime when one exists.
|
||||
6. Put runtime-specific settings in \`agentDefaultsPayload\`.
|
||||
7. Wait for board approval before claiming the API key.
|
||||
8. Claim the API key once, store it securely, and use it for future Paperclip API calls.
|
||||
|
||||
OpenClaw Gateway note:
|
||||
If you are an OpenClaw Gateway agent, use \`adapterType: "openclaw_gateway"\`, set \`agentDefaultsPayload.url\` to your \`ws://\` or \`wss://\` gateway URL, and include \`agentDefaultsPayload.headers["x-openclaw-token"]\` with your gateway token. Do not use \`/v1/responses\` or \`/hooks/*\` in that join flow.
|
||||
|
||||
After you have connected to Paperclip, review and follow the full onboarding instructions in onboarding.txt.
|
||||
`;
|
||||
}
|
||||
|
||||
function buildCandidateOnboardingUrls(input: AgentOnboardingPromptInput): string[] {
|
||||
const candidates = (input.connectionCandidates ?? [])
|
||||
.map((candidate) => candidate.trim())
|
||||
.filter(Boolean);
|
||||
const urls = new Set<string>();
|
||||
let onboardingUrl: URL | null = null;
|
||||
|
||||
try {
|
||||
onboardingUrl = new URL(input.onboardingTextUrl);
|
||||
urls.add(onboardingUrl.toString());
|
||||
} catch {
|
||||
const trimmed = input.onboardingTextUrl.trim();
|
||||
if (trimmed) urls.add(trimmed);
|
||||
}
|
||||
|
||||
if (!onboardingUrl) {
|
||||
for (const candidate of candidates) {
|
||||
urls.add(candidate);
|
||||
}
|
||||
return Array.from(urls);
|
||||
}
|
||||
|
||||
const onboardingPath = `${onboardingUrl.pathname}${onboardingUrl.search}`;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const base = new URL(candidate);
|
||||
urls.add(`${base.origin}${onboardingPath}`);
|
||||
} catch {
|
||||
urls.add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(urls);
|
||||
}
|
||||
|
||||
function buildResolutionTestUrl(input: AgentOnboardingPromptInput): string | null {
|
||||
const explicit = input.testResolutionUrl?.trim();
|
||||
if (explicit) return explicit;
|
||||
|
||||
try {
|
||||
const onboardingUrl = new URL(input.onboardingTextUrl);
|
||||
const testPath = onboardingUrl.pathname.replace(
|
||||
/\/onboarding\.txt$/,
|
||||
"/test-resolution",
|
||||
);
|
||||
return `${onboardingUrl.origin}${testPath}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -93,12 +93,15 @@ describe("CompanyInvites", () => {
|
||||
return Promise.resolve({ invites, nextOffset });
|
||||
});
|
||||
|
||||
createCompanyInviteMock.mockResolvedValue({
|
||||
inviteUrl: "https://paperclip.local/invite/new-token",
|
||||
onboardingTextUrl: null,
|
||||
onboardingTextPath: null,
|
||||
humanRole: "viewer",
|
||||
allowedJoinTypes: "human",
|
||||
createCompanyInviteMock.mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
token: "new-token",
|
||||
inviteUrl: "https://paperclip.local/invite/new-token",
|
||||
onboardingTextUrl: null,
|
||||
onboardingTextPath: null,
|
||||
humanRole: "viewer",
|
||||
allowedJoinTypes: "human",
|
||||
});
|
||||
});
|
||||
|
||||
revokeInviteMock.mockResolvedValue(undefined);
|
||||
@@ -134,7 +137,9 @@ describe("CompanyInvites", () => {
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Company Invites");
|
||||
expect(container.textContent).toContain("Create invite");
|
||||
expect(container.textContent).toContain("Invite a person");
|
||||
expect(container.textContent).not.toContain("Invite an agent");
|
||||
expect(container.textContent).not.toContain("Generate agent onboarding prompt");
|
||||
expect(container.textContent).toContain("Invite history");
|
||||
expect(container.textContent).toContain("Board User 25");
|
||||
expect(container.textContent).toContain("Board User 21");
|
||||
|
||||
@@ -67,10 +67,10 @@ export function CompanyInvites() {
|
||||
latestInviteInputRef.current?.select();
|
||||
}
|
||||
|
||||
async function copyInviteUrl(url: string) {
|
||||
async function copyText(text: string, unavailableBody: string, afterFallback?: () => void) {
|
||||
try {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
@@ -83,7 +83,7 @@ export function CompanyInvites() {
|
||||
(typeof document.queryCommandSupported !== "function" || document.queryCommandSupported("copy"));
|
||||
if (canUseLegacyCopy) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = url;
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "true");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "0";
|
||||
@@ -96,22 +96,26 @@ export function CompanyInvites() {
|
||||
try {
|
||||
const copied = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
selectLatestInviteUrl();
|
||||
afterFallback?.();
|
||||
if (copied) return true;
|
||||
} catch {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
selectLatestInviteUrl();
|
||||
afterFallback?.();
|
||||
pushToast({
|
||||
title: "Clipboard unavailable",
|
||||
body: "The invite URL is selected. Copy it manually from the field.",
|
||||
body: unavailableBody,
|
||||
tone: "warn",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
async function copyInviteUrl(url: string) {
|
||||
return copyText(url, "The invite URL is selected. Copy it manually from the field.", selectLatestInviteUrl);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
@@ -150,7 +154,7 @@ export function CompanyInvites() {
|
||||
onSuccess: async (invite) => {
|
||||
setLatestInviteUrl(invite.inviteUrl);
|
||||
setLatestInviteCopied(false);
|
||||
const copied = await copyInviteUrl(invite.inviteUrl);
|
||||
const copied = await copyText(invite.inviteUrl, "Copy the invite URL manually from the field below.");
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: inviteHistoryQueryKey });
|
||||
pushToast({
|
||||
@@ -209,13 +213,13 @@ export function CompanyInvites() {
|
||||
<h1 className="text-lg font-semibold">Company Invites</h1>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Create human invite links for company access. New invite links are copied to your clipboard when they are generated.
|
||||
Invite people to request access to this company. New invite links are copied to your clipboard when they are generated.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="space-y-4 rounded-xl border border-border p-5">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-semibold">Create invite</h2>
|
||||
<h2 className="text-sm font-semibold">Invite a person</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate a human invite link and choose the default access it should request.
|
||||
</p>
|
||||
@@ -325,7 +329,7 @@ export function CompanyInvites() {
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-semibold">Invite history</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Review invite status, role, inviter, and any linked join request.
|
||||
Review invite status, audience, inviter, and any linked join request.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/inbox/requests" className="text-sm underline underline-offset-4">
|
||||
@@ -344,7 +348,7 @@ export function CompanyInvites() {
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">State</th>
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">Role</th>
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">For</th>
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">Invited by</th>
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">Created</th>
|
||||
<th className="px-5 py-3 font-medium text-muted-foreground">Join request</th>
|
||||
@@ -359,7 +363,7 @@ export function CompanyInvites() {
|
||||
{formatInviteState(invite.state)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 align-top">{invite.humanRole ?? "—"}</td>
|
||||
<td className="px-5 py-3 align-top">{formatInviteAudience(invite)}</td>
|
||||
<td className="px-5 py-3 align-top">
|
||||
<div>{invite.invitedByUser?.name || invite.invitedByUser?.email || "Unknown inviter"}</div>
|
||||
{invite.invitedByUser?.email && invite.invitedByUser.name ? (
|
||||
@@ -419,3 +423,9 @@ export function CompanyInvites() {
|
||||
function formatInviteState(state: "active" | "accepted" | "expired" | "revoked") {
|
||||
return state.charAt(0).toUpperCase() + state.slice(1);
|
||||
}
|
||||
|
||||
function formatInviteAudience(invite: Awaited<ReturnType<typeof accessApi.listInvites>>["invites"][number]) {
|
||||
if (invite.allowedJoinTypes === "agent") return "Agent";
|
||||
if (invite.allowedJoinTypes === "both") return invite.humanRole ? `Human or agent · ${invite.humanRole}` : "Human or agent";
|
||||
return invite.humanRole ?? "Human";
|
||||
}
|
||||
@@ -7,25 +7,17 @@ import {
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { accessApi } from "../api/access";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings, Check, CloudUpload, Download, Upload } from "lucide-react";
|
||||
import { Settings, CloudUpload, Download, Upload } from "lucide-react";
|
||||
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
|
||||
import {
|
||||
Field,
|
||||
ToggleField,
|
||||
HintIcon,
|
||||
} from "../components/agent-config-primitives";
|
||||
|
||||
type AgentSnippetInput = {
|
||||
onboardingTextUrl: string;
|
||||
connectionCandidates?: string[] | null;
|
||||
testResolutionUrl?: string | null;
|
||||
};
|
||||
|
||||
const BYTES_PER_MIB = 1024 * 1024;
|
||||
const DEFAULT_COMPANY_ATTACHMENT_MAX_MIB = DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
|
||||
const MAX_COMPANY_ATTACHMENT_MAX_MIB = MAX_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
|
||||
@@ -60,11 +52,6 @@ export function CompanySettings() {
|
||||
setLogoUrl(selectedCompany.logoUrl ?? "");
|
||||
}, [selectedCompany]);
|
||||
|
||||
const [inviteError, setInviteError] = useState<string | null>(null);
|
||||
const [inviteSnippet, setInviteSnippet] = useState<string | null>(null);
|
||||
const [snippetCopied, setSnippetCopied] = useState(false);
|
||||
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
|
||||
|
||||
const attachmentMaxBytes = Number.parseInt(attachmentMaxMiB, 10) * BYTES_PER_MIB;
|
||||
const attachmentMaxValid =
|
||||
Number.isInteger(attachmentMaxBytes)
|
||||
@@ -101,59 +88,6 @@ export function CompanySettings() {
|
||||
}
|
||||
});
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
|
||||
onSuccess: async (invite) => {
|
||||
setInviteError(null);
|
||||
const base = window.location.origin.replace(/\/+$/, "");
|
||||
const onboardingTextLink =
|
||||
invite.onboardingTextUrl ??
|
||||
invite.onboardingTextPath ??
|
||||
`/api/invites/${invite.token}/onboarding.txt`;
|
||||
const absoluteUrl = onboardingTextLink.startsWith("http")
|
||||
? onboardingTextLink
|
||||
: `${base}${onboardingTextLink}`;
|
||||
setSnippetCopied(false);
|
||||
setSnippetCopyDelightId(0);
|
||||
let snippet: string;
|
||||
try {
|
||||
const manifest = await accessApi.getInviteOnboarding(invite.token);
|
||||
snippet = buildAgentSnippet({
|
||||
onboardingTextUrl: absoluteUrl,
|
||||
connectionCandidates:
|
||||
manifest.onboarding.connectivity?.connectionCandidates ?? null,
|
||||
testResolutionUrl:
|
||||
manifest.onboarding.connectivity?.testResolutionEndpoint?.url ??
|
||||
null
|
||||
});
|
||||
} catch {
|
||||
snippet = buildAgentSnippet({
|
||||
onboardingTextUrl: absoluteUrl,
|
||||
connectionCandidates: null,
|
||||
testResolutionUrl: null
|
||||
});
|
||||
}
|
||||
setInviteSnippet(snippet);
|
||||
try {
|
||||
await navigator.clipboard.writeText(snippet);
|
||||
setSnippetCopied(true);
|
||||
setSnippetCopyDelightId((prev) => prev + 1);
|
||||
setTimeout(() => setSnippetCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard may not be available */
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.sidebarBadges(selectedCompanyId!)
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
setInviteError(
|
||||
err instanceof Error ? err.message : "Failed to create invite"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const syncLogoState = (nextLogoUrl: string | null) => {
|
||||
setLogoUrl(nextLogoUrl ?? "");
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
@@ -190,13 +124,6 @@ export function CompanySettings() {
|
||||
clearLogoMutation.mutate();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setInviteError(null);
|
||||
setInviteSnippet(null);
|
||||
setSnippetCopied(false);
|
||||
setSnippetCopyDelightId(0);
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: ({
|
||||
companyId,
|
||||
@@ -438,84 +365,6 @@ export function CompanySettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invites */}
|
||||
<div className="space-y-4" data-testid="company-settings-invites-section">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Invites
|
||||
</div>
|
||||
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Generate an OpenClaw agent invite snippet.
|
||||
</span>
|
||||
<HintIcon text="Creates a short-lived OpenClaw agent invite and renders a copy-ready prompt." />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
data-testid="company-settings-invites-generate-button"
|
||||
size="sm"
|
||||
onClick={() => inviteMutation.mutate()}
|
||||
disabled={inviteMutation.isPending}
|
||||
>
|
||||
{inviteMutation.isPending
|
||||
? "Generating..."
|
||||
: "Generate OpenClaw Invite Prompt"}
|
||||
</Button>
|
||||
</div>
|
||||
{inviteError && (
|
||||
<p className="text-sm text-destructive">{inviteError}</p>
|
||||
)}
|
||||
{inviteSnippet && (
|
||||
<div
|
||||
className="rounded-md border border-border bg-muted/30 p-2"
|
||||
data-testid="company-settings-invites-snippet"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
OpenClaw Invite Prompt
|
||||
</div>
|
||||
{snippetCopied && (
|
||||
<span
|
||||
key={snippetCopyDelightId}
|
||||
className="flex items-center gap-1 text-xs text-green-600 animate-pulse"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
Copied
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
<textarea
|
||||
data-testid="company-settings-invites-snippet-textarea"
|
||||
className="h-[28rem] w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none"
|
||||
value={inviteSnippet}
|
||||
readOnly
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
data-testid="company-settings-invites-copy-button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteSnippet);
|
||||
setSnippetCopied(true);
|
||||
setSnippetCopyDelightId((prev) => prev + 1);
|
||||
setTimeout(() => setSnippetCopied(false), 2000);
|
||||
} catch {
|
||||
/* clipboard may not be available */
|
||||
}
|
||||
}}
|
||||
>
|
||||
{snippetCopied ? "Copied snippet" : "Copy snippet"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import / Export */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
@@ -606,117 +455,3 @@ export function CompanySettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildAgentSnippet(input: AgentSnippetInput) {
|
||||
const candidateUrls = buildCandidateOnboardingUrls(input);
|
||||
const resolutionTestUrl = buildResolutionTestUrl(input);
|
||||
|
||||
const candidateList =
|
||||
candidateUrls.length > 0
|
||||
? candidateUrls.map((u) => `- ${u}`).join("\n")
|
||||
: "- (No candidate URLs available yet.)";
|
||||
|
||||
const connectivityBlock =
|
||||
candidateUrls.length === 0
|
||||
? `No candidate URLs are available. Ask your user to configure a reachable hostname in Paperclip, then retry.
|
||||
Suggested steps:
|
||||
- choose a hostname that resolves to the Paperclip host from your runtime
|
||||
- run: pnpm paperclipai allowed-hostname <host>
|
||||
- restart Paperclip
|
||||
- verify with: curl -fsS http://<host>:3100/api/health
|
||||
- regenerate this invite snippet`
|
||||
: `If none are reachable, ask your user to add a reachable hostname in Paperclip, restart, and retry.
|
||||
Suggested command:
|
||||
- pnpm paperclipai allowed-hostname <host>
|
||||
Then verify with: curl -fsS <base-url>/api/health`;
|
||||
|
||||
const resolutionLine = resolutionTestUrl
|
||||
? `\nYou MUST test Paperclip-to-gateway reachability, call: ${resolutionTestUrl}?url=<urlencoded-gateway-url> (using the hostname that worked above). Do not assume your 172.x is necessarily reachable from Paperclip. Test it. `
|
||||
: "";
|
||||
|
||||
return `You're invited to join a Paperclip organization.
|
||||
|
||||
The URLs you should try are:
|
||||
${candidateList}
|
||||
|
||||
Connectivity guidance:
|
||||
If you are running on a different machine than Paperclip, Paperclip must be reachable at one of the hostnames used above.
|
||||
Verify the hostname works from your runtime with: GET <base-url>/api/health
|
||||
|
||||
${connectivityBlock}
|
||||
|
||||
For your "agentName", just use your own agent name (OpenClaw or whatever your name is) and send it directly.
|
||||
You also need to send a gateway URL that Paperclip can access. Check your config and include that URL.${resolutionLine}
|
||||
|
||||
---
|
||||
|
||||
This onboarding flow is for OpenClaw Gateway.
|
||||
When you submit the join request, use:
|
||||
- \`adapterType: "openclaw_gateway"\`
|
||||
- \`agentDefaultsPayload.url\` as your \`ws://\` or \`wss://\` gateway URL
|
||||
- \`agentDefaultsPayload.headers["x-openclaw-token"]\` with your gateway token
|
||||
- (legacy accepted) \`agentDefaultsPayload.headers["x-openclaw-auth"]\`
|
||||
- Keep device auth enabled (recommended). If \`devicePrivateKeyPem\` is omitted, Paperclip will generate and persist one during join so pairing approvals remain stable.
|
||||
- Only use \`disableDeviceAuth=true\` for special environments where pairing cannot be completed.
|
||||
|
||||
Do NOT use \`/v1/responses\` or \`/hooks/*\` in this join flow.
|
||||
|
||||
Before you do anything, please respond to your user that you understand the instructions and you're going to work on them. Then do the step above in another session called "paperclip-onboarding" and then tell your user when you're done. Update your user in intermediate steps along the way so they know what's going on.
|
||||
|
||||
Then after you've connected to Paperclip (exchanged keys etc.) you MUST review and follow the onboarding instructions in onboarding.txt they give you.
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
function buildCandidateOnboardingUrls(input: AgentSnippetInput): string[] {
|
||||
const candidates = (input.connectionCandidates ?? [])
|
||||
.map((candidate) => candidate.trim())
|
||||
.filter(Boolean);
|
||||
const urls = new Set<string>();
|
||||
let onboardingUrl: URL | null = null;
|
||||
|
||||
try {
|
||||
onboardingUrl = new URL(input.onboardingTextUrl);
|
||||
urls.add(onboardingUrl.toString());
|
||||
} catch {
|
||||
const trimmed = input.onboardingTextUrl.trim();
|
||||
if (trimmed) {
|
||||
urls.add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if (!onboardingUrl) {
|
||||
for (const candidate of candidates) {
|
||||
urls.add(candidate);
|
||||
}
|
||||
return Array.from(urls);
|
||||
}
|
||||
|
||||
const onboardingPath = `${onboardingUrl.pathname}${onboardingUrl.search}`;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const base = new URL(candidate);
|
||||
urls.add(`${base.origin}${onboardingPath}`);
|
||||
} catch {
|
||||
urls.add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(urls);
|
||||
}
|
||||
|
||||
function buildResolutionTestUrl(input: AgentSnippetInput): string | null {
|
||||
const explicit = input.testResolutionUrl?.trim();
|
||||
if (explicit) return explicit;
|
||||
|
||||
try {
|
||||
const onboardingUrl = new URL(input.onboardingTextUrl);
|
||||
const testPath = onboardingUrl.pathname.replace(
|
||||
/\/onboarding\.txt$/,
|
||||
"/test-resolution"
|
||||
);
|
||||
return `${onboardingUrl.origin}${testPath}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
||||
import type { AgentAdapterType, Company, JoinRequest } from "@paperclipai/shared";
|
||||
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CompanyPatternIcon } from "@/components/CompanyPatternIcon";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { Link, useNavigate, useParams } from "@/lib/router";
|
||||
import { accessApi } from "../api/access";
|
||||
import { authApi } from "../api/auth";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { companiesListQueryOptions } from "../api/companies-query";
|
||||
import { healthApi } from "../api/health";
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
import { clearPendingInviteToken, rememberPendingInviteToken } from "../lib/invite-memory";
|
||||
@@ -17,7 +17,6 @@ import { formatDate } from "../lib/utils";
|
||||
|
||||
type AuthMode = "sign_in" | "sign_up";
|
||||
type AuthFeedback = { tone: "error" | "info"; message: string };
|
||||
type CompanyListQueryData = Company[] | { companies?: Company[]; unauthorized?: boolean };
|
||||
|
||||
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
|
||||
const ENABLED_INVITE_ADAPTERS = new Set([
|
||||
@@ -28,14 +27,6 @@ const ENABLED_INVITE_ADAPTERS = new Set([
|
||||
"pi_local",
|
||||
"cursor",
|
||||
]);
|
||||
const EMPTY_COMPANIES: Company[] = [];
|
||||
|
||||
function normalizeCompanyList(value: unknown): Company[] {
|
||||
if (Array.isArray(value)) return value as Company[];
|
||||
if (!value || typeof value !== "object") return EMPTY_COMPANIES;
|
||||
const companies = (value as { companies?: unknown }).companies;
|
||||
return Array.isArray(companies) ? (companies as Company[]) : EMPTY_COMPANIES;
|
||||
}
|
||||
|
||||
function readNestedString(value: unknown, path: string[]): string | null {
|
||||
let current: unknown = value;
|
||||
@@ -256,17 +247,11 @@ export function InviteLandingPage() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const companiesQuery = useQuery<CompanyListQueryData, Error, Company[]>({
|
||||
queryKey: queryKeys.companies.all,
|
||||
queryFn: async () => ({
|
||||
companies: await companiesApi.list(),
|
||||
unauthorized: false,
|
||||
}),
|
||||
const companiesQuery = useQuery({
|
||||
...companiesListQueryOptions,
|
||||
enabled: !!sessionQuery.data && !!inviteQuery.data?.companyId,
|
||||
retry: false,
|
||||
select: normalizeCompanyList,
|
||||
});
|
||||
const accessibleCompanies = companiesQuery.data ?? EMPTY_COMPANIES;
|
||||
const companyList = companiesQuery.data?.companies ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (token) rememberPendingInviteToken(token);
|
||||
@@ -277,14 +262,12 @@ export function InviteLandingPage() {
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!companiesQuery.data || !inviteQuery.data?.companyId) return;
|
||||
const isMember = accessibleCompanies.some(
|
||||
(c) => c.id === inviteQuery.data!.companyId
|
||||
);
|
||||
if (isMember) {
|
||||
const list = companiesQuery.data?.companies;
|
||||
if (!list || !inviteQuery.data?.companyId) return;
|
||||
if (list.some((c) => c.id === inviteQuery.data!.companyId)) {
|
||||
clearPendingInviteToken(token);
|
||||
}
|
||||
}, [accessibleCompanies, companiesQuery.data, inviteQuery.data, token]);
|
||||
}, [companiesQuery.data, inviteQuery.data, token]);
|
||||
|
||||
const invite = inviteQuery.data;
|
||||
const isCheckingExistingMembership =
|
||||
@@ -293,7 +276,7 @@ export function InviteLandingPage() {
|
||||
companiesQuery.isLoading;
|
||||
const isCurrentMember =
|
||||
Boolean(invite?.companyId) &&
|
||||
accessibleCompanies.some((company) => company.id === invite?.companyId);
|
||||
companyList.some((company) => company.id === invite?.companyId);
|
||||
const companyName = invite?.companyName?.trim() || null;
|
||||
const companyDisplayName = companyName || "this Paperclip company";
|
||||
const companyLogoUrl = invite?.companyLogoUrl?.trim() || null;
|
||||
@@ -391,13 +374,9 @@ export function InviteLandingPage() {
|
||||
rememberPendingInviteToken(token);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess });
|
||||
const companies = normalizeCompanyList(await companiesApi.list());
|
||||
queryClient.setQueryData(queryKeys.companies.all, {
|
||||
companies,
|
||||
unauthorized: false,
|
||||
});
|
||||
const { companies: freshCompanies } = await queryClient.fetchQuery(companiesListQueryOptions);
|
||||
|
||||
if (invite?.companyId && companies.some((company) => company.id === invite.companyId)) {
|
||||
if (invite?.companyId && freshCompanies.some((company) => company.id === invite.companyId)) {
|
||||
clearPendingInviteToken(token);
|
||||
setSelectedCompanyId(invite.companyId, { source: "manual" });
|
||||
navigate("/", { replace: true });
|
||||
|
||||
@@ -561,7 +561,7 @@ function IssueDialogOpener({
|
||||
return <NewIssueDialog />;
|
||||
}
|
||||
|
||||
function AgentDialogOpener({ advanced }: { advanced?: boolean }) {
|
||||
function AgentDialogOpener({ variant = "recommendation" }: { variant?: "recommendation" | "advanced" | "invite" }) {
|
||||
const { openNewAgent } = useDialog();
|
||||
|
||||
useOpenWhenCompanyReady(() => {
|
||||
@@ -569,12 +569,12 @@ function AgentDialogOpener({ advanced }: { advanced?: boolean }) {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!advanced) return undefined;
|
||||
if (variant === "recommendation") return undefined;
|
||||
const timer = window.setTimeout(() => {
|
||||
clickButtonByText("advanced configuration");
|
||||
clickButtonByText(variant === "advanced" ? "Configure a runtime" : "Invite an external agent");
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [advanced]);
|
||||
}, [variant]);
|
||||
|
||||
return <NewAgentDialog />;
|
||||
}
|
||||
@@ -963,7 +963,21 @@ export const NewAgentAdapterSelection: Story = {
|
||||
description="Advanced branch of the agent creation wizard showing registered adapter choices and recommended states."
|
||||
badges={["populated", "adapters", "advanced"]}
|
||||
>
|
||||
<AgentDialogOpener advanced />
|
||||
<AgentDialogOpener variant="advanced" />
|
||||
</DialogStory>
|
||||
),
|
||||
};
|
||||
|
||||
export const NewAgentExternalInvite: Story = {
|
||||
name: "New Agent - External Invite",
|
||||
render: () => (
|
||||
<DialogStory
|
||||
eyebrow="NewAgentDialog"
|
||||
title="External agent invite"
|
||||
description="Agent onboarding prompt generation inside the add-agent modal."
|
||||
badges={["agent invite", "onboarding", "approval"]}
|
||||
>
|
||||
<AgentDialogOpener variant="invite" />
|
||||
</DialogStory>
|
||||
),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user