Merge pull request #252 from paperclipai/dotta

Dotta updates - sorry it's so large
This commit is contained in:
Dotta
2026-03-07 15:20:39 -06:00
committed by GitHub
62 changed files with 5598 additions and 387 deletions

View File

@@ -22,6 +22,7 @@ const workspacePaths = [
"packages/adapters/claude-local",
"packages/adapters/codex-local",
"packages/adapters/openclaw",
"packages/adapters/openclaw-gateway",
];
// Workspace packages that should NOT be bundled — they'll be published

View File

@@ -40,6 +40,7 @@
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/db": "workspace:*",
"@paperclipai/server": "workspace:*",

View File

@@ -5,6 +5,7 @@ import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli";
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
import { processCLIAdapter } from "./process/index.js";
import { httpCLIAdapter } from "./http/index.js";
@@ -38,8 +39,23 @@ const openclawCLIAdapter: CLIAdapterModule = {
formatStdoutEvent: printOpenClawStreamEvent,
};
const openclawGatewayCLIAdapter: CLIAdapterModule = {
type: "openclaw_gateway",
formatStdoutEvent: printOpenClawGatewayStreamEvent,
};
const adaptersByType = new Map<string, CLIAdapterModule>(
[claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, piLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
[
claudeLocalCLIAdapter,
codexLocalCLIAdapter,
openCodeLocalCLIAdapter,
piLocalCLIAdapter,
cursorLocalCLIAdapter,
openclawCLIAdapter,
openclawGatewayCLIAdapter,
processCLIAdapter,
httpCLIAdapter,
].map((a) => [a.type, a]),
);
export function getCLIAdapter(type: string): CLIAdapterModule {

View File

@@ -1,5 +1,9 @@
import { Command } from "commander";
import type { Agent } from "@paperclipai/shared";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
addCommonClientOptions,
formatInlineRecord,
@@ -13,6 +17,107 @@ interface AgentListOptions extends BaseClientOptions {
companyId?: string;
}
interface AgentLocalCliOptions extends BaseClientOptions {
companyId?: string;
keyName?: string;
installSkills?: boolean;
}
interface CreatedAgentKey {
id: string;
name: string;
token: string;
createdAt: string;
}
interface SkillsInstallSummary {
tool: "codex" | "claude";
target: string;
linked: string[];
skipped: string[];
failed: Array<{ name: string; error: string }>;
}
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../../../../skills"), // dev: cli/src/commands/client -> repo root/skills
path.resolve(process.cwd(), "skills"),
];
function codexSkillsHome(): string {
const fromEnv = process.env.CODEX_HOME?.trim();
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex");
return path.join(base, "skills");
}
function claudeSkillsHome(): string {
const fromEnv = process.env.CLAUDE_HOME?.trim();
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude");
return path.join(base, "skills");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
async function installSkillsForTarget(
sourceSkillsDir: string,
targetSkillsDir: string,
tool: "codex" | "claude",
): Promise<SkillsInstallSummary> {
const summary: SkillsInstallSummary = {
tool,
target: targetSkillsDir,
linked: [],
skipped: [],
failed: [],
};
await fs.mkdir(targetSkillsDir, { recursive: true });
const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(sourceSkillsDir, entry.name);
const target = path.join(targetSkillsDir, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) {
summary.skipped.push(entry.name);
continue;
}
try {
await fs.symlink(source, target);
summary.linked.push(entry.name);
} catch (err) {
summary.failed.push({
name: entry.name,
error: err instanceof Error ? err.message : String(err),
});
}
}
return summary;
}
function buildAgentEnvExports(input: {
apiBase: string;
companyId: string;
agentId: string;
apiKey: string;
}): string {
const escaped = (value: string) => value.replace(/'/g, "'\"'\"'");
return [
`export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`,
`export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`,
`export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'`,
`export PAPERCLIP_API_KEY='${escaped(input.apiKey)}'`,
].join("\n");
}
export function registerAgentCommands(program: Command): void {
const agent = program.command("agent").description("Agent operations");
@@ -71,4 +176,96 @@ export function registerAgentCommands(program: Command): void {
}
}),
);
addCommonClientOptions(
agent
.command("local-cli")
.description(
"Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports",
)
.argument("<agentRef>", "Agent ID or shortname/url-key")
.requiredOption("-C, --company-id <id>", "Company ID")
.option("--key-name <name>", "API key label", "local-cli")
.option(
"--no-install-skills",
"Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills",
)
.action(async (agentRef: string, opts: AgentLocalCliOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const query = new URLSearchParams({ companyId: ctx.companyId ?? "" });
const agentRow = await ctx.api.get<Agent>(
`/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`,
);
const now = new Date().toISOString().replaceAll(":", "-");
const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`;
const key = await ctx.api.post<CreatedAgentKey>(`/api/agents/${agentRow.id}/keys`, { name: keyName });
const installSummaries: SkillsInstallSummary[] = [];
if (opts.installSkills !== false) {
const skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) {
throw new Error(
"Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.",
);
}
installSummaries.push(
await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"),
await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"),
);
}
const exportsText = buildAgentEnvExports({
apiBase: ctx.api.apiBase,
companyId: agentRow.companyId,
agentId: agentRow.id,
apiKey: key.token,
});
if (ctx.json) {
printOutput(
{
agent: {
id: agentRow.id,
name: agentRow.name,
urlKey: agentRow.urlKey,
companyId: agentRow.companyId,
},
key: {
id: key.id,
name: key.name,
createdAt: key.createdAt,
token: key.token,
},
skills: installSummaries,
exports: exportsText,
},
{ json: true },
);
return;
}
console.log(`Agent: ${agentRow.name} (${agentRow.id})`);
console.log(`API key created: ${key.name} (${key.id})`);
if (installSummaries.length > 0) {
for (const summary of installSummaries) {
console.log(
`${summary.tool}: linked=${summary.linked.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`,
);
for (const failed of summary.failed) {
console.log(` failed ${failed.name}: ${failed.error}`);
}
}
}
console.log("");
console.log("# Run this in your shell before launching codex/claude:");
console.log(exportsText);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}

View File

@@ -116,6 +116,20 @@ pnpm paperclipai issue release <issue-id>
```sh
pnpm paperclipai agent list --company-id <company-id>
pnpm paperclipai agent get <agent-id>
pnpm paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id>
```
`agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent:
- creates a new long-lived agent API key
- installs missing Paperclip skills into `~/.codex/skills` and `~/.claude/skills`
- prints `export ...` lines for `PAPERCLIP_API_URL`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_API_KEY`
Example for shortname-based local setup:
```sh
pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
```
## Approval Commands

View File

@@ -0,0 +1,55 @@
Use this exact checklist.
1. Start Paperclip in auth mode.
```bash
cd <paperclip-repo-root>
pnpm dev --tailscale-auth
```
Then verify:
```bash
curl -sS http://127.0.0.1:3100/api/health | jq
```
2. Start a clean/stock OpenClaw Docker.
```bash
OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
```
Open the printed `Dashboard URL` (includes `#token=...`) in your browser.
3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`.
4. Use the agent snippet flow.
- Copy the snippet from company settings.
- Paste it into OpenClaw main chat as one message.
- If it stalls, send one follow-up: `How is onboarding going? Continue setup now.`
5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents.
6. Case A (manual issue test).
- Create an issue assigned to the OpenClaw agent.
- Put instructions: “post comment `OPENCLAW_CASE_A_OK_<timestamp>` and mark done.”
- Verify in UI: issue status becomes `done` and comment exists.
7. Case B (message tool test).
- Create another issue assigned to OpenClaw.
- Instructions: “send `OPENCLAW_CASE_B_OK_<timestamp>` to main webchat via message tool, then comment same marker on issue, then mark done.”
- Verify both:
- marker comment on issue
- marker text appears in OpenClaw main chat
8. Case C (new session memory/skills test).
- In OpenClaw, start `/new` session.
- Ask it to create a new CLA issue in Paperclip with unique title `OPENCLAW_CASE_C_CREATED_<timestamp>`.
- Verify in Paperclip UI that new issue exists.
9. Watch logs during test (optional but helpful):
```bash
docker compose -f /tmp/openclaw-docker/docker-compose.yml -f /tmp/openclaw-docker/.paperclip-openclaw.override.yml logs -f openclaw-gateway
```
10. Expected pass criteria.
- Case A: `done` + marker comment.
- Case B: `done` + marker comment + main-chat message visible.
- Case C: original task done and new issue created from `/new` session.
If you want, I can also give you a single “observer mode” command that runs the stock smoke harness while you watch the same steps live in UI.

View File

@@ -47,6 +47,14 @@ If resume fails with an unknown session error, the adapter automatically retries
The adapter creates a temporary directory with symlinks to Paperclip skills and passes it via `--add-dir`. This makes skills discoverable without polluting the agent's working directory.
For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use:
```sh
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
```
This installs Paperclip skills in `~/.claude/skills`, creates an agent API key, and prints shell exports to run as that agent.
## Environment Test
Use the "Test Environment" button in the UI to validate the adapter config. It checks:

View File

@@ -30,6 +30,14 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
```sh
pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
```
This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent.
## Environment Test
The environment test checks:

View File

@@ -0,0 +1,71 @@
# OpenClaw Gateway Adapter
This document describes how `@paperclipai/adapter-openclaw-gateway` invokes OpenClaw over the Gateway protocol.
## Transport
This adapter always uses WebSocket gateway transport.
- URL must be `ws://` or `wss://`
- Connect flow follows gateway protocol:
1. receive `connect.challenge`
2. send `req connect` (protocol/client/auth/device payload)
3. send `req agent`
4. wait for completion via `req agent.wait`
5. stream `event agent` frames into Paperclip logs/transcript parsing
## Auth Modes
Gateway credentials can be provided in any of these ways:
- `authToken` / `token` in adapter config
- `headers.x-openclaw-token`
- `headers.x-openclaw-auth` (legacy)
- `password` (shared password mode)
When a token is present and `authorization` header is missing, the adapter derives `Authorization: Bearer <token>`.
## Device Auth
By default the adapter sends a signed `device` payload in `connect` params.
- set `disableDeviceAuth=true` to omit device signing
- set `devicePrivateKeyPem` to pin a stable signing key
- without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run
## Session Strategy
The adapter supports the same session routing model as HTTP OpenClaw mode:
- `sessionKeyStrategy=fixed|issue|run`
- `sessionKey` is used when strategy is `fixed`
Resolved session key is sent as `agent.sessionKey`.
## Payload Mapping
The agent request is built as:
- required fields:
- `message` (wake text plus optional `payloadTemplate.message`/`payloadTemplate.text` prefix)
- `idempotencyKey` (Paperclip `runId`)
- `sessionKey` (resolved strategy)
- optional additions:
- all `payloadTemplate` fields merged in
- `agentId` from config if set and not already in template
## Timeouts
- `timeoutSec` controls adapter-level request budget
- `waitTimeoutMs` controls `agent.wait.timeoutMs`
If `agent.wait` returns `timeout`, adapter returns `openclaw_gateway_wait_timeout`.
## Log Format
Structured gateway event logs use:
- `[openclaw-gateway] ...` for lifecycle/system logs
- `[openclaw-gateway:event] run=<id> stream=<stream> data=<json>` for `event agent` frames
UI/CLI parsers consume these lines to render transcript updates.

View File

@@ -0,0 +1,351 @@
# OpenClaw Gateway Onboarding and Test Plan
## Objective
Define a reliable, repeatable onboarding and E2E test workflow for OpenClaw integration in authenticated/private Paperclip dev mode (`pnpm dev --tailscale-auth`) with a strong UX path for users and a scriptable path for Codex.
This plan covers:
- Current onboarding flow behavior and gaps.
- Recommended UX for `openclaw` (HTTP `sse/webhook`) and `openclaw_gateway` (WebSocket gateway protocol).
- A concrete automation plan for Codex to run cleanup, onboarding, and E2E validation against the `CLA` company.
## Hard Requirements (Testing Contract)
These are mandatory for onboarding and smoke testing:
1. **Stock/clean OpenClaw boot every run**
- Use a fresh, unmodified OpenClaw Docker image path each test cycle.
- Do not rely on persistent/manual in-UI tweaks from prior runs.
- Recreate runtime state each run so results represent first-time user experience.
2. **One-command/prompt setup inside OpenClaw**
- OpenClaw should be bootstrapped by one primary instruction/prompt (copy/paste-able).
- If a kick is needed, allow at most one follow-up message (for example: “how is it going?”).
- Required OpenClaw configuration (transport enablement, auth loading, skill usage) must be embedded in prompt instructions, not manual hidden steps.
3. **Two-lane validation is required**
- Lane A (stock pass lane): unmodified/clean OpenClaw image and config flow. This lane is the release gate.
- Lane B (instrumentation lane): temporary test instrumentation is allowed only to diagnose failures; it cannot be the final passing path.
## Execution Findings (2026-03-07)
Observed from running `scripts/smoke/openclaw-gateway-e2e.sh` against `CLA` in authenticated/private dev mode:
1. **Baseline failure (before wake-text fix)**
- Stock lane had run-level success but failed functional assertions:
- connectivity run `64a72d8b-f5b3-4f62-9147-1c60932f50ad` succeeded
- case A run `fd29e361-a6bd-4bc6-9270-36ef96e3bd8e` succeeded
- issue `CLA-6` (`dad7b967-29d2-4317-8c9d-425b4421e098`) stayed `todo` with `0` comments
- Root symptom: OpenClaw reported missing concrete heartbeat procedure and guessed non-existent `/api/*heartbeat` endpoints.
2. **Post-fix validation (stock-clean lane passes)**
- After updating adapter wake text to include explicit Paperclip API workflow steps and explicit endpoint bans:
- connectivity run `c297e2d0-020b-4b30-95d3-a4c04e1373bb`: `succeeded`
- case A run `baac403e-8d86-48e5-b7d5-239c4755ce7e`: `succeeded`, issue `CLA-7` done with marker
- case B run `521fc8ad-2f5a-4bd8-9ddd-c491401c9158`: `succeeded`, issue `CLA-8` done with marker
- case C run `a03d86b6-91a8-48b4-8813-758f6bf11aec`: `succeeded`, issue `CLA-9` done, created issue `CLA-10`
- Stock release-gate lane now passes scripted checks.
3. **Instrumentation lane note**
- Prompt-augmented diagnostics lane previously timed out (`7537e5d2-a76a-44c5-bf9f-57f1b21f5fc3`) with missing tool runtime utilities (`jq`, `python`) inside the stock container.
- Keep this lane for diagnostics only; stock lane remains the acceptance gate.
## External Protocol Constraints
OpenClaw docs to anchor behavior:
- Webhook mode requires `hooks.enabled=true` and exposes `/hooks/wake` + `/hooks/agent`: https://docs.openclaw.ai/automation/webhook
- Gateway protocol is WebSocket challenge/response plus request/event frames: https://docs.openclaw.ai/gateway/protocol
- OpenResponses HTTP endpoint is separate (`gateway.http.endpoints.responses.enabled=true`): https://docs.openclaw.ai/openapi/responses
Implication:
- `webhook` transport should target `/hooks/*` and requires hook server enablement.
- `sse` transport should target `/v1/responses`.
- `openclaw_gateway` should use `ws://` or `wss://` and should not depend on `/v1/responses` or `/hooks/*`.
## Current Implementation Map (What Exists)
### Invite + onboarding pipeline
- Invite create: `POST /api/companies/:companyId/invites`
- Invite onboarding manifest: `GET /api/invites/:token/onboarding`
- Agent-readable text: `GET /api/invites/:token/onboarding.txt`
- Accept join: `POST /api/invites/:token/accept`
- Approve join: `POST /api/companies/:companyId/join-requests/:requestId/approve`
- Claim key: `POST /api/join-requests/:requestId/claim-api-key`
### Adapter state
- `openclaw` adapter supports `sse|webhook` and has remap/fallback behavior for webhook mode.
- `openclaw_gateway` adapter is implemented and working for direct gateway invocation (`connect -> agent -> agent.wait`).
### Existing smoke foundation
- `scripts/smoke/openclaw-docker-ui.sh` builds/starts OpenClaw Docker and polls readiness on `http://127.0.0.1:18789/`.
- Current local OpenClaw smoke config commonly enables `gateway.http.endpoints.responses.enabled=true`, but not hooks (`gateway.hooks`).
## Deep Code Findings (Gaps)
### 1) Onboarding content is still OpenClaw-HTTP specific
`server/src/routes/access.ts` hardcodes onboarding to:
- `recommendedAdapterType: "openclaw"`
- Required `agentDefaultsPayload.headers.x-openclaw-auth`
- HTTP callback URL guidance and `/v1/responses` examples.
There is no adapter-specific onboarding manifest/text for `openclaw_gateway`.
### 2) Company settings snippet is OpenClaw HTTP-first
`ui/src/pages/CompanySettings.tsx` generates one snippet that:
- Assumes OpenClaw HTTP callback setup.
- Instructs enabling `gateway.http.endpoints.responses.enabled=true`.
- Does not provide a dedicated gateway onboarding path.
### 3) Invite landing “agent join” UX is not wired for OpenClaw adapters
`ui/src/pages/InviteLanding.tsx` shows `openclaw` and `openclaw_gateway` as disabled (“Coming soon”) in join UI.
### 4) Join normalization/replay logic only special-cases `adapterType === "openclaw"`
`server/src/routes/access.ts` helper paths (`buildJoinDefaultsPayloadForAccept`, replay, normalization diagnostics) are OpenClaw-HTTP specific.
No equivalent normalization/diagnostics for gateway defaults.
### 5) Webhook confusion is expected in current setup
For `openclaw` + `streamTransport=webhook`:
- Adapter may remap `/v1/responses -> /hooks/agent`.
- If `/hooks/agent` returns `404`, it falls back to `/v1/responses`.
If OpenClaw hooks are disabled, users still see successful `/v1/responses` runs even with webhook selected.
### 6) Auth/testing ergonomics mismatch in tailscale-auth dev mode
- Runtime can be `authenticated/private` via env overrides (`pnpm dev --tailscale-auth`).
- CLI bootstrap/admin helpers read config file (`config.json`), which may still say `local_trusted`.
- Board setup actions require session cookies; CLI `--api-key` cannot replace board session for invite/approval routes.
### 7) Gateway adapter lacks hire-approved callback parity
`openclaw` has `onHireApproved`; `openclaw_gateway` currently does not.
Not a blocker for core routing, but creates inconsistent onboarding feedback behavior.
## UX Intention (Target Experience)
### Product goal
Users should pick one clear onboarding path:
- `Invite OpenClaw (HTTP)` for existing webhook/SSE installs.
- `Invite OpenClaw Gateway` for gateway-native installs.
### UX design requirements
- One-click invite action per mode in `/CLA/company/settings` (or equivalent company settings route).
- Mode-specific generated snippet and mode-specific onboarding text.
- Clear compatibility checks before user copies anything.
### Proposed UX structure
1. Add invite buttons:
- `Invite OpenClaw (SSE/Webhook)`
- `Invite OpenClaw Gateway`
2. For HTTP invite:
- Require transport choice (`sse` or `webhook`).
- Validate endpoint expectations:
- `sse` with `/v1/responses`.
- `webhook` with `/hooks/*` and hooks enablement guidance.
3. For Gateway invite:
- Ask only for `ws://`/`wss://` and token source guidance.
- No callback URL/paperclipApiUrl complexity in onboarding.
4. Always show:
- Preflight diagnostics.
- Copy-ready command/snippet.
- Expected next steps (join -> approve -> claim -> skill install).
## Why Gateway Improves Onboarding
Compared to webhook/SSE onboarding:
- Fewer network assumptions: Paperclip dials outbound WebSocket to OpenClaw; avoids callback reachability pitfalls.
- Less transport ambiguity: no `/v1/responses` vs `/hooks/*` fallback confusion.
- Better run observability: gateway event frames stream lifecycle/delta events in one protocol.
Tradeoff:
- Requires stable WS endpoint and gateway token handling.
## Codex-Executable E2E Workflow
## Scope
Run this full flow per test cycle against company `CLA`:
1. Assign task to OpenClaw agent -> agent executes -> task closes.
2. Task asks OpenClaw to send message to user main chat via message tool -> message appears in main chat.
3. OpenClaw in a fresh/new session can still create a Paperclip task.
4. Use one primary OpenClaw bootstrap prompt (plus optional single follow-up ping) to perform setup.
## 0) Cleanup Before Each Run
Use deterministic reset to avoid stale agents/runs/state.
1. OpenClaw Docker cleanup:
```bash
# stop/remove OpenClaw compose services
OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker
if [ -d "$OPENCLAW_DOCKER_DIR" ]; then
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true
fi
# remove old image (as requested)
docker image rm openclaw:local || true
```
2. Recreate OpenClaw cleanly:
```bash
OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
```
This must remain a stock/clean image boot path, with no hidden manual state carried from prior runs.
3. Remove prior CLA OpenClaw agents:
- List `CLA` agents via API.
- Terminate/delete agents with `adapterType in ("openclaw", "openclaw_gateway")` before new onboarding.
4. Reject/clear stale pending join requests for CLA (optional but recommended).
## 1) Start Paperclip in Required Mode
```bash
pnpm dev --tailscale-auth
```
Verify:
```bash
curl -fsS http://127.0.0.1:3100/api/health
# expect deploymentMode=authenticated, deploymentExposure=private
```
## 2) Acquire Board Session for Automation
Board operations (create invite, approve join, terminate agents) require board session cookie.
Short-term practical options:
1. Preferred immediate path: reuse an existing signed-in board browser cookie and export as `PAPERCLIP_COOKIE`.
2. Scripted fallback: sign-up/sign-in via `/api/auth/*`, then use a dedicated admin promotion/bootstrap utility for dev (recommended to add as a small internal script).
Note:
- CLI `--api-key` is for agent auth and is not enough for board-only routes in this flow.
## 3) Resolve CLA Company ID
With board cookie:
```bash
curl -sS -H "Cookie: $PAPERCLIP_COOKIE" http://127.0.0.1:3100/api/companies
```
Pick company where identifier/code is `CLA` and store `CLA_COMPANY_ID`.
## 4) Preflight OpenClaw Endpoint Capability
From host (using current OpenClaw token):
- For HTTP SSE mode: confirm `/v1/responses` behavior.
- For HTTP webhook mode: confirm `/hooks/agent` exists; if 404, hooks are disabled.
- For gateway mode: confirm WS challenge appears from `ws://127.0.0.1:18789`.
Expected in current docker smoke config:
- `/hooks/agent` likely `404` unless hooks explicitly enabled.
- WS gateway protocol works.
## 5) Gateway Join Flow (Primary Path)
1. Create agent-only invite in CLA:
```bash
POST /api/companies/$CLA_COMPANY_ID/invites
{ "allowedJoinTypes": "agent" }
```
2. Submit join request with gateway defaults:
```json
{
"requestType": "agent",
"agentName": "OpenClaw Gateway",
"adapterType": "openclaw_gateway",
"capabilities": "OpenClaw gateway agent",
"agentDefaultsPayload": {
"url": "ws://127.0.0.1:18789",
"headers": { "x-openclaw-token": "<gateway-token>" },
"role": "operator",
"scopes": ["operator.admin"],
"sessionKeyStrategy": "fixed",
"sessionKey": "paperclip",
"waitTimeoutMs": 120000
}
}
```
3. Approve join request.
4. Claim API key with `claimSecret`.
5. Save claimed token to OpenClaw expected file path (`~/.openclaw/workspace/paperclip-claimed-api-key.json`) and ensure `PAPERCLIP_API_KEY` + `PAPERCLIP_API_URL` are available for OpenClaw skill execution context.
- Write compatibility JSON keys (`token` and `apiKey`) to avoid runtime parser mismatch.
6. Ensure Paperclip skill is installed for OpenClaw runtime.
7. Send one bootstrap prompt to OpenClaw containing all setup instructions needed for this run (auth file usage, heartbeat procedure, required tools). If needed, send one follow-up nudge only.
## 6) E2E Validation Cases
### Case A: Assigned task execution/closure
1. Create issue in CLA assigned to joined OpenClaw agent.
2. Poll issue + heartbeat runs until terminal.
3. Pass criteria:
- At least one run invoked for that agent/issue.
- Run status `succeeded`.
- Issue reaches `done` (or documented expected terminal state if policy differs).
### Case B: Message tool to main chat
1. Create issue instructing OpenClaw: “send a message to the users main chat session in webchat using message tool”.
2. Trigger/poll run completion.
3. Validate output:
- Automated minimum: run log/transcript confirms tool invocation success.
- UX-level validation: message visibly appears in main chat UI.
Current recommendation:
- Keep this checkpoint as manual/assisted until a browser automation harness is added for OpenClaw control UI verification.
### Case C: Fresh session still creates Paperclip task
1. Force fresh-session behavior for test:
- set agent `sessionKeyStrategy` to `run` (or explicitly rotate session key).
2. Create issue asking agent to create a new Paperclip task.
3. Pass criteria:
- New issue appears in CLA with expected title/body.
- Agent succeeds without re-onboarding.
## 7) Observability and Assertions
Use these APIs for deterministic assertions:
- `GET /api/companies/:companyId/heartbeat-runs?agentId=...`
- `GET /api/heartbeat-runs/:runId/events`
- `GET /api/heartbeat-runs/:runId/log`
- `GET /api/issues/:id`
- `GET /api/companies/:companyId/issues?q=...`
Include explicit timeout budgets per poll loop and hard failure reasons in output.
## 8) Automation Artifact
Implemented smoke harness:
- `scripts/smoke/openclaw-gateway-e2e.sh`
Responsibilities:
- OpenClaw docker cleanup/rebuild/start.
- Paperclip health/auth preflight.
- CLA company resolution.
- Old OpenClaw agent cleanup.
- Invite/join/approve/claim orchestration.
- E2E case execution + assertions.
- Final summary with run IDs, issue IDs, agent ID.
## 9) Required Product/Code Changes to Support This Plan Cleanly
### Access/onboarding backend
- Make onboarding manifest/text adapter-aware (`openclaw` vs `openclaw_gateway`).
- Add gateway-specific required fields and examples.
- Add gateway-specific diagnostics (WS URL/token/role/scopes/device-auth hints).
### Company settings UX
- Replace single generic snippet with mode-specific invite actions.
- Add “Invite OpenClaw Gateway” path with concise copy/paste onboarding.
### Invite landing UX
- Enable OpenClaw adapter options when invite allows agent join.
- Allow `agentDefaultsPayload` entry for advanced joins where needed.
### Adapter parity
- Consider `onHireApproved` support for `openclaw_gateway` for consistency.
### Test coverage
- Add integration tests for adapter-aware onboarding manifest generation.
- Add route tests for gateway join/approve/claim path.
- Add smoke test target for gateway E2E flow.
## 10) Execution Order
1. Implement onboarding manifest/text split by adapter mode.
2. Add company settings invite UX split (HTTP vs Gateway).
3. Add gateway E2E smoke script.
4. Run full CLA workflow in authenticated/private mode.
5. Iterate on message-tool verification automation.
## Acceptance Criteria
- No webhook-mode ambiguity: webhook path does not silently appear as SSE success without explicit compatibility signal.
- Gateway onboarding is first-class and copy/pasteable from company settings.
- Codex can run end-to-end onboarding and validation against CLA with repeatable cleanup.
- All three validation cases are documented with pass/fail criteria and reproducible evidence paths.

View File

@@ -0,0 +1,52 @@
{
"name": "@paperclipai/adapter-openclaw-gateway",
"version": "0.2.7",
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@paperclipai/adapter-utils": "workspace:*",
"picocolors": "^1.1.1",
"ws": "^8.19.0"
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/ws": "^8.18.1",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,23 @@
import pc from "picocolors";
export function printOpenClawGatewayStreamEvent(raw: string, debug: boolean): void {
const line = raw.trim();
if (!line) return;
if (!debug) {
console.log(line);
return;
}
if (line.startsWith("[openclaw-gateway:event]")) {
console.log(pc.cyan(line));
return;
}
if (line.startsWith("[openclaw-gateway]")) {
console.log(pc.blue(line));
return;
}
console.log(pc.gray(line));
}

View File

@@ -0,0 +1 @@
export { printOpenClawGatewayStreamEvent } from "./format-event.js";

View File

@@ -0,0 +1,41 @@
export const type = "openclaw_gateway";
export const label = "OpenClaw Gateway";
export const models: { id: string; label: string }[] = [];
export const agentConfigurationDoc = `# openclaw_gateway agent configuration
Adapter: openclaw_gateway
Use when:
- You want Paperclip to invoke OpenClaw over the Gateway WebSocket protocol.
- You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*.
Don't use when:
- You only expose OpenClaw HTTP endpoints (use openclaw adapter with sse/webhook transport).
- Your deployment does not permit outbound WebSocket access from the Paperclip server.
Core fields:
- url (string, required): OpenClaw gateway WebSocket URL (ws:// or wss://)
- headers (object, optional): handshake headers; supports x-openclaw-token / x-openclaw-auth
- authToken (string, optional): shared gateway token override
- password (string, optional): gateway shared password, if configured
Gateway connect identity fields:
- clientId (string, optional): gateway client id (default gateway-client)
- clientMode (string, optional): gateway client mode (default backend)
- clientVersion (string, optional): client version string
- role (string, optional): gateway role (default operator)
- scopes (string[] | comma string, optional): gateway scopes (default ["operator.admin"])
- disableDeviceAuth (boolean, optional): disable signed device payload in connect params (default false)
Request behavior fields:
- payloadTemplate (object, optional): additional fields merged into gateway agent params
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
- paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text
Session routing fields:
- sessionKeyStrategy (string, optional): fixed (default), issue, or run
- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip)
`;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";

View File

@@ -0,0 +1,317 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
import { randomUUID } from "node:crypto";
import { WebSocket } from "ws";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function nonEmpty(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function isLoopbackHost(hostname: string): boolean {
const value = hostname.trim().toLowerCase();
return value === "localhost" || value === "127.0.0.1" || value === "::1";
}
function toStringRecord(value: unknown): Record<string, string> {
const parsed = parseObject(value);
const out: Record<string, string> = {};
for (const [key, entry] of Object.entries(parsed)) {
if (typeof entry === "string") out[key] = entry;
}
return out;
}
function toStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean);
}
if (typeof value === "string") {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
return [];
}
function headerMapGetIgnoreCase(headers: Record<string, string>, key: string): string | null {
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
return match ? match[1] : null;
}
function tokenFromAuthHeader(rawHeader: string | null): string | null {
if (!rawHeader) return null;
const trimmed = rawHeader.trim();
if (!trimmed) return null;
const match = trimmed.match(/^bearer\s+(.+)$/i);
return match ? nonEmpty(match[1]) : trimmed;
}
function resolveAuthToken(config: Record<string, unknown>, headers: Record<string, string>): string | null {
const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token);
if (explicit) return explicit;
const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token");
if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader);
const authHeader =
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
headerMapGetIgnoreCase(headers, "authorization");
return tokenFromAuthHeader(authHeader);
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function rawDataToString(data: unknown): string {
if (typeof data === "string") return data;
if (Buffer.isBuffer(data)) return data.toString("utf8");
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
if (Array.isArray(data)) {
return Buffer.concat(
data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))),
).toString("utf8");
}
return String(data ?? "");
}
async function probeGateway(input: {
url: string;
headers: Record<string, string>;
authToken: string | null;
role: string;
scopes: string[];
timeoutMs: number;
}): Promise<"ok" | "challenge_only" | "failed"> {
return await new Promise((resolve) => {
const ws = new WebSocket(input.url, { headers: input.headers, maxPayload: 2 * 1024 * 1024 });
const timeout = setTimeout(() => {
try {
ws.close();
} catch {
// ignore
}
resolve("failed");
}, input.timeoutMs);
let completed = false;
const finish = (status: "ok" | "challenge_only" | "failed") => {
if (completed) return;
completed = true;
clearTimeout(timeout);
try {
ws.close();
} catch {
// ignore
}
resolve(status);
};
ws.on("message", (raw) => {
let parsed: unknown;
try {
parsed = JSON.parse(rawDataToString(raw));
} catch {
return;
}
const event = asRecord(parsed);
if (event?.type === "event" && event.event === "connect.challenge") {
const nonce = nonEmpty(asRecord(event.payload)?.nonce);
if (!nonce) {
finish("failed");
return;
}
const connectId = randomUUID();
ws.send(
JSON.stringify({
type: "req",
id: connectId,
method: "connect",
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: "gateway-client",
version: "paperclip-probe",
platform: process.platform,
mode: "probe",
},
role: input.role,
scopes: input.scopes,
...(input.authToken
? {
auth: {
token: input.authToken,
},
}
: {}),
},
}),
);
return;
}
if (event?.type === "res") {
if (event.ok === true) {
finish("ok");
} else {
finish("challenge_only");
}
}
});
ws.on("error", () => {
finish("failed");
});
ws.on("close", () => {
if (!completed) finish("failed");
});
});
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const urlValue = asString(config.url, "").trim();
if (!urlValue) {
checks.push({
code: "openclaw_gateway_url_missing",
level: "error",
message: "OpenClaw gateway adapter requires a WebSocket URL.",
hint: "Set adapterConfig.url to ws://host:port (or wss://).",
});
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}
let url: URL | null = null;
try {
url = new URL(urlValue);
} catch {
checks.push({
code: "openclaw_gateway_url_invalid",
level: "error",
message: `Invalid URL: ${urlValue}`,
});
}
if (url && url.protocol !== "ws:" && url.protocol !== "wss:") {
checks.push({
code: "openclaw_gateway_url_protocol_invalid",
level: "error",
message: `Unsupported URL protocol: ${url.protocol}`,
hint: "Use ws:// or wss://.",
});
}
if (url) {
checks.push({
code: "openclaw_gateway_url_valid",
level: "info",
message: `Configured gateway URL: ${url.toString()}`,
});
if (url.protocol === "ws:" && !isLoopbackHost(url.hostname)) {
checks.push({
code: "openclaw_gateway_plaintext_remote_ws",
level: "warn",
message: "Gateway URL uses plaintext ws:// on a non-loopback host.",
hint: "Prefer wss:// for remote gateways.",
});
}
}
const headers = toStringRecord(config.headers);
const authToken = resolveAuthToken(config, headers);
const password = nonEmpty(config.password);
const role = nonEmpty(config.role) ?? "operator";
const scopes = toStringArray(config.scopes);
if (authToken || password) {
checks.push({
code: "openclaw_gateway_auth_present",
level: "info",
message: "Gateway credentials are configured.",
});
} else {
checks.push({
code: "openclaw_gateway_auth_missing",
level: "warn",
message: "No gateway credentials detected in adapter config.",
hint: "Set authToken/password or headers.x-openclaw-token for authenticated gateways.",
});
}
if (url && (url.protocol === "ws:" || url.protocol === "wss:")) {
try {
const probeResult = await probeGateway({
url: url.toString(),
headers,
authToken,
role,
scopes: scopes.length > 0 ? scopes : ["operator.admin"],
timeoutMs: 3_000,
});
if (probeResult === "ok") {
checks.push({
code: "openclaw_gateway_probe_ok",
level: "info",
message: "Gateway connect probe succeeded.",
});
} else if (probeResult === "challenge_only") {
checks.push({
code: "openclaw_gateway_probe_challenge_only",
level: "warn",
message: "Gateway challenge was received, but connect probe was rejected.",
hint: "Check gateway credentials, scopes, role, and device-auth requirements.",
});
} else {
checks.push({
code: "openclaw_gateway_probe_failed",
level: "warn",
message: "Gateway probe failed.",
hint: "Verify network reachability and gateway URL from the Paperclip server host.",
});
}
} catch (err) {
checks.push({
code: "openclaw_gateway_probe_error",
level: "warn",
message: err instanceof Error ? err.message : "Gateway probe failed",
});
}
}
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View File

@@ -0,0 +1,16 @@
export function normalizeOpenClawGatewayStreamLine(rawLine: string): {
stream: "stdout" | "stderr" | null;
line: string;
} {
const trimmed = rawLine.trim();
if (!trimmed) return { stream: null, line: "" };
const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*(.*)$/i);
if (!prefixed) {
return { stream: null, line: trimmed };
}
const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout";
const line = (prefixed[2] ?? "").trim();
return { stream, line };
}

View File

@@ -0,0 +1,13 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.url) ac.url = v.url;
ac.timeoutSec = 120;
ac.waitTimeoutMs = 120000;
ac.sessionKeyStrategy = "fixed";
ac.sessionKey = "paperclip";
ac.role = "operator";
ac.scopes = ["operator.admin"];
return ac;
}

View File

@@ -0,0 +1,2 @@
export { parseOpenClawGatewayStdoutLine } from "./parse-stdout.js";
export { buildOpenClawGatewayConfig } from "./build-config.js";

View File

@@ -0,0 +1,75 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import { normalizeOpenClawGatewayStreamLine } from "../shared/stream.js";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function parseAgentEventLine(line: string, ts: string): TranscriptEntry[] {
const match = line.match(/^\[openclaw-gateway:event\]\s+run=([^\s]+)\s+stream=([^\s]+)\s+data=(.*)$/s);
if (!match) return [{ kind: "stdout", ts, text: line }];
const stream = asString(match[2]).toLowerCase();
const data = asRecord(safeJsonParse(asString(match[3]).trim()));
if (stream === "assistant") {
const delta = asString(data?.delta);
if (delta.length > 0) {
return [{ kind: "assistant", ts, text: delta, delta: true }];
}
const text = asString(data?.text);
if (text.length > 0) {
return [{ kind: "assistant", ts, text }];
}
return [];
}
if (stream === "error") {
const message = asString(data?.error) || asString(data?.message);
return message ? [{ kind: "stderr", ts, text: message }] : [];
}
if (stream === "lifecycle") {
const phase = asString(data?.phase).toLowerCase();
const message = asString(data?.error) || asString(data?.message);
if ((phase === "error" || phase === "failed" || phase === "cancelled") && message) {
return [{ kind: "stderr", ts, text: message }];
}
}
return [];
}
export function parseOpenClawGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] {
const normalized = normalizeOpenClawGatewayStreamLine(line);
if (normalized.stream === "stderr") {
return [{ kind: "stderr", ts, text: normalized.line }];
}
const trimmed = normalized.line.trim();
if (!trimmed) return [];
if (trimmed.startsWith("[openclaw-gateway:event]")) {
return parseAgentEventLine(trimmed, ts);
}
if (trimmed.startsWith("[openclaw-gateway]")) {
return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw-gateway\]\s*/, "") }];
}
return [{ kind: "stdout", ts, text: normalized.line }];
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -290,15 +290,21 @@ export function buildWakeText(payload: WakePayload, paperclipEnv: Record<string,
envLines.push(`${key}=${value}`);
}
const issueIdHint = payload.taskId ?? payload.issueId ?? "";
const apiBaseHint = paperclipEnv.PAPERCLIP_API_URL ?? "<set PAPERCLIP_API_URL>";
const lines = [
"Paperclip wake event for a cloud adapter.",
"",
"Run this procedure now. Do not guess undocumented endpoints and do not ask for additional heartbeat docs.",
"",
"Set these values in your run context:",
...envLines,
`PAPERCLIP_API_KEY=<token from ${claimedApiKeyPath}>`,
"",
`Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`,
"",
`api_base=${apiBaseHint}`,
`task_id=${payload.taskId ?? ""}`,
`issue_id=${payload.issueId ?? ""}`,
`wake_reason=${payload.wakeReason ?? ""}`,
@@ -306,9 +312,34 @@ export function buildWakeText(payload: WakePayload, paperclipEnv: Record<string,
`approval_id=${payload.approvalId ?? ""}`,
`approval_status=${payload.approvalStatus ?? ""}`,
`linked_issue_ids=${payload.issueIds.join(",")}`,
"",
"HTTP rules:",
"- Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call.",
"- Use X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutating API call.",
"- Use only /api endpoints listed below.",
"- Do NOT call guessed endpoints like /api/cloud-adapter/*, /api/cloud-adapters/*, /api/adapters/cloud/*, or /api/heartbeat.",
"",
"Workflow:",
"1) GET /api/agents/me",
`2) Determine issueId: PAPERCLIP_TASK_ID if present, otherwise issue_id (${issueIdHint}).`,
"3) If issueId exists:",
" - POST /api/issues/{issueId}/checkout with {\"agentId\":\"$PAPERCLIP_AGENT_ID\",\"expectedStatuses\":[\"todo\",\"backlog\",\"blocked\"]}",
" - GET /api/issues/{issueId}",
" - GET /api/issues/{issueId}/comments",
" - Execute the issue instructions exactly.",
" - If instructions require a comment, POST /api/issues/{issueId}/comments with {\"body\":\"...\"}.",
" - PATCH /api/issues/{issueId} with {\"status\":\"done\",\"comment\":\"what changed and why\"}.",
"4) If issueId does not exist:",
" - GET /api/companies/$PAPERCLIP_COMPANY_ID/issues?assigneeAgentId=$PAPERCLIP_AGENT_ID&status=todo,in_progress,blocked",
" - Pick in_progress first, then todo, then blocked, then execute step 3.",
"",
"Useful endpoints for issue work:",
"- POST /api/issues/{issueId}/comments",
"- PATCH /api/issues/{issueId}",
"- POST /api/companies/{companyId}/issues (when asked to create a new issue)",
"",
"Complete the workflow in this run.",
];
lines.push("", "Run your Paperclip heartbeat procedure now.");
return lines.join("\n");
}

View File

@@ -30,6 +30,7 @@ export const AGENT_ADAPTER_TYPES = [
"pi_local",
"cursor",
"openclaw",
"openclaw_gateway",
] as const;
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];

958
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -56,6 +56,7 @@ require_cmd grep
OPENCLAW_REPO_URL="${OPENCLAW_REPO_URL:-https://github.com/openclaw/openclaw.git}"
OPENCLAW_DOCKER_DIR="${OPENCLAW_DOCKER_DIR:-/tmp/openclaw-docker}"
OPENCLAW_REPO_REF="${OPENCLAW_REPO_REF:-v2026.3.2}"
OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-openclaw:local}"
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-${TMPDIR:-/tmp}}"
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR%/}"
@@ -101,14 +102,23 @@ fi
log "preparing OpenClaw repo at $OPENCLAW_DOCKER_DIR"
if [[ -d "$OPENCLAW_DOCKER_DIR/.git" ]]; then
git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet origin || true
git -C "$OPENCLAW_DOCKER_DIR" checkout --quiet main || true
git -C "$OPENCLAW_DOCKER_DIR" pull --ff-only --quiet origin main || true
git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet --tags origin || true
else
rm -rf "$OPENCLAW_DOCKER_DIR"
git clone "$OPENCLAW_REPO_URL" "$OPENCLAW_DOCKER_DIR"
git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet --tags origin || true
fi
resolved_openclaw_ref=""
if git -C "$OPENCLAW_DOCKER_DIR" rev-parse --verify --quiet "origin/$OPENCLAW_REPO_REF" >/dev/null; then
resolved_openclaw_ref="origin/$OPENCLAW_REPO_REF"
elif git -C "$OPENCLAW_DOCKER_DIR" rev-parse --verify --quiet "$OPENCLAW_REPO_REF" >/dev/null; then
resolved_openclaw_ref="$OPENCLAW_REPO_REF"
fi
[[ -n "$resolved_openclaw_ref" ]] || fail "unable to resolve OPENCLAW_REPO_REF=$OPENCLAW_REPO_REF in $OPENCLAW_DOCKER_DIR"
git -C "$OPENCLAW_DOCKER_DIR" checkout --quiet "$resolved_openclaw_ref"
log "using OpenClaw ref $resolved_openclaw_ref ($(git -C "$OPENCLAW_DOCKER_DIR" rev-parse --short HEAD))"
if [[ "$OPENCLAW_BUILD" == "1" ]]; then
log "building Docker image $OPENCLAW_IMAGE"
docker build -t "$OPENCLAW_IMAGE" -f "$OPENCLAW_DOCKER_DIR/Dockerfile" "$OPENCLAW_DOCKER_DIR"

View File

@@ -0,0 +1,868 @@
#!/usr/bin/env bash
set -euo pipefail
log() {
echo "[openclaw-gateway-e2e] $*"
}
warn() {
echo "[openclaw-gateway-e2e] WARN: $*" >&2
}
fail() {
echo "[openclaw-gateway-e2e] ERROR: $*" >&2
exit 1
}
require_cmd() {
local cmd="$1"
command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: $cmd"
}
require_cmd curl
require_cmd jq
require_cmd docker
require_cmd node
require_cmd shasum
PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://127.0.0.1:3100}"
API_BASE="${PAPERCLIP_API_URL%/}/api"
COMPANY_SELECTOR="${COMPANY_SELECTOR:-CLA}"
OPENCLAW_AGENT_NAME="${OPENCLAW_AGENT_NAME:-OpenClaw Gateway Smoke Agent}"
OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-ws://127.0.0.1:18789}"
OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-}"
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-${TMPDIR:-/tmp}}"
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR%/}"
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-/tmp}"
OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-${OPENCLAW_TMP_DIR}/openclaw-paperclip-smoke}"
OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-${OPENCLAW_CONFIG_DIR}/workspace}"
OPENCLAW_CONTAINER_NAME="${OPENCLAW_CONTAINER_NAME:-openclaw-docker-openclaw-gateway-1}"
OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-openclaw:local}"
OPENCLAW_DOCKER_DIR="${OPENCLAW_DOCKER_DIR:-/tmp/openclaw-docker}"
OPENCLAW_RESET_DOCKER="${OPENCLAW_RESET_DOCKER:-1}"
OPENCLAW_BUILD="${OPENCLAW_BUILD:-1}"
OPENCLAW_WAIT_SECONDS="${OPENCLAW_WAIT_SECONDS:-60}"
OPENCLAW_RESET_STATE="${OPENCLAW_RESET_STATE:-1}"
PAPERCLIP_API_URL_FOR_OPENCLAW="${PAPERCLIP_API_URL_FOR_OPENCLAW:-http://host.docker.internal:3100}"
CASE_TIMEOUT_SEC="${CASE_TIMEOUT_SEC:-420}"
RUN_TIMEOUT_SEC="${RUN_TIMEOUT_SEC:-300}"
STRICT_CASES="${STRICT_CASES:-1}"
AUTO_INSTALL_SKILL="${AUTO_INSTALL_SKILL:-1}"
OPENCLAW_DIAG_DIR="${OPENCLAW_DIAG_DIR:-/tmp/openclaw-gateway-e2e-diag-$(date +%Y%m%d-%H%M%S)}"
OPENCLAW_ADAPTER_TIMEOUT_SEC="${OPENCLAW_ADAPTER_TIMEOUT_SEC:-120}"
OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS="${OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS:-120000}"
PAYLOAD_TEMPLATE_MESSAGE_APPEND="${PAYLOAD_TEMPLATE_MESSAGE_APPEND:-}"
AUTH_HEADERS=()
if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then
AUTH_HEADERS+=( -H "Authorization: ${PAPERCLIP_AUTH_HEADER}" )
fi
if [[ -n "${PAPERCLIP_COOKIE:-}" ]]; then
AUTH_HEADERS+=( -H "Cookie: ${PAPERCLIP_COOKIE}" )
PAPERCLIP_BROWSER_ORIGIN="${PAPERCLIP_BROWSER_ORIGIN:-${PAPERCLIP_API_URL%/}}"
AUTH_HEADERS+=( -H "Origin: ${PAPERCLIP_BROWSER_ORIGIN}" -H "Referer: ${PAPERCLIP_BROWSER_ORIGIN}/" )
fi
RESPONSE_CODE=""
RESPONSE_BODY=""
COMPANY_ID=""
AGENT_ID=""
AGENT_API_KEY=""
JOIN_REQUEST_ID=""
INVITE_ID=""
RUN_ID=""
CASE_A_ISSUE_ID=""
CASE_B_ISSUE_ID=""
CASE_C_ISSUE_ID=""
CASE_C_CREATED_ISSUE_ID=""
api_request() {
local method="$1"
local path="$2"
local data="${3-}"
local tmp
tmp="$(mktemp)"
local url
if [[ "$path" == http://* || "$path" == https://* ]]; then
url="$path"
elif [[ "$path" == /api/* ]]; then
url="${PAPERCLIP_API_URL%/}${path}"
else
url="${API_BASE}${path}"
fi
if [[ -n "$data" ]]; then
if (( ${#AUTH_HEADERS[@]} > 0 )); then
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" -H "Content-Type: application/json" "$url" --data "$data")"
else
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" -H "Content-Type: application/json" "$url" --data "$data")"
fi
else
if (( ${#AUTH_HEADERS[@]} > 0 )); then
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" "$url")"
else
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "$url")"
fi
fi
RESPONSE_BODY="$(cat "$tmp")"
rm -f "$tmp"
}
capture_run_diagnostics() {
local run_id="$1"
local label="${2:-run}"
[[ -n "$run_id" ]] || return 0
mkdir -p "$OPENCLAW_DIAG_DIR"
api_request "GET" "/heartbeat-runs/${run_id}/events?limit=1000"
if [[ "$RESPONSE_CODE" == "200" ]]; then
printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-events.json"
else
warn "could not fetch events for run ${run_id} (HTTP ${RESPONSE_CODE})"
fi
api_request "GET" "/heartbeat-runs/${run_id}/log?limitBytes=524288"
if [[ "$RESPONSE_CODE" == "200" ]]; then
printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-log.json"
jq -r '.content // ""' <<<"$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-log.txt" 2>/dev/null || true
else
warn "could not fetch log for run ${run_id} (HTTP ${RESPONSE_CODE})"
fi
}
capture_issue_diagnostics() {
local issue_id="$1"
local label="${2:-issue}"
[[ -n "$issue_id" ]] || return 0
mkdir -p "$OPENCLAW_DIAG_DIR"
api_request "GET" "/issues/${issue_id}"
if [[ "$RESPONSE_CODE" == "200" ]]; then
printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${issue_id}.json"
fi
api_request "GET" "/issues/${issue_id}/comments"
if [[ "$RESPONSE_CODE" == "200" ]]; then
printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${issue_id}-comments.json"
fi
}
capture_openclaw_container_logs() {
mkdir -p "$OPENCLAW_DIAG_DIR"
local container
container="$(detect_openclaw_container || true)"
if [[ -z "$container" ]]; then
warn "could not detect OpenClaw container for diagnostics"
return 0
fi
docker logs --tail=1200 "$container" > "${OPENCLAW_DIAG_DIR}/openclaw-container.log" 2>&1 || true
}
assert_status() {
local expected="$1"
if [[ "$RESPONSE_CODE" != "$expected" ]]; then
echo "$RESPONSE_BODY" >&2
fail "expected HTTP ${expected}, got ${RESPONSE_CODE}"
fi
}
require_board_auth() {
if [[ ${#AUTH_HEADERS[@]} -eq 0 ]]; then
fail "board auth required. Set PAPERCLIP_COOKIE or PAPERCLIP_AUTH_HEADER."
fi
api_request "GET" "/companies"
if [[ "$RESPONSE_CODE" != "200" ]]; then
echo "$RESPONSE_BODY" >&2
fail "board auth invalid for /api/companies (HTTP ${RESPONSE_CODE})"
fi
}
maybe_cleanup_openclaw_docker() {
if [[ "$OPENCLAW_RESET_DOCKER" != "1" ]]; then
log "OPENCLAW_RESET_DOCKER=${OPENCLAW_RESET_DOCKER}; skipping docker cleanup"
return
fi
log "cleaning OpenClaw docker state"
if [[ -d "$OPENCLAW_DOCKER_DIR" ]]; then
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans >/dev/null 2>&1 || true
fi
if docker ps -a --format '{{.Names}}' | grep -qx "$OPENCLAW_CONTAINER_NAME"; then
docker rm -f "$OPENCLAW_CONTAINER_NAME" >/dev/null 2>&1 || true
fi
docker image rm "$OPENCLAW_IMAGE" >/dev/null 2>&1 || true
}
start_openclaw_docker() {
log "starting clean OpenClaw docker"
OPENCLAW_CONFIG_DIR="$OPENCLAW_CONFIG_DIR" OPENCLAW_WORKSPACE_DIR="$OPENCLAW_WORKSPACE_DIR" \
OPENCLAW_RESET_STATE="$OPENCLAW_RESET_STATE" OPENCLAW_BUILD="$OPENCLAW_BUILD" OPENCLAW_WAIT_SECONDS="$OPENCLAW_WAIT_SECONDS" \
./scripts/smoke/openclaw-docker-ui.sh
}
wait_http_ready() {
local url="$1"
local timeout_sec="$2"
local started_at now code
started_at="$(date +%s)"
while true; do
code="$(curl -sS -o /dev/null -w "%{http_code}" "$url" || true)"
if [[ "$code" == "200" ]]; then
return 0
fi
now="$(date +%s)"
if (( now - started_at >= timeout_sec )); then
return 1
fi
sleep 1
done
}
detect_openclaw_container() {
if docker ps --format '{{.Names}}' | grep -qx "$OPENCLAW_CONTAINER_NAME"; then
echo "$OPENCLAW_CONTAINER_NAME"
return 0
fi
local detected
detected="$(docker ps --format '{{.Names}}' | grep 'openclaw-gateway' | head -n1 || true)"
if [[ -n "$detected" ]]; then
echo "$detected"
return 0
fi
return 1
}
detect_gateway_token() {
if [[ -n "$OPENCLAW_GATEWAY_TOKEN" ]]; then
echo "$OPENCLAW_GATEWAY_TOKEN"
return 0
fi
local config_path
config_path="${OPENCLAW_CONFIG_DIR%/}/openclaw.json"
if [[ -f "$config_path" ]]; then
local token
token="$(jq -r '.gateway.auth.token // empty' "$config_path")"
if [[ -n "$token" ]]; then
echo "$token"
return 0
fi
fi
local container
container="$(detect_openclaw_container || true)"
if [[ -n "$container" ]]; then
local token_from_container
token_from_container="$(docker exec "$container" sh -lc "node -e 'const fs=require(\"fs\");const c=JSON.parse(fs.readFileSync(\"/home/node/.openclaw/openclaw.json\",\"utf8\"));process.stdout.write(c.gateway?.auth?.token||\"\");'" 2>/dev/null || true)"
if [[ -n "$token_from_container" ]]; then
echo "$token_from_container"
return 0
fi
fi
return 1
}
hash_prefix() {
local value="$1"
printf "%s" "$value" | shasum -a 256 | awk '{print $1}' | cut -c1-12
}
probe_gateway_ws() {
local url="$1"
local token="$2"
node - "$url" "$token" <<'NODE'
const WebSocket = require("ws");
const url = process.argv[2];
const token = process.argv[3];
const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } });
const timeout = setTimeout(() => {
console.error("gateway probe timed out");
process.exit(2);
}, 8000);
ws.on("message", (raw) => {
try {
const message = JSON.parse(String(raw));
if (message?.type === "event" && message?.event === "connect.challenge") {
clearTimeout(timeout);
ws.close();
process.exit(0);
}
} catch {
// ignore
}
});
ws.on("error", (err) => {
clearTimeout(timeout);
console.error(err?.message || String(err));
process.exit(1);
});
NODE
}
resolve_company_id() {
api_request "GET" "/companies"
assert_status "200"
local selector
selector="$(printf "%s" "$COMPANY_SELECTOR" | tr '[:lower:]' '[:upper:]')"
COMPANY_ID="$(jq -r --arg sel "$selector" '
map(select(
((.id // "") | ascii_upcase) == $sel or
((.name // "") | ascii_upcase) == $sel or
((.issuePrefix // "") | ascii_upcase) == $sel
))
| .[0].id // empty
' <<<"$RESPONSE_BODY")"
if [[ -z "$COMPANY_ID" ]]; then
local available
available="$(jq -r '.[] | "- id=\(.id) issuePrefix=\(.issuePrefix // "") name=\(.name // "")"' <<<"$RESPONSE_BODY")"
echo "$available" >&2
fail "could not find company for selector '${COMPANY_SELECTOR}'"
fi
log "resolved company ${COMPANY_ID} from selector ${COMPANY_SELECTOR}"
}
cleanup_openclaw_agents() {
api_request "GET" "/companies/${COMPANY_ID}/agents"
assert_status "200"
local ids
ids="$(jq -r '.[] | select((.adapterType == "openclaw" or .adapterType == "openclaw_gateway")) | .id' <<<"$RESPONSE_BODY")"
if [[ -z "$ids" ]]; then
log "no prior OpenClaw agents to cleanup"
return
fi
while IFS= read -r id; do
[[ -n "$id" ]] || continue
log "terminating prior OpenClaw agent ${id}"
api_request "POST" "/agents/${id}/terminate" "{}"
if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" ]]; then
warn "terminate ${id} returned HTTP ${RESPONSE_CODE}"
fi
api_request "DELETE" "/agents/${id}"
if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" ]]; then
warn "delete ${id} returned HTTP ${RESPONSE_CODE}"
fi
done <<<"$ids"
}
cleanup_pending_join_requests() {
api_request "GET" "/companies/${COMPANY_ID}/join-requests?status=pending_approval"
if [[ "$RESPONSE_CODE" != "200" ]]; then
warn "join-request cleanup skipped (HTTP ${RESPONSE_CODE})"
return
fi
local ids
ids="$(jq -r '.[] | select((.adapterType == "openclaw" or .adapterType == "openclaw_gateway")) | .id' <<<"$RESPONSE_BODY")"
if [[ -z "$ids" ]]; then
return
fi
while IFS= read -r request_id; do
[[ -n "$request_id" ]] || continue
log "rejecting stale pending join request ${request_id}"
api_request "POST" "/companies/${COMPANY_ID}/join-requests/${request_id}/reject" "{}"
if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" && "$RESPONSE_CODE" != "409" ]]; then
warn "reject ${request_id} returned HTTP ${RESPONSE_CODE}"
fi
done <<<"$ids"
}
create_and_approve_gateway_join() {
local gateway_token="$1"
local invite_payload
invite_payload="$(jq -nc '{allowedJoinTypes:"agent"}')"
api_request "POST" "/companies/${COMPANY_ID}/invites" "$invite_payload"
assert_status "201"
local invite_token
invite_token="$(jq -r '.token // empty' <<<"$RESPONSE_BODY")"
INVITE_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
[[ -n "$invite_token" && -n "$INVITE_ID" ]] || fail "invite creation missing token/id"
local join_payload
join_payload="$(jq -nc \
--arg name "$OPENCLAW_AGENT_NAME" \
--arg url "$OPENCLAW_GATEWAY_URL" \
--arg token "$gateway_token" \
--arg paperclipApiUrl "$PAPERCLIP_API_URL_FOR_OPENCLAW" \
--argjson timeoutSec "$OPENCLAW_ADAPTER_TIMEOUT_SEC" \
--argjson waitTimeoutMs "$OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS" \
'{
requestType: "agent",
agentName: $name,
adapterType: "openclaw_gateway",
capabilities: "OpenClaw gateway smoke harness",
agentDefaultsPayload: {
url: $url,
headers: { "x-openclaw-token": $token },
role: "operator",
scopes: ["operator.admin"],
disableDeviceAuth: true,
sessionKeyStrategy: "fixed",
sessionKey: "paperclip",
timeoutSec: $timeoutSec,
waitTimeoutMs: $waitTimeoutMs,
paperclipApiUrl: $paperclipApiUrl
}
}')"
api_request "POST" "/invites/${invite_token}/accept" "$join_payload"
assert_status "202"
JOIN_REQUEST_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
local claim_secret
claim_secret="$(jq -r '.claimSecret // empty' <<<"$RESPONSE_BODY")"
local claim_path
claim_path="$(jq -r '.claimApiKeyPath // empty' <<<"$RESPONSE_BODY")"
[[ -n "$JOIN_REQUEST_ID" && -n "$claim_secret" && -n "$claim_path" ]] || fail "join accept missing claim metadata"
log "approving join request ${JOIN_REQUEST_ID}"
api_request "POST" "/companies/${COMPANY_ID}/join-requests/${JOIN_REQUEST_ID}/approve" "{}"
assert_status "200"
AGENT_ID="$(jq -r '.createdAgentId // empty' <<<"$RESPONSE_BODY")"
[[ -n "$AGENT_ID" ]] || fail "join approval missing createdAgentId"
log "claiming one-time agent API key"
local claim_payload
claim_payload="$(jq -nc --arg secret "$claim_secret" '{claimSecret:$secret}')"
api_request "POST" "$claim_path" "$claim_payload"
assert_status "201"
AGENT_API_KEY="$(jq -r '.token // empty' <<<"$RESPONSE_BODY")"
[[ -n "$AGENT_API_KEY" ]] || fail "claim response missing token"
persist_claimed_key_artifacts "$RESPONSE_BODY"
inject_agent_api_key_payload_template
}
persist_claimed_key_artifacts() {
local claim_json="$1"
local workspace_dir="${OPENCLAW_CONFIG_DIR%/}/workspace"
local skill_dir="${OPENCLAW_CONFIG_DIR%/}/skills/paperclip"
local claimed_file="${workspace_dir}/paperclip-claimed-api-key.json"
local claimed_raw_file="${workspace_dir}/paperclip-claimed-api-key.raw.json"
mkdir -p "$workspace_dir" "$skill_dir"
local token
token="$(jq -r '.token // .apiKey // empty' <<<"$claim_json")"
[[ -n "$token" ]] || fail "claim response missing token/apiKey"
printf "%s\n" "$claim_json" > "$claimed_raw_file"
chmod 600 "$claimed_raw_file"
jq -nc --arg token "$token" '{ token: $token, apiKey: $token }' > "$claimed_file"
# Keep this readable for OpenClaw runtime users across sandbox/container contexts.
chmod 644 "$claimed_file"
local container
container="$(detect_openclaw_container || true)"
if [[ -n "$container" ]]; then
docker exec "$container" sh -lc "mkdir -p /home/node/.openclaw/workspace" >/dev/null 2>&1 || true
docker cp "$claimed_file" "${container}:/home/node/.openclaw/workspace/paperclip-claimed-api-key.json" >/dev/null 2>&1 || true
docker exec "$container" sh -lc "chmod 644 /home/node/.openclaw/workspace/paperclip-claimed-api-key.json" >/dev/null 2>&1 || true
fi
if [[ "$AUTO_INSTALL_SKILL" == "1" ]]; then
api_request "GET" "/skills/paperclip"
assert_status "200"
{
echo "PAPERCLIP_API_URL=${PAPERCLIP_API_URL_FOR_OPENCLAW}"
echo
printf "%s\n" "$RESPONSE_BODY"
} > "${skill_dir}/SKILL.md"
chmod 600 "${skill_dir}/SKILL.md"
fi
log "wrote claimed key artifacts to ${claimed_file} and ${claimed_raw_file}"
}
inject_agent_api_key_payload_template() {
[[ -n "$AGENT_ID" ]] || fail "inject_agent_api_key_payload_template requires AGENT_ID"
[[ -n "$AGENT_API_KEY" ]] || fail "inject_agent_api_key_payload_template requires AGENT_API_KEY"
api_request "GET" "/agents/${AGENT_ID}"
assert_status "200"
local base_message
base_message="Set PAPERCLIP_API_KEY=${AGENT_API_KEY} in your run context before running Paperclip heartbeat steps."
if [[ -n "$PAYLOAD_TEMPLATE_MESSAGE_APPEND" ]]; then
base_message="${base_message}\n\n${PAYLOAD_TEMPLATE_MESSAGE_APPEND}"
fi
local patch_payload
patch_payload="$(jq -c --arg message "$base_message" '
{adapterConfig: ((.adapterConfig // {}) + {
payloadTemplate: (((.adapterConfig // {}).payloadTemplate // {}) + {
message: $message
})
})}
' <<<"$RESPONSE_BODY")"
api_request "PATCH" "/agents/${AGENT_ID}" "$patch_payload"
assert_status "200"
}
trigger_wakeup() {
local reason="$1"
local issue_id="${2:-}"
local payload
if [[ -n "$issue_id" ]]; then
payload="$(jq -nc --arg issueId "$issue_id" --arg reason "$reason" '{source:"on_demand",triggerDetail:"manual",reason:$reason,payload:{issueId:$issueId,taskId:$issueId}}')"
else
payload="$(jq -nc --arg reason "$reason" '{source:"on_demand",triggerDetail:"manual",reason:$reason}')"
fi
api_request "POST" "/agents/${AGENT_ID}/wakeup" "$payload"
if [[ "$RESPONSE_CODE" != "202" ]]; then
echo "$RESPONSE_BODY" >&2
fail "wakeup failed (HTTP ${RESPONSE_CODE})"
fi
RUN_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
if [[ -z "$RUN_ID" ]]; then
warn "wakeup response did not include run id; body: ${RESPONSE_BODY}"
fi
}
get_run_status() {
local run_id="$1"
api_request "GET" "/companies/${COMPANY_ID}/heartbeat-runs?agentId=${AGENT_ID}&limit=200"
if [[ "$RESPONSE_CODE" != "200" ]]; then
echo ""
return 0
fi
jq -r --arg runId "$run_id" '.[] | select(.id == $runId) | .status' <<<"$RESPONSE_BODY" | head -n1
}
wait_for_run_terminal() {
local run_id="$1"
local timeout_sec="$2"
local started now status
[[ -n "$run_id" ]] || fail "wait_for_run_terminal requires run id"
started="$(date +%s)"
while true; do
status="$(get_run_status "$run_id")"
if [[ "$status" == "succeeded" || "$status" == "failed" || "$status" == "timed_out" || "$status" == "cancelled" ]]; then
if [[ "$status" != "succeeded" ]]; then
capture_run_diagnostics "$run_id" "run-nonsuccess"
capture_openclaw_container_logs
fi
echo "$status"
return 0
fi
now="$(date +%s)"
if (( now - started >= timeout_sec )); then
capture_run_diagnostics "$run_id" "run-timeout"
capture_openclaw_container_logs
echo "timeout"
return 0
fi
sleep 2
done
}
get_issue_status() {
local issue_id="$1"
api_request "GET" "/issues/${issue_id}"
if [[ "$RESPONSE_CODE" != "200" ]]; then
echo ""
return 0
fi
jq -r '.status // empty' <<<"$RESPONSE_BODY"
}
wait_for_issue_terminal() {
local issue_id="$1"
local timeout_sec="$2"
local started now status
started="$(date +%s)"
while true; do
status="$(get_issue_status "$issue_id")"
if [[ "$status" == "done" || "$status" == "blocked" || "$status" == "cancelled" ]]; then
echo "$status"
return 0
fi
now="$(date +%s)"
if (( now - started >= timeout_sec )); then
echo "timeout"
return 0
fi
sleep 3
done
}
issue_comments_contain() {
local issue_id="$1"
local marker="$2"
api_request "GET" "/issues/${issue_id}/comments"
if [[ "$RESPONSE_CODE" != "200" ]]; then
echo "false"
return 0
fi
jq -r --arg marker "$marker" '[.[] | (.body // "") | contains($marker)] | any' <<<"$RESPONSE_BODY"
}
create_issue_for_case() {
local title="$1"
local description="$2"
local priority="${3:-high}"
local payload
payload="$(jq -nc \
--arg title "$title" \
--arg description "$description" \
--arg assignee "$AGENT_ID" \
--arg priority "$priority" \
'{title:$title,description:$description,status:"todo",priority:$priority,assigneeAgentId:$assignee}')"
api_request "POST" "/companies/${COMPANY_ID}/issues" "$payload"
assert_status "201"
local issue_id issue_identifier
issue_id="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
issue_identifier="$(jq -r '.identifier // empty' <<<"$RESPONSE_BODY")"
[[ -n "$issue_id" ]] || fail "issue create missing id"
echo "${issue_id}|${issue_identifier}"
}
patch_agent_session_strategy_run() {
api_request "GET" "/agents/${AGENT_ID}"
assert_status "200"
local patch_payload
patch_payload="$(jq -c '{adapterConfig: ((.adapterConfig // {}) + {sessionKeyStrategy:"run"})}' <<<"$RESPONSE_BODY")"
api_request "PATCH" "/agents/${AGENT_ID}" "$patch_payload"
assert_status "200"
}
find_issue_by_query() {
local query="$1"
local encoded_query
encoded_query="$(jq -rn --arg q "$query" '$q|@uri')"
api_request "GET" "/companies/${COMPANY_ID}/issues?q=${encoded_query}"
if [[ "$RESPONSE_CODE" != "200" ]]; then
echo ""
return 0
fi
jq -r '.[] | .id' <<<"$RESPONSE_BODY" | head -n1
}
run_case_a() {
local marker="OPENCLAW_CASE_A_OK_$(date +%s)"
local description
description="Case A validation.\n\n1) Read this issue.\n2) Post a comment containing exactly: ${marker}\n3) Mark this issue done."
local created
created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case A" "$description")"
CASE_A_ISSUE_ID="${created%%|*}"
local case_identifier="${created##*|}"
log "case A issue ${CASE_A_ISSUE_ID} (${case_identifier})"
trigger_wakeup "openclaw_gateway_smoke_case_a" "$CASE_A_ISSUE_ID"
local run_status issue_status marker_found
if [[ -n "$RUN_ID" ]]; then
run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
log "case A run ${RUN_ID} status=${run_status}"
else
run_status="unknown"
fi
issue_status="$(wait_for_issue_terminal "$CASE_A_ISSUE_ID" "$CASE_TIMEOUT_SEC")"
marker_found="$(issue_comments_contain "$CASE_A_ISSUE_ID" "$marker")"
log "case A issue_status=${issue_status} marker_found=${marker_found}"
if [[ "$issue_status" != "done" || "$marker_found" != "true" ]]; then
capture_issue_diagnostics "$CASE_A_ISSUE_ID" "case-a"
if [[ -n "$RUN_ID" ]]; then
capture_run_diagnostics "$RUN_ID" "case-a"
fi
capture_openclaw_container_logs
fi
if [[ "$STRICT_CASES" == "1" ]]; then
[[ "$run_status" == "succeeded" ]] || fail "case A run did not succeed"
[[ "$issue_status" == "done" ]] || fail "case A issue did not reach done"
[[ "$marker_found" == "true" ]] || fail "case A marker not found in comments"
fi
}
run_case_b() {
local marker="OPENCLAW_CASE_B_OK_$(date +%s)"
local message_text="${marker}"
local description
description="Case B validation.\n\nUse the message tool to send this exact text to the user's main chat session in webchat:\n${message_text}\n\nAfter sending, post a Paperclip issue comment containing exactly: ${marker}\nThen mark this issue done."
local created
created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case B" "$description")"
CASE_B_ISSUE_ID="${created%%|*}"
local case_identifier="${created##*|}"
log "case B issue ${CASE_B_ISSUE_ID} (${case_identifier})"
trigger_wakeup "openclaw_gateway_smoke_case_b" "$CASE_B_ISSUE_ID"
local run_status issue_status marker_found
if [[ -n "$RUN_ID" ]]; then
run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
log "case B run ${RUN_ID} status=${run_status}"
else
run_status="unknown"
fi
issue_status="$(wait_for_issue_terminal "$CASE_B_ISSUE_ID" "$CASE_TIMEOUT_SEC")"
marker_found="$(issue_comments_contain "$CASE_B_ISSUE_ID" "$marker")"
log "case B issue_status=${issue_status} marker_found=${marker_found}"
if [[ "$issue_status" != "done" || "$marker_found" != "true" ]]; then
capture_issue_diagnostics "$CASE_B_ISSUE_ID" "case-b"
if [[ -n "$RUN_ID" ]]; then
capture_run_diagnostics "$RUN_ID" "case-b"
fi
capture_openclaw_container_logs
fi
warn "case B requires manual UX confirmation in OpenClaw main webchat: message '${message_text}' appears in main chat"
if [[ "$STRICT_CASES" == "1" ]]; then
[[ "$run_status" == "succeeded" ]] || fail "case B run did not succeed"
[[ "$issue_status" == "done" ]] || fail "case B issue did not reach done"
[[ "$marker_found" == "true" ]] || fail "case B marker not found in comments"
fi
}
run_case_c() {
patch_agent_session_strategy_run
local marker="OPENCLAW_CASE_C_CREATED_$(date +%s)"
local ack_marker="OPENCLAW_CASE_C_ACK_$(date +%s)"
local description
description="Case C validation.\n\nTreat this run as a fresh/new session.\nCreate a NEW Paperclip issue in this same company with title exactly:\n${marker}\nUse description: 'created by case C smoke'.\n\nThen post a comment on this issue containing exactly: ${ack_marker}\nThen mark this issue done."
local created
created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case C" "$description")"
CASE_C_ISSUE_ID="${created%%|*}"
local case_identifier="${created##*|}"
log "case C issue ${CASE_C_ISSUE_ID} (${case_identifier})"
trigger_wakeup "openclaw_gateway_smoke_case_c" "$CASE_C_ISSUE_ID"
local run_status issue_status marker_found created_issue
if [[ -n "$RUN_ID" ]]; then
run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
log "case C run ${RUN_ID} status=${run_status}"
else
run_status="unknown"
fi
issue_status="$(wait_for_issue_terminal "$CASE_C_ISSUE_ID" "$CASE_TIMEOUT_SEC")"
marker_found="$(issue_comments_contain "$CASE_C_ISSUE_ID" "$ack_marker")"
created_issue="$(find_issue_by_query "$marker")"
if [[ "$created_issue" == "$CASE_C_ISSUE_ID" ]]; then
created_issue=""
fi
CASE_C_CREATED_ISSUE_ID="$created_issue"
log "case C issue_status=${issue_status} marker_found=${marker_found} created_issue_id=${CASE_C_CREATED_ISSUE_ID:-none}"
if [[ "$issue_status" != "done" || "$marker_found" != "true" || -z "$CASE_C_CREATED_ISSUE_ID" ]]; then
capture_issue_diagnostics "$CASE_C_ISSUE_ID" "case-c"
if [[ -n "$CASE_C_CREATED_ISSUE_ID" ]]; then
capture_issue_diagnostics "$CASE_C_CREATED_ISSUE_ID" "case-c-created"
fi
if [[ -n "$RUN_ID" ]]; then
capture_run_diagnostics "$RUN_ID" "case-c"
fi
capture_openclaw_container_logs
fi
if [[ "$STRICT_CASES" == "1" ]]; then
[[ "$run_status" == "succeeded" ]] || fail "case C run did not succeed"
[[ "$issue_status" == "done" ]] || fail "case C issue did not reach done"
[[ "$marker_found" == "true" ]] || fail "case C ack marker not found in comments"
[[ -n "$CASE_C_CREATED_ISSUE_ID" ]] || fail "case C did not create the expected new issue"
fi
}
main() {
log "starting OpenClaw gateway E2E smoke"
mkdir -p "$OPENCLAW_DIAG_DIR"
log "diagnostics dir: ${OPENCLAW_DIAG_DIR}"
wait_http_ready "${PAPERCLIP_API_URL%/}/api/health" 15 || fail "Paperclip API health endpoint not reachable"
api_request "GET" "/health"
assert_status "200"
log "paperclip health deploymentMode=$(jq -r '.deploymentMode // "unknown"' <<<"$RESPONSE_BODY") exposure=$(jq -r '.deploymentExposure // "unknown"' <<<"$RESPONSE_BODY")"
require_board_auth
resolve_company_id
cleanup_openclaw_agents
cleanup_pending_join_requests
maybe_cleanup_openclaw_docker
start_openclaw_docker
wait_http_ready "http://127.0.0.1:18789/" "$OPENCLAW_WAIT_SECONDS" || fail "OpenClaw HTTP health not reachable"
local gateway_token
gateway_token="$(detect_gateway_token || true)"
[[ -n "$gateway_token" ]] || fail "could not resolve OpenClaw gateway token"
log "resolved gateway token (sha256 prefix $(hash_prefix "$gateway_token"))"
log "probing gateway websocket challenge at ${OPENCLAW_GATEWAY_URL}"
probe_gateway_ws "$OPENCLAW_GATEWAY_URL" "$gateway_token"
create_and_approve_gateway_join "$gateway_token"
log "joined/approved agent ${AGENT_ID} invite=${INVITE_ID} joinRequest=${JOIN_REQUEST_ID}"
trigger_wakeup "openclaw_gateway_smoke_connectivity"
if [[ -n "$RUN_ID" ]]; then
local connect_status
connect_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
[[ "$connect_status" == "succeeded" ]] || fail "connectivity wake run failed: ${connect_status}"
log "connectivity wake run ${RUN_ID} succeeded"
fi
run_case_a
run_case_b
run_case_c
log "success"
log "companyId=${COMPANY_ID}"
log "agentId=${AGENT_ID}"
log "inviteId=${INVITE_ID}"
log "joinRequestId=${JOIN_REQUEST_ID}"
log "caseA_issueId=${CASE_A_ISSUE_ID}"
log "caseB_issueId=${CASE_B_ISSUE_ID}"
log "caseC_issueId=${CASE_C_ISSUE_ID}"
log "caseC_createdIssueId=${CASE_C_CREATED_ISSUE_ID:-none}"
log "agentApiKeyPrefix=${AGENT_API_KEY:0:12}..."
}
main "$@"

View File

@@ -37,6 +37,7 @@
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/db": "workspace:*",
"@paperclipai/shared": "workspace:*",

View File

@@ -0,0 +1,53 @@
import type { NextFunction, Request, Response } from "express";
import { describe, expect, it, vi } from "vitest";
import { HttpError } from "../errors.js";
import { errorHandler } from "../middleware/error-handler.js";
function makeReq(): Request {
return {
method: "GET",
originalUrl: "/api/test",
body: { a: 1 },
params: { id: "123" },
query: { q: "x" },
} as unknown as Request;
}
function makeRes(): Response {
const res = {
status: vi.fn(),
json: vi.fn(),
} as unknown as Response;
(res.status as unknown as ReturnType<typeof vi.fn>).mockReturnValue(res);
return res;
}
describe("errorHandler", () => {
it("attaches the original Error to res.err for 500s", () => {
const req = makeReq();
const res = makeRes() as any;
const next = vi.fn() as unknown as NextFunction;
const err = new Error("boom");
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" });
expect(res.err).toBe(err);
expect(res.__errorContext?.error?.message).toBe("boom");
});
it("attaches HttpError instances for 500 responses", () => {
const req = makeReq();
const res = makeRes() as any;
const next = vi.fn() as unknown as NextFunction;
const err = new HttpError(500, "db exploded");
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: "db exploded" });
expect(res.err).toBe(err);
expect(res.__errorContext?.error?.message).toBe("db exploded");
});
});

View File

@@ -0,0 +1,254 @@
import { afterEach, describe, expect, it } from "vitest";
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server";
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
function buildContext(
config: Record<string, unknown>,
overrides?: Partial<AdapterExecutionContext>,
): AdapterExecutionContext {
return {
runId: "run-123",
agent: {
id: "agent-123",
companyId: "company-123",
name: "OpenClaw Gateway Agent",
adapterType: "openclaw_gateway",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config,
context: {
taskId: "task-123",
issueId: "issue-123",
wakeReason: "issue_assigned",
issueIds: ["issue-123"],
},
onLog: async () => {},
...overrides,
};
}
async function createMockGatewayServer() {
const server = createServer();
const wss = new WebSocketServer({ server });
let agentPayload: Record<string, unknown> | null = null;
wss.on("connection", (socket) => {
socket.send(
JSON.stringify({
type: "event",
event: "connect.challenge",
payload: { nonce: "nonce-123" },
}),
);
socket.on("message", (raw) => {
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
const frame = JSON.parse(text) as {
type: string;
id: string;
method: string;
params?: Record<string, unknown>;
};
if (frame.type !== "req") return;
if (frame.method === "connect") {
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
type: "hello-ok",
protocol: 3,
server: { version: "test", connId: "conn-1" },
features: { methods: ["connect", "agent", "agent.wait"], events: ["agent"] },
snapshot: { version: 1, ts: Date.now() },
policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 },
},
}),
);
return;
}
if (frame.method === "agent") {
agentPayload = frame.params ?? null;
const runId =
typeof frame.params?.idempotencyKey === "string"
? frame.params.idempotencyKey
: "run-123";
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
runId,
status: "accepted",
acceptedAt: Date.now(),
},
}),
);
socket.send(
JSON.stringify({
type: "event",
event: "agent",
payload: {
runId,
seq: 1,
stream: "assistant",
ts: Date.now(),
data: { delta: "cha" },
},
}),
);
socket.send(
JSON.stringify({
type: "event",
event: "agent",
payload: {
runId,
seq: 2,
stream: "assistant",
ts: Date.now(),
data: { delta: "chacha" },
},
}),
);
return;
}
if (frame.method === "agent.wait") {
socket.send(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: {
runId: frame.params?.runId,
status: "ok",
startedAt: 1,
endedAt: 2,
},
}),
);
}
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Failed to resolve test server address");
}
return {
url: `ws://127.0.0.1:${address.port}`,
getAgentPayload: () => agentPayload,
close: async () => {
await new Promise<void>((resolve) => wss.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
afterEach(() => {
// no global mocks
});
describe("openclaw gateway ui stdout parser", () => {
it("parses assistant deltas from gateway event lines", () => {
const ts = "2026-03-06T15:00:00.000Z";
const line =
'[openclaw-gateway:event] run=run-1 stream=assistant data={"delta":"hello"}';
expect(parseOpenClawGatewayStdoutLine(line, ts)).toEqual([
{
kind: "assistant",
ts,
text: "hello",
delta: true,
},
]);
});
});
describe("openclaw gateway adapter execute", () => {
it("runs connect -> agent -> agent.wait and forwards wake payload", async () => {
const gateway = await createMockGatewayServer();
const logs: string[] = [];
try {
const result = await execute(
buildContext(
{
url: gateway.url,
headers: {
"x-openclaw-token": "gateway-token",
},
payloadTemplate: {
message: "wake now",
},
waitTimeoutMs: 2000,
},
{
onLog: async (_stream, chunk) => {
logs.push(chunk);
},
},
),
);
expect(result.exitCode).toBe(0);
expect(result.timedOut).toBe(false);
expect(result.summary).toContain("chachacha");
expect(result.provider).toBe("openclaw");
const payload = gateway.getAgentPayload();
expect(payload).toBeTruthy();
expect(payload?.idempotencyKey).toBe("run-123");
expect(payload?.sessionKey).toBe("paperclip");
expect(String(payload?.message ?? "")).toContain("wake now");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
} finally {
await gateway.close();
}
});
it("fails fast when url is missing", async () => {
const result = await execute(buildContext({}));
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
});
});
describe("openclaw gateway testEnvironment", () => {
it("reports missing url as failure", async () => {
const result = await testEnvironment({
companyId: "company-123",
adapterType: "openclaw_gateway",
config: {},
});
expect(result.status).toBe("fail");
expect(result.checks.some((check) => check.code === "openclaw_gateway_url_missing")).toBe(true);
});
});

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { resolveProjectNameForUniqueShortname } from "../services/projects.ts";
describe("resolveProjectNameForUniqueShortname", () => {
it("keeps name when shortname is not used", () => {
const resolved = resolveProjectNameForUniqueShortname("Platform", [
{ id: "p1", name: "Growth" },
]);
expect(resolved).toBe("Platform");
});
it("appends numeric suffix when shortname collides", () => {
const resolved = resolveProjectNameForUniqueShortname("Growth Team", [
{ id: "p1", name: "growth-team" },
]);
expect(resolved).toBe("Growth Team 2");
});
it("increments suffix until unique", () => {
const resolved = resolveProjectNameForUniqueShortname("Growth Team", [
{ id: "p1", name: "growth-team" },
{ id: "p2", name: "growth-team-2" },
]);
expect(resolved).toBe("Growth Team 3");
});
it("ignores excluded project id", () => {
const resolved = resolveProjectNameForUniqueShortname(
"Growth Team",
[
{ id: "p1", name: "growth-team" },
{ id: "p2", name: "platform" },
],
{ excludeProjectId: "p1" },
);
expect(resolved).toBe("Growth Team");
});
it("keeps non-normalizable names unchanged", () => {
const resolved = resolveProjectNameForUniqueShortname("!!!", [
{ id: "p1", name: "growth" },
]);
expect(resolved).toBe("!!!");
});
});

View File

@@ -35,6 +35,14 @@ import {
agentConfigurationDoc as openclawAgentConfigurationDoc,
models as openclawModels,
} from "@paperclipai/adapter-openclaw";
import {
execute as openclawGatewayExecute,
testEnvironment as openclawGatewayTestEnvironment,
} from "@paperclipai/adapter-openclaw-gateway/server";
import {
agentConfigurationDoc as openclawGatewayAgentConfigurationDoc,
models as openclawGatewayModels,
} from "@paperclipai/adapter-openclaw-gateway";
import { listCodexModels } from "./codex-models.js";
import { listCursorModels } from "./cursor-models.js";
import {
@@ -91,6 +99,15 @@ const openclawAdapter: ServerAdapterModule = {
agentConfigurationDoc: openclawAgentConfigurationDoc,
};
const openclawGatewayAdapter: ServerAdapterModule = {
type: "openclaw_gateway",
execute: openclawGatewayExecute,
testEnvironment: openclawGatewayTestEnvironment,
models: openclawGatewayModels,
supportsLocalAgentJwt: false,
agentConfigurationDoc: openclawGatewayAgentConfigurationDoc,
};
const openCodeLocalAdapter: ServerAdapterModule = {
type: "opencode_local",
execute: openCodeExecute,
@@ -114,7 +131,17 @@ const piLocalAdapter: ServerAdapterModule = {
};
const adaptersByType = new Map<string, ServerAdapterModule>(
[claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, piLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
[
claudeLocalAdapter,
codexLocalAdapter,
openCodeLocalAdapter,
piLocalAdapter,
cursorLocalAdapter,
openclawAdapter,
openclawGatewayAdapter,
processAdapter,
httpAdapter,
].map((a) => [a.type, a]),
);
export function getServerAdapter(type: string): ServerAdapterModule {

View File

@@ -121,6 +121,9 @@ export async function createApp(
}),
);
app.use("/api", api);
app.use("/api", (_req, res) => {
res.status(404).json({ error: "API route not found" });
});
const __dirname = path.dirname(fileURLToPath(import.meta.url));
if (opts.uiMode === "static") {

View File

@@ -11,6 +11,25 @@ export interface ErrorContext {
reqQuery?: unknown;
}
function attachErrorContext(
req: Request,
res: Response,
payload: ErrorContext["error"],
rawError?: Error,
) {
(res as any).__errorContext = {
error: payload,
method: req.method,
url: req.originalUrl,
reqBody: req.body,
reqParams: req.params,
reqQuery: req.query,
} satisfies ErrorContext;
if (rawError) {
(res as any).err = rawError;
}
}
export function errorHandler(
err: unknown,
req: Request,
@@ -19,14 +38,12 @@ export function errorHandler(
) {
if (err instanceof HttpError) {
if (err.status >= 500) {
(res as any).__errorContext = {
error: { message: err.message, stack: err.stack, name: err.name, details: err.details },
method: req.method,
url: req.originalUrl,
reqBody: req.body,
reqParams: req.params,
reqQuery: req.query,
} satisfies ErrorContext;
attachErrorContext(
req,
res,
{ message: err.message, stack: err.stack, name: err.name, details: err.details },
err,
);
}
res.status(err.status).json({
error: err.message,
@@ -40,16 +57,15 @@ export function errorHandler(
return;
}
(res as any).__errorContext = {
error: err instanceof Error
const rootError = err instanceof Error ? err : new Error(String(err));
attachErrorContext(
req,
res,
err instanceof Error
? { message: err.message, stack: err.stack, name: err.name }
: { message: String(err), raw: err },
method: req.method,
url: req.originalUrl,
reqBody: req.body,
reqParams: req.params,
reqQuery: req.query,
} satisfies ErrorContext;
: { message: String(err), raw: err, stack: rootError.stack, name: rootError.name },
rootError,
);
res.status(500).json({ error: "Internal server error" });
}

View File

@@ -62,7 +62,7 @@ export const httpLogger = pinoHttp({
const ctx = (res as any).__errorContext;
if (ctx) {
return {
err: ctx.error,
errorContext: ctx.error,
reqBody: ctx.reqBody,
reqParams: ctx.reqParams,
reqQuery: ctx.reqQuery,

View File

@@ -184,6 +184,13 @@ export function issueRoutes(db: Db, storage: StorageService) {
}
});
// Common malformed path when companyId is empty in "/api/companies/{companyId}/issues".
router.get("/issues", (_req, res) => {
res.status(400).json({
error: "Missing companyId in path. Use /api/companies/{companyId}/issues.",
});
});
router.get("/companies/:companyId/issues", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -522,6 +529,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
}
const actor = getActorInfo(req);
const hasFieldChanges = Object.keys(previous).length > 0;
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
@@ -531,7 +539,12 @@ export function issueRoutes(db: Db, storage: StorageService) {
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
details: { ...updateFields, identifier: issue.identifier, _previous: Object.keys(previous).length > 0 ? previous : undefined },
details: {
...updateFields,
identifier: issue.identifier,
...(commentBody ? { source: "comment" } : {}),
_previous: hasFieldChanges ? previous : undefined,
},
});
let comment = null;
@@ -555,6 +568,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
bodySnippet: comment.body.slice(0, 120),
identifier: issue.identifier,
issueTitle: issue.title,
...(hasFieldChanges ? { updated: true } : {}),
},
});

View File

@@ -87,6 +87,14 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
{ path: ["method"], value: "POST" },
{ path: ["timeoutSec"], value: 30 },
],
openclaw_gateway: [
{ path: ["timeoutSec"], value: 120 },
{ path: ["waitTimeoutMs"], value: 120000 },
{ path: ["sessionKeyStrategy"], value: "fixed" },
{ path: ["sessionKey"], value: "paperclip" },
{ path: ["role"], value: "operator" },
{ path: ["scopes"], value: ["operator.admin"] },
],
};
function isPlainRecord(value: unknown): value is Record<string, unknown> {

View File

@@ -31,6 +31,15 @@ interface ProjectWithGoals extends ProjectRow {
primaryWorkspace: ProjectWorkspace | null;
}
interface ProjectShortnameRow {
id: string;
name: string;
}
interface ResolveProjectNameOptions {
excludeProjectId?: string | null;
}
/** Batch-load goal refs for a set of projects. */
async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals[]> {
if (rows.length === 0) return [];
@@ -192,6 +201,34 @@ function deriveWorkspaceName(input: {
return "Workspace";
}
export function resolveProjectNameForUniqueShortname(
requestedName: string,
existingProjects: ProjectShortnameRow[],
options?: ResolveProjectNameOptions,
): string {
const requestedShortname = normalizeProjectUrlKey(requestedName);
if (!requestedShortname) return requestedName;
const usedShortnames = new Set(
existingProjects
.filter((project) => !(options?.excludeProjectId && project.id === options.excludeProjectId))
.map((project) => normalizeProjectUrlKey(project.name))
.filter((value): value is string => value !== null),
);
if (!usedShortnames.has(requestedShortname)) return requestedName;
for (let suffix = 2; suffix < 10_000; suffix += 1) {
const candidateName = `${requestedName} ${suffix}`;
const candidateShortname = normalizeProjectUrlKey(candidateName);
if (candidateShortname && !usedShortnames.has(candidateShortname)) {
return candidateName;
}
}
// Fallback guard for pathological naming collisions.
return `${requestedName} ${Date.now()}`;
}
async function ensureSinglePrimaryWorkspace(
dbOrTx: any,
input: {
@@ -271,6 +308,12 @@ export function projectService(db: Db) {
projectData.color = nextColor;
}
const existingProjects = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(eq(projects.companyId, companyId));
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects);
// Also write goalId to the legacy column (first goal or null)
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
@@ -295,6 +338,26 @@ export function projectService(db: Db) {
): Promise<ProjectWithGoals | null> => {
const { goalIds: inputGoalIds, ...projectData } = data;
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
const existingProject = await db
.select({ id: projects.id, companyId: projects.companyId, name: projects.name })
.from(projects)
.where(eq(projects.id, id))
.then((rows) => rows[0] ?? null);
if (!existingProject) return null;
if (projectData.name !== undefined) {
const existingShortname = normalizeProjectUrlKey(existingProject.name);
const nextShortname = normalizeProjectUrlKey(projectData.name);
if (existingShortname !== nextShortname) {
const existingProjects = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(eq(projects.companyId, existingProject.companyId));
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects, {
excludeProjectId: id,
});
}
}
// Keep legacy goalId column in sync
const updates: Partial<typeof projects.$inferInsert> = {

View File

@@ -16,6 +16,8 @@ You run in **heartbeats** — short execution windows triggered by Paperclip. Ea
Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL.
Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id>` to install Paperclip skills for Claude/Codex and print/export the required `PAPERCLIP_*` environment variables for that agent identity.
**Run audit trail:** You MUST include `-H 'X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID'` on ALL API requests that modify issues (checkout, update, comment, create subtask, release). This links your actions to the current heartbeat run for traceability.
## The Heartbeat Procedure
@@ -222,6 +224,43 @@ GET /api/companies/{companyId}/issues?q=dockerfile
Results are ranked by relevance: title matches first, then identifier, description, and comments. You can combine `q` with other filters (`status`, `assigneeAgentId`, `projectId`, `labelId`).
## Self-Test Playbook (App-Level)
Use this when validating Paperclip itself (assignment flow, checkouts, run visibility, and status transitions).
1. Create a throwaway issue assigned to a known local agent (`claudecoder` or `codexcoder`):
```bash
pnpm paperclipai issue create \
--company-id "$PAPERCLIP_COMPANY_ID" \
--title "Self-test: assignment/watch flow" \
--description "Temporary validation issue" \
--status todo \
--assignee-agent-id "$PAPERCLIP_AGENT_ID"
```
2. Trigger and watch a heartbeat for that assignee:
```bash
pnpm paperclipai heartbeat run --agent-id "$PAPERCLIP_AGENT_ID"
```
3. Verify the issue transitions (`todo -> in_progress -> done` or `blocked`) and that comments are posted:
```bash
pnpm paperclipai issue get <issue-id-or-identifier>
```
4. Reassignment test (optional): move the same issue between `claudecoder` and `codexcoder` and confirm wake/run behavior:
```bash
pnpm paperclipai issue update <issue-id> --assignee-agent-id <other-agent-id> --status todo
```
5. Cleanup: mark temporary issues done/cancelled with a clear note.
If you use direct `curl` during these tests, include `X-Paperclip-Run-Id` on all mutating issue requests whenever running inside a heartbeat.
## Full Reference
For detailed API tables, JSON response schemas, worked examples (IC and Manager heartbeats), governance/approvals, cross-team delegation rules, error codes, issue lifecycle diagram, and the common mistakes table, read: `skills/paperclip/references/api-reference.md`

View File

@@ -20,6 +20,7 @@
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/shared": "workspace:*",
"@radix-ui/react-slot": "^1.2.4",
@@ -29,6 +30,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.574.0",
"mermaid": "^11.12.0",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -24,6 +24,7 @@ import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { DesignGuide } from "./pages/DesignGuide";
import { OrgChart } from "./pages/OrgChart";
import { NewAgent } from "./pages/NewAgent";
import { AuthPage } from "./pages/Auth";
import { BoardClaimPage } from "./pages/BoardClaim";
import { InviteLandingPage } from "./pages/InviteLanding";
@@ -101,6 +102,7 @@ function boardRoutes() {
<Route path="agents/active" element={<Agents />} />
<Route path="agents/paused" element={<Agents />} />
<Route path="agents/error" element={<Agents />} />
<Route path="agents/new" element={<NewAgent />} />
<Route path="agents/:agentId" element={<AgentDetail />} />
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
<Route path="agents/:agentId/runs/:runId" element={<AgentDetail />} />
@@ -214,6 +216,7 @@ export function App() {
<Route path="issues" element={<UnprefixedBoardRedirect />} />
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
<Route path="agents" element={<UnprefixedBoardRedirect />} />
<Route path="agents/new" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId/:tab" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId/runs/:runId" element={<UnprefixedBoardRedirect />} />

View File

@@ -0,0 +1,221 @@
import { useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
DraftInput,
help,
} from "../../components/agent-config-primitives";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
function SecretField({
label,
value,
onCommit,
placeholder,
}: {
label: string;
value: string;
onCommit: (v: string) => void;
placeholder?: string;
}) {
const [visible, setVisible] = useState(false);
return (
<Field label={label}>
<div className="relative">
<button
type="button"
onClick={() => setVisible((v) => !v)}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors"
>
{visible ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
</button>
<DraftInput
value={value}
onCommit={onCommit}
immediate
type={visible ? "text" : "password"}
className={inputClass + " pl-8"}
placeholder={placeholder}
/>
</div>
</Field>
);
}
function parseScopes(value: unknown): string {
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === "string").join(", ");
}
return typeof value === "string" ? value : "";
}
export function OpenClawGatewayConfigFields({
isCreate,
values,
set,
config,
eff,
mark,
}: AdapterConfigFieldsProps) {
const configuredHeaders =
config.headers && typeof config.headers === "object" && !Array.isArray(config.headers)
? (config.headers as Record<string, unknown>)
: {};
const effectiveHeaders =
(eff("adapterConfig", "headers", configuredHeaders) as Record<string, unknown>) ?? {};
const effectiveGatewayToken = typeof effectiveHeaders["x-openclaw-token"] === "string"
? String(effectiveHeaders["x-openclaw-token"])
: typeof effectiveHeaders["x-openclaw-auth"] === "string"
? String(effectiveHeaders["x-openclaw-auth"])
: "";
const commitGatewayToken = (rawValue: string) => {
const nextValue = rawValue.trim();
const nextHeaders: Record<string, unknown> = { ...effectiveHeaders };
if (nextValue) {
nextHeaders["x-openclaw-token"] = nextValue;
delete nextHeaders["x-openclaw-auth"];
} else {
delete nextHeaders["x-openclaw-token"];
delete nextHeaders["x-openclaw-auth"];
}
mark("adapterConfig", "headers", Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined);
};
const sessionStrategy = eff(
"adapterConfig",
"sessionKeyStrategy",
String(config.sessionKeyStrategy ?? "fixed"),
);
return (
<>
<Field label="Gateway URL" hint={help.webhookUrl}>
<DraftInput
value={
isCreate
? values!.url
: eff("adapterConfig", "url", String(config.url ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ url: v })
: mark("adapterConfig", "url", v || undefined)
}
immediate
className={inputClass}
placeholder="ws://127.0.0.1:18789"
/>
</Field>
{!isCreate && (
<>
<Field label="Paperclip API URL override">
<DraftInput
value={
eff(
"adapterConfig",
"paperclipApiUrl",
String(config.paperclipApiUrl ?? ""),
)
}
onCommit={(v) => mark("adapterConfig", "paperclipApiUrl", v || undefined)}
immediate
className={inputClass}
placeholder="https://paperclip.example"
/>
</Field>
<Field label="Session strategy">
<select
value={sessionStrategy}
onChange={(e) => mark("adapterConfig", "sessionKeyStrategy", e.target.value)}
className={inputClass}
>
<option value="fixed">Fixed</option>
<option value="issue">Per issue</option>
<option value="run">Per run</option>
</select>
</Field>
{sessionStrategy === "fixed" && (
<Field label="Session key">
<DraftInput
value={eff("adapterConfig", "sessionKey", String(config.sessionKey ?? "paperclip"))}
onCommit={(v) => mark("adapterConfig", "sessionKey", v || undefined)}
immediate
className={inputClass}
placeholder="paperclip"
/>
</Field>
)}
<SecretField
label="Gateway auth token (x-openclaw-token)"
value={effectiveGatewayToken}
onCommit={commitGatewayToken}
placeholder="OpenClaw gateway token"
/>
<Field label="Role">
<DraftInput
value={eff("adapterConfig", "role", String(config.role ?? "operator"))}
onCommit={(v) => mark("adapterConfig", "role", v || undefined)}
immediate
className={inputClass}
placeholder="operator"
/>
</Field>
<Field label="Scopes (comma-separated)">
<DraftInput
value={eff("adapterConfig", "scopes", parseScopes(config.scopes ?? ["operator.admin"]))}
onCommit={(v) => {
const parsed = v
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
mark("adapterConfig", "scopes", parsed.length > 0 ? parsed : undefined);
}}
immediate
className={inputClass}
placeholder="operator.admin"
/>
</Field>
<Field label="Wait timeout (ms)">
<DraftInput
value={eff("adapterConfig", "waitTimeoutMs", String(config.waitTimeoutMs ?? "120000"))}
onCommit={(v) => {
const parsed = Number.parseInt(v.trim(), 10);
mark(
"adapterConfig",
"waitTimeoutMs",
Number.isFinite(parsed) && parsed > 0 ? parsed : undefined,
);
}}
immediate
className={inputClass}
placeholder="120000"
/>
</Field>
<Field label="Disable device auth">
<select
value={String(eff("adapterConfig", "disableDeviceAuth", Boolean(config.disableDeviceAuth ?? false)))}
onChange={(e) => mark("adapterConfig", "disableDeviceAuth", e.target.value === "true")}
className={inputClass}
>
<option value="false">No (recommended)</option>
<option value="true">Yes</option>
</select>
</Field>
</>
)}
</>
);
}

View File

@@ -0,0 +1,12 @@
import type { UIAdapterModule } from "../types";
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
import { buildOpenClawGatewayConfig } from "@paperclipai/adapter-openclaw-gateway/ui";
import { OpenClawGatewayConfigFields } from "./config-fields";
export const openClawGatewayUIAdapter: UIAdapterModule = {
type: "openclaw_gateway",
label: "OpenClaw Gateway",
parseStdoutLine: parseOpenClawGatewayStdoutLine,
ConfigFields: OpenClawGatewayConfigFields,
buildAdapterConfig: buildOpenClawGatewayConfig,
};

View File

@@ -5,11 +5,22 @@ import { cursorLocalUIAdapter } from "./cursor";
import { openCodeLocalUIAdapter } from "./opencode-local";
import { piLocalUIAdapter } from "./pi-local";
import { openClawUIAdapter } from "./openclaw";
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
import { processUIAdapter } from "./process";
import { httpUIAdapter } from "./http";
const adaptersByType = new Map<string, UIAdapterModule>(
[claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, piLocalUIAdapter, cursorLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]),
[
claudeLocalUIAdapter,
codexLocalUIAdapter,
openCodeLocalUIAdapter,
piLocalUIAdapter,
cursorLocalUIAdapter,
openClawUIAdapter,
openClawGatewayUIAdapter,
processUIAdapter,
httpUIAdapter,
].map((a) => [a.type, a]),
);
export function getUIAdapter(type: string): UIAdapterModule {

View File

@@ -461,7 +461,7 @@ function AgentRunCard({
<Link
to={`/issues/${issue?.identifier ?? run.issueId}`}
className={cn(
"hover:underline min-w-0 truncate",
"hover:underline min-w-0 line-clamp-2 min-h-[2rem]",
isActive ? "text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300" : "text-muted-foreground hover:text-foreground",
)}
title={issue?.title ? `${issue?.identifier ?? run.issueId.slice(0, 8)} - ${issue.title}` : issue?.identifier ?? run.issueId.slice(0, 8)}

View File

@@ -19,6 +19,7 @@ const adapterLabels: Record<string, string> = {
codex_local: "Codex (local)",
opencode_local: "OpenCode (local)",
openclaw: "OpenClaw",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
process: "Process",
http: "HTTP",

View File

@@ -2,7 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "re
import { Link, useLocation } from "react-router-dom";
import type { IssueComment, Agent } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Paperclip } from "lucide-react";
import { Check, Copy, Paperclip } from "lucide-react";
import { Identity } from "./Identity";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { MarkdownBody } from "./MarkdownBody";
@@ -92,6 +92,25 @@ function parseReassignment(target: string): CommentReassignment | null {
return null;
}
function CopyMarkdownButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
className="text-muted-foreground hover:text-foreground transition-colors"
title="Copy as markdown"
onClick={() => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</button>
);
}
type TimelineItem =
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
@@ -160,12 +179,15 @@ const TimelineList = memo(function TimelineList({
) : (
<Identity name="You" size="sm" />
)}
<a
href={`#comment-${comment.id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
>
{formatDateTime(comment.createdAt)}
</a>
<span className="flex items-center gap-1.5">
<a
href={`#comment-${comment.id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
>
{formatDateTime(comment.createdAt)}
</a>
<CopyMarkdownButton text={comment.body} />
</span>
</div>
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
{comment.runId && (

View File

@@ -157,7 +157,7 @@ function parseStdoutChunk(
if (!trimmed) continue;
const parsed = adapter.parseStdoutLine(trimmed, ts);
if (parsed.length === 0) {
if (run.adapterType === "openclaw") {
if (run.adapterType === "openclaw" || run.adapterType === "openclaw_gateway") {
continue;
}
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);

View File

@@ -1,4 +1,4 @@
import type { CSSProperties } from "react";
import { isValidElement, useEffect, useId, useState, type CSSProperties, type ReactNode } from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { parseProjectMentionHref } from "@paperclipai/shared";
@@ -10,6 +10,30 @@ interface MarkdownBodyProps {
className?: string;
}
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
function loadMermaid() {
if (!mermaidLoaderPromise) {
mermaidLoaderPromise = import("mermaid").then((module) => module.default);
}
return mermaidLoaderPromise;
}
function flattenText(value: ReactNode): string {
if (value == null) return "";
if (typeof value === "string" || typeof value === "number") return String(value);
if (Array.isArray(value)) return value.map((item) => flattenText(item)).join("");
return "";
}
function extractMermaidSource(children: ReactNode): string | null {
if (!isValidElement(children)) return null;
const childProps = children.props as { className?: unknown; children?: ReactNode };
if (typeof childProps.className !== "string") return null;
if (!/\blanguage-mermaid\b/i.test(childProps.className)) return null;
return flattenText(childProps.children).replace(/\n$/, "");
}
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
if (!match) return null;
@@ -33,6 +57,61 @@ function mentionChipStyle(color: string | null): CSSProperties | undefined {
};
}
function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) {
const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
setSvg(null);
setError(null);
loadMermaid()
.then(async (mermaid) => {
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: darkMode ? "dark" : "default",
fontFamily: "inherit",
suppressErrorRendering: true,
});
const rendered = await mermaid.render(`paperclip-mermaid-${renderId}`, source);
if (!active) return;
setSvg(rendered.svg);
})
.catch((err) => {
if (!active) return;
const message =
err instanceof Error && err.message
? err.message
: "Failed to render Mermaid diagram.";
setError(message);
});
return () => {
active = false;
};
}, [darkMode, renderId, source]);
return (
<div className="paperclip-mermaid">
{svg ? (
<div dangerouslySetInnerHTML={{ __html: svg }} />
) : (
<>
<p className={cn("paperclip-mermaid-status", error && "paperclip-mermaid-status-error")}>
{error ? `Unable to render Mermaid diagram: ${error}` : "Rendering Mermaid diagram..."}
</p>
<pre className="paperclip-mermaid-source">
<code className="language-mermaid">{source}</code>
</pre>
</>
)}
</div>
);
}
export function MarkdownBody({ children, className }: MarkdownBodyProps) {
const { theme } = useTheme();
return (
@@ -46,6 +125,13 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) {
<Markdown
remarkPlugins={[remarkGfm]}
components={{
pre: ({ node: _node, children: preChildren, ...preProps }) => {
const mermaidSource = extractMermaidSource(preChildren);
if (mermaidSource) {
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
}
return <pre {...preProps}>{preChildren}</pre>;
},
a: ({ href, children: linkChildren }) => {
const parsed = href ? parseProjectMentionHref(href) : null;
if (parsed) {

View File

@@ -1,53 +1,20 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@/lib/router";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { agentsApi } from "../api/agents";
import { queryKeys } from "../lib/queryKeys";
import { AGENT_ROLES } from "@paperclipai/shared";
import {
Dialog,
DialogContent,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Minimize2,
Maximize2,
Shield,
User,
} from "lucide-react";
import { cn, agentUrl } from "../lib/utils";
import { roleLabels } from "./agent-config-primitives";
import { AgentConfigForm, type CreateConfigValues } from "./AgentConfigForm";
import { defaultCreateValues } from "./agent-config-defaults";
import { getUIAdapter } from "../adapters";
import { AgentIcon } from "./AgentIconPicker";
import { Bot, Sparkles } from "lucide-react";
export function NewAgentDialog() {
const { newAgentOpen, closeNewAgent } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany();
const queryClient = useQueryClient();
const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog();
const { selectedCompanyId } = useCompany();
const navigate = useNavigate();
const [expanded, setExpanded] = useState(true);
// Identity
const [name, setName] = useState("");
const [title, setTitle] = useState("");
const [role, setRole] = useState("general");
const [reportsTo, setReportsTo] = useState("");
// Config values (managed by AgentConfigForm)
const [configValues, setConfigValues] = useState<CreateConfigValues>(defaultCreateValues);
// Popover states
const [roleOpen, setRoleOpen] = useState(false);
const [reportsToOpen, setReportsToOpen] = useState(false);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
@@ -55,287 +22,74 @@ export function NewAgentDialog() {
enabled: !!selectedCompanyId && newAgentOpen,
});
const {
data: adapterModels,
error: adapterModelsError,
isLoading: adapterModelsLoading,
isFetching: adapterModelsFetching,
} = useQuery({
queryKey:
selectedCompanyId
? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType)
: ["agents", "none", "adapter-models", configValues.adapterType],
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType),
enabled: Boolean(selectedCompanyId) && newAgentOpen,
});
const ceoAgent = (agents ?? []).find((a) => a.role === "ceo");
const isFirstAgent = !agents || agents.length === 0;
const effectiveRole = isFirstAgent ? "ceo" : role;
const [formError, setFormError] = useState<string | null>(null);
// Auto-fill for CEO
useEffect(() => {
if (newAgentOpen && isFirstAgent) {
if (!name) setName("CEO");
if (!title) setTitle("CEO");
}
}, [newAgentOpen, isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps
const createAgent = useMutation({
mutationFn: (data: Record<string, unknown>) =>
agentsApi.hire(selectedCompanyId!, data),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
reset();
closeNewAgent();
navigate(agentUrl(result.agent));
},
onError: (error) => {
setFormError(error instanceof Error ? error.message : "Failed to create agent");
},
});
function reset() {
setName("");
setTitle("");
setRole("general");
setReportsTo("");
setConfigValues(defaultCreateValues);
setExpanded(true);
setFormError(null);
}
function buildAdapterConfig() {
const adapter = getUIAdapter(configValues.adapterType);
return adapter.buildAdapterConfig(configValues);
}
function handleSubmit() {
if (!selectedCompanyId || !name.trim()) return;
setFormError(null);
if (configValues.adapterType === "opencode_local") {
const selectedModel = configValues.model.trim();
if (!selectedModel) {
setFormError("OpenCode requires an explicit model in provider/model format.");
return;
}
if (adapterModelsError) {
setFormError(
adapterModelsError instanceof Error
? adapterModelsError.message
: "Failed to load OpenCode models.",
);
return;
}
if (adapterModelsLoading || adapterModelsFetching) {
setFormError("OpenCode models are still loading. Please wait and try again.");
return;
}
const discovered = adapterModels ?? [];
if (!discovered.some((entry) => entry.id === selectedModel)) {
setFormError(
discovered.length === 0
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
: `Configured OpenCode model is unavailable: ${selectedModel}`,
);
return;
}
}
createAgent.mutate({
name: name.trim(),
role: effectiveRole,
...(title.trim() ? { title: title.trim() } : {}),
...(reportsTo ? { reportsTo } : {}),
adapterType: configValues.adapterType,
adapterConfig: buildAdapterConfig(),
runtimeConfig: {
heartbeat: {
enabled: configValues.heartbeatEnabled,
intervalSec: configValues.intervalSec,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1,
},
},
budgetMonthlyCents: 0,
function handleAskCeo() {
closeNewAgent();
openNewIssue({
assigneeAgentId: ceoAgent?.id,
title: "Create a new agent",
description: "(type in what kind of agent you want here)",
});
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
function handleAdvancedConfig() {
closeNewAgent();
navigate("/agents/new");
}
const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo);
return (
<Dialog
open={newAgentOpen}
onOpenChange={(open) => {
if (!open) { reset(); closeNewAgent(); }
if (!open) closeNewAgent();
}}
>
<DialogContent
showCloseButton={false}
className={cn("p-0 gap-0 overflow-hidden", expanded ? "sm:max-w-2xl" : "sm:max-w-lg")}
onKeyDown={handleKeyDown}
className="sm:max-w-md p-0 gap-0 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{selectedCompany && (
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
{selectedCompany.name.slice(0, 3).toUpperCase()}
</span>
)}
<span className="text-muted-foreground/60">&rsaquo;</span>
<span>New agent</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon-xs" className="text-muted-foreground" onClick={() => setExpanded(!expanded)}>
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</Button>
<Button variant="ghost" size="icon-xs" className="text-muted-foreground" onClick={() => { reset(); closeNewAgent(); }}>
<span className="text-lg leading-none">&times;</span>
</Button>
</div>
</div>
<div className="overflow-y-auto max-h-[70vh]">
{/* Name */}
<div className="px-4 pt-4 pb-2 shrink-0">
<input
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder="Agent name"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</div>
{/* Title */}
<div className="px-4 pb-2">
<input
className="w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40"
placeholder="Title (e.g. VP of Engineering)"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
{/* Property chips: Role + Reports To */}
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
{/* Role */}
<Popover open={roleOpen} onOpenChange={setRoleOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
isFirstAgent && "opacity-60 cursor-not-allowed"
)}
disabled={isFirstAgent}
>
<Shield className="h-3 w-3 text-muted-foreground" />
{roleLabels[effectiveRole] ?? effectiveRole}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start">
{AGENT_ROLES.map((r) => (
<button
key={r}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
r === role && "bg-accent"
)}
onClick={() => { setRole(r); setRoleOpen(false); }}
>
{roleLabels[r] ?? r}
</button>
))}
</PopoverContent>
</Popover>
{/* Reports To */}
<Popover open={reportsToOpen} onOpenChange={setReportsToOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
isFirstAgent && "opacity-60 cursor-not-allowed"
)}
disabled={isFirstAgent}
>
{currentReportsTo ? (
<>
<AgentIcon icon={currentReportsTo.icon} className="h-3 w-3 text-muted-foreground" />
{`Reports to ${currentReportsTo.name}`}
</>
) : (
<>
<User className="h-3 w-3 text-muted-foreground" />
{isFirstAgent ? "Reports to: N/A (CEO)" : "Reports to..."}
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="start">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!reportsTo && "bg-accent"
)}
onClick={() => { setReportsTo(""); setReportsToOpen(false); }}
>
No manager
</button>
{(agents ?? []).map((a) => (
<button
key={a.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate",
a.id === reportsTo && "bg-accent"
)}
onClick={() => { setReportsTo(a.id); setReportsToOpen(false); }}
>
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
{a.name}
<span className="text-muted-foreground ml-auto">{roleLabels[a.role] ?? a.role}</span>
</button>
))}
</PopoverContent>
</Popover>
</div>
{/* Shared config form (adapter + heartbeat) */}
<AgentConfigForm
mode="create"
values={configValues}
onChange={(patch) => setConfigValues((prev) => ({ ...prev, ...patch }))}
adapterModels={adapterModels}
/>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border">
<span className="text-xs text-muted-foreground">
{isFirstAgent ? "This will be the CEO" : ""}
</span>
</div>
{formError && (
<div className="px-4 pb-2 text-xs text-destructive">{formError}</div>
)}
<div className="flex items-center justify-end px-4 pb-3">
<span className="text-sm text-muted-foreground">Add a new agent</span>
<Button
size="sm"
disabled={!name.trim() || createAgent.isPending}
onClick={handleSubmit}
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={closeNewAgent}
>
{createAgent.isPending ? "Creating…" : "Create agent"}
<span className="text-lg leading-none">&times;</span>
</Button>
</div>
<div className="p-6 space-y-6">
{/* Recommendation */}
<div className="text-center space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
<Sparkles 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.
</p>
</div>
<Button className="w-full" size="lg" onClick={handleAskCeo}>
<Bot className="h-4 w-4 mr-2" />
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>
</div>
</DialogContent>
</Dialog>
);

View File

@@ -2,7 +2,6 @@ import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent } f
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { useToast } from "../context/ToastContext";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { agentsApi } from "../api/agents";
@@ -170,7 +169,6 @@ const priorities = [
export function NewIssueDialog() {
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
const { companies, selectedCompanyId, selectedCompany } = useCompany();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
@@ -262,19 +260,12 @@ export function NewIssueDialog() {
const createIssue = useMutation({
mutationFn: ({ companyId, ...data }: { companyId: string } & Record<string, unknown>) =>
issuesApi.create(companyId, data),
onSuccess: (issue) => {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) });
if (draftTimer.current) clearTimeout(draftTimer.current);
clearDraft();
reset();
closeNewIssue();
pushToast({
dedupeKey: `activity:issue.created:${issue.id}`,
title: `${issue.identifier ?? "Issue"} created`,
body: issue.title,
tone: "success",
action: { label: `View ${issue.identifier ?? "issue"}`, href: `/issues/${issue.identifier ?? issue.id}` },
});
},
});
@@ -332,7 +323,18 @@ export function NewIssueDialog() {
setDialogCompanyId(selectedCompanyId);
const draft = loadDraft();
if (draft && draft.title.trim()) {
if (newIssueDefaults.title) {
setTitle(newIssueDefaults.title);
setDescription(newIssueDefaults.description ?? "");
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
setProjectId(newIssueDefaults.projectId ?? "");
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setAssigneeUseProjectWorkspace(true);
} else if (draft && draft.title.trim()) {
setTitle(draft.title);
setDescription(draft.description);
setStatus(draft.status || "todo");

View File

@@ -57,7 +57,8 @@ type AdapterType =
| "cursor"
| "process"
| "http"
| "openclaw";
| "openclaw"
| "openclaw_gateway";
const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: [https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md](https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md)
@@ -679,6 +680,12 @@ export function OnboardingWizard() {
desc: "Notify OpenClaw webhook",
comingSoon: true
},
{
value: "openclaw_gateway" as const,
label: "OpenClaw Gateway",
icon: Bot,
desc: "Invoke OpenClaw via gateway protocol"
},
{
value: "cursor" as const,
label: "Cursor",
@@ -981,14 +988,14 @@ export function OnboardingWizard() {
</div>
)}
{(adapterType === "http" || adapterType === "openclaw") && (
{(adapterType === "http" || adapterType === "openclaw" || adapterType === "openclaw_gateway") && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Webhook URL
{adapterType === "openclaw_gateway" ? "Gateway URL" : "Webhook URL"}
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="https://..."
placeholder={adapterType === "openclaw_gateway" ? "ws://127.0.0.1:18789" : "https://..."}
value={url}
onChange={(e) => setUrl(e.target.value)}
/>

View File

@@ -1,8 +1,9 @@
import { useMemo, useState } from "react";
import { NavLink, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight } from "lucide-react";
import { ChevronRight, Plus } from "lucide-react";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useSidebar } from "../context/SidebarContext";
import { agentsApi } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
@@ -40,6 +41,7 @@ function sortByHierarchy(agents: Agent[]): Agent[] {
export function SidebarAgents() {
const [open, setOpen] = useState(true);
const { selectedCompanyId } = useCompany();
const { openNewAgent } = useDialog();
const { isMobile, setSidebarOpen } = useSidebar();
const location = useLocation();
@@ -89,6 +91,16 @@ export function SidebarAgents() {
Agents
</span>
</CollapsibleTrigger>
<button
onClick={(e) => {
e.stopPropagation();
openNewAgent();
}}
className="flex items-center justify-center h-4 w-4 rounded text-muted-foreground/60 hover:text-foreground hover:bg-accent/50 transition-colors"
aria-label="New agent"
>
<Plus className="h-3 w-3" />
</button>
</div>
</div>

View File

@@ -23,7 +23,7 @@ export const help: Record<string, string> = {
role: "Organizational role. Determines position and capabilities.",
reportsTo: "The agent this one reports to in the org hierarchy.",
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw webhook, spawned process, or generic HTTP webhook.",
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw (HTTP hooks or Gateway protocol), spawned process, or generic HTTP webhook.",
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
model: "Override the default model used by the adapter.",
@@ -54,6 +54,7 @@ export const adapterLabels: Record<string, string> = {
codex_local: "Codex (local)",
opencode_local: "OpenCode (local)",
openclaw: "OpenClaw",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
process: "Process",
http: "HTTP",

View File

@@ -5,6 +5,8 @@ interface NewIssueDefaults {
priority?: string;
projectId?: string;
assigneeAgentId?: string;
title?: string;
description?: string;
}
interface NewGoalDefaults {

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, type ReactNode } from "react";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import type { Agent, Issue, LiveEvent } from "@paperclipai/shared";
import { authApi } from "../api/auth";
import { useCompany } from "./CompanyContext";
import type { ToastInput } from "./ToastContext";
import { useToast } from "./ToastContext";
@@ -152,6 +153,7 @@ function buildActivityToast(
queryClient: QueryClient,
companyId: string,
payload: Record<string, unknown>,
currentActor: { userId: string | null; agentId: string | null },
): ToastInput | null {
const entityType = readString(payload.entityType);
const entityId = readString(payload.entityId);
@@ -166,6 +168,10 @@ function buildActivityToast(
const issue = resolveIssueToastContext(queryClient, companyId, entityId, details);
const actor = resolveActorLabel(queryClient, companyId, actorType, actorId);
const isSelfActivity =
(actorType === "user" && !!currentActor.userId && actorId === currentActor.userId) ||
(actorType === "agent" && !!currentActor.agentId && actorId === currentActor.agentId);
if (isSelfActivity) return null;
if (action === "issue.created") {
return {
@@ -178,8 +184,8 @@ function buildActivityToast(
}
if (action === "issue.updated") {
if (details?.reopened === true && readString(details.source) === "comment") {
// Reopen-via-comment emits a paired comment event; show one combined toast on the comment event.
if (readString(details?.source) === "comment") {
// Comment-driven updates emit a paired comment event; show one combined toast on the comment event.
return null;
}
const changeDesc = describeIssueUpdate(details);
@@ -202,13 +208,18 @@ function buildActivityToast(
const commentId = readString(details?.commentId);
const bodySnippet = readString(details?.bodySnippet);
const reopened = details?.reopened === true;
const updated = details?.updated === true;
const reopenedFrom = readString(details?.reopenedFrom);
const reopenedLabel = reopened
? reopenedFrom
? `reopened from ${reopenedFrom.replace(/_/g, " ")}`
: "reopened"
: null;
const title = reopened ? `${actor} reopened and commented on ${issue.ref}` : `${actor} commented on ${issue.ref}`;
const title = reopened
? `${actor} reopened and commented on ${issue.ref}`
: updated
? `${actor} commented and updated ${issue.ref}`
: `${actor} commented on ${issue.ref}`;
const body = bodySnippet
? reopenedLabel
? `${reopenedLabel} - ${bodySnippet.replace(/^#+\s*/m, "").replace(/\n/g, " ")}`
@@ -448,6 +459,7 @@ function handleLiveEvent(
event: LiveEvent,
pushToast: (toast: ToastInput) => string | null,
gate: ToastGate,
currentActor: { userId: string | null; agentId: string | null },
) {
if (event.companyId !== expectedCompanyId) return;
@@ -485,7 +497,7 @@ function handleLiveEvent(
invalidateActivityQueries(queryClient, expectedCompanyId, payload);
const action = readString(payload.action);
const toast =
buildActivityToast(queryClient, expectedCompanyId, payload) ??
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
buildJoinRequestToast(payload);
if (toast) gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
}
@@ -496,6 +508,12 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
retry: false,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
useEffect(() => {
if (!selectedCompanyId) return;
@@ -541,7 +559,10 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
try {
const parsed = JSON.parse(raw) as LiveEvent;
handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current);
handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current, {
userId: currentUserId,
agentId: null,
});
} catch {
// Ignore non-JSON payloads.
}
@@ -570,7 +591,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
socket.close(1000, "provider_unmount");
}
};
}, [queryClient, selectedCompanyId, pushToast]);
}, [queryClient, selectedCompanyId, pushToast, currentUserId]);
return <>{children}</>;
}

View File

@@ -426,6 +426,40 @@
font-weight: 500;
}
.paperclip-mermaid {
margin: 0.5rem 0;
padding: 0.45rem 0.55rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 3px);
background-color: color-mix(in oklab, var(--accent) 35%, transparent);
overflow-x: auto;
}
.paperclip-mermaid svg {
display: block;
width: max-content;
max-width: none;
min-width: 100%;
height: auto;
}
.paperclip-mermaid-status {
margin: 0 0 0.45rem;
font-size: 0.75rem;
color: var(--muted-foreground);
}
.paperclip-mermaid-status-error {
color: var(--destructive);
}
.paperclip-mermaid-source {
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
/* Project mention chips rendered inside MarkdownBody */
a.paperclip-project-mention-chip {
display: inline-flex;

View File

@@ -26,6 +26,7 @@ const adapterLabels: Record<string, string> = {
opencode_local: "OpenCode",
cursor: "Cursor",
openclaw: "OpenClaw",
openclaw_gateway: "OpenClaw Gateway",
process: "Process",
http: "HTTP",
};

View File

@@ -20,6 +20,7 @@ const adapterLabels: Record<string, string> = {
codex_local: "Codex (local)",
opencode_local: "OpenCode (local)",
openclaw: "OpenClaw",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
process: "Process",
http: "HTTP",

View File

@@ -8,7 +8,6 @@ import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useToast } from "../context/ToastContext";
import { usePanel } from "../context/PanelContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
@@ -146,7 +145,6 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<st
export function IssueDetail() {
const { issueId } = useParams<{ issueId: string }>();
const { selectedCompanyId } = useCompany();
const { pushToast } = useToast();
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
@@ -403,33 +401,17 @@ export function IssueDetail() {
const updateIssue = useMutation({
mutationFn: (data: Record<string, unknown>) => issuesApi.update(issueId!, data),
onSuccess: (updated) => {
onSuccess: () => {
invalidateIssue();
const issueRef = updated.identifier ?? `Issue ${updated.id.slice(0, 8)}`;
pushToast({
dedupeKey: `activity:issue.updated:${updated.id}`,
title: `${issueRef} updated`,
body: truncate(updated.title, 96),
tone: "success",
action: { label: `View ${issueRef}`, href: `/issues/${updated.identifier ?? updated.id}` },
});
},
});
const addComment = useMutation({
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
issuesApi.addComment(issueId!, body, reopen),
onSuccess: (comment) => {
onSuccess: () => {
invalidateIssue();
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
const issueRef = issue?.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue");
pushToast({
dedupeKey: `activity:issue.comment_added:${issueId}:${comment.id}`,
title: `Comment posted on ${issueRef}`,
body: issue?.title ? truncate(issue.title, 96) : undefined,
tone: "success",
action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined,
});
},
});
@@ -449,17 +431,9 @@ export function IssueDetail() {
assigneeUserId: reassignment.assigneeUserId,
...(reopen ? { status: "todo" } : {}),
}),
onSuccess: (updated) => {
onSuccess: () => {
invalidateIssue();
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
const issueRef = updated.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue");
pushToast({
dedupeKey: `activity:issue.reassigned:${updated.id}`,
title: `${issueRef} reassigned`,
body: issue?.title ? truncate(issue.title, 96) : undefined,
tone: "success",
action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined,
});
},
});

289
ui/src/pages/NewAgent.tsx Normal file
View File

@@ -0,0 +1,289 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { agentsApi } from "../api/agents";
import { queryKeys } from "../lib/queryKeys";
import { AGENT_ROLES } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Shield, User } from "lucide-react";
import { cn, agentUrl } from "../lib/utils";
import { roleLabels } from "../components/agent-config-primitives";
import { AgentConfigForm, type CreateConfigValues } from "../components/AgentConfigForm";
import { defaultCreateValues } from "../components/agent-config-defaults";
import { getUIAdapter } from "../adapters";
import { AgentIcon } from "../components/AgentIconPicker";
export function NewAgent() {
const { selectedCompanyId, selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [name, setName] = useState("");
const [title, setTitle] = useState("");
const [role, setRole] = useState("general");
const [reportsTo, setReportsTo] = useState("");
const [configValues, setConfigValues] = useState<CreateConfigValues>(defaultCreateValues);
const [roleOpen, setRoleOpen] = useState(false);
const [reportsToOpen, setReportsToOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const {
data: adapterModels,
error: adapterModelsError,
isLoading: adapterModelsLoading,
isFetching: adapterModelsFetching,
} = useQuery({
queryKey: selectedCompanyId
? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType)
: ["agents", "none", "adapter-models", configValues.adapterType],
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType),
enabled: Boolean(selectedCompanyId),
});
const isFirstAgent = !agents || agents.length === 0;
const effectiveRole = isFirstAgent ? "ceo" : role;
useEffect(() => {
setBreadcrumbs([
{ label: "Agents", href: "/agents" },
{ label: "New Agent" },
]);
}, [setBreadcrumbs]);
useEffect(() => {
if (isFirstAgent) {
if (!name) setName("CEO");
if (!title) setTitle("CEO");
}
}, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps
const createAgent = useMutation({
mutationFn: (data: Record<string, unknown>) =>
agentsApi.hire(selectedCompanyId!, data),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
navigate(agentUrl(result.agent));
},
onError: (error) => {
setFormError(error instanceof Error ? error.message : "Failed to create agent");
},
});
function buildAdapterConfig() {
const adapter = getUIAdapter(configValues.adapterType);
return adapter.buildAdapterConfig(configValues);
}
function handleSubmit() {
if (!selectedCompanyId || !name.trim()) return;
setFormError(null);
if (configValues.adapterType === "opencode_local") {
const selectedModel = configValues.model.trim();
if (!selectedModel) {
setFormError("OpenCode requires an explicit model in provider/model format.");
return;
}
if (adapterModelsError) {
setFormError(
adapterModelsError instanceof Error
? adapterModelsError.message
: "Failed to load OpenCode models.",
);
return;
}
if (adapterModelsLoading || adapterModelsFetching) {
setFormError("OpenCode models are still loading. Please wait and try again.");
return;
}
const discovered = adapterModels ?? [];
if (!discovered.some((entry) => entry.id === selectedModel)) {
setFormError(
discovered.length === 0
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
: `Configured OpenCode model is unavailable: ${selectedModel}`,
);
return;
}
}
createAgent.mutate({
name: name.trim(),
role: effectiveRole,
...(title.trim() ? { title: title.trim() } : {}),
...(reportsTo ? { reportsTo } : {}),
adapterType: configValues.adapterType,
adapterConfig: buildAdapterConfig(),
runtimeConfig: {
heartbeat: {
enabled: configValues.heartbeatEnabled,
intervalSec: configValues.intervalSec,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1,
},
},
budgetMonthlyCents: 0,
});
}
const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo);
return (
<div className="mx-auto max-w-2xl space-y-6">
<div>
<h1 className="text-lg font-semibold">New Agent</h1>
<p className="text-sm text-muted-foreground mt-1">
Advanced agent configuration
</p>
</div>
<div className="border border-border">
{/* Name */}
<div className="px-4 pt-4 pb-2">
<input
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder="Agent name"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</div>
{/* Title */}
<div className="px-4 pb-2">
<input
className="w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40"
placeholder="Title (e.g. VP of Engineering)"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
{/* Property chips: Role + Reports To */}
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
<Popover open={roleOpen} onOpenChange={setRoleOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
isFirstAgent && "opacity-60 cursor-not-allowed"
)}
disabled={isFirstAgent}
>
<Shield className="h-3 w-3 text-muted-foreground" />
{roleLabels[effectiveRole] ?? effectiveRole}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start">
{AGENT_ROLES.map((r) => (
<button
key={r}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
r === role && "bg-accent"
)}
onClick={() => { setRole(r); setRoleOpen(false); }}
>
{roleLabels[r] ?? r}
</button>
))}
</PopoverContent>
</Popover>
<Popover open={reportsToOpen} onOpenChange={setReportsToOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
isFirstAgent && "opacity-60 cursor-not-allowed"
)}
disabled={isFirstAgent}
>
{currentReportsTo ? (
<>
<AgentIcon icon={currentReportsTo.icon} className="h-3 w-3 text-muted-foreground" />
{`Reports to ${currentReportsTo.name}`}
</>
) : (
<>
<User className="h-3 w-3 text-muted-foreground" />
{isFirstAgent ? "Reports to: N/A (CEO)" : "Reports to..."}
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="start">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!reportsTo && "bg-accent"
)}
onClick={() => { setReportsTo(""); setReportsToOpen(false); }}
>
No manager
</button>
{(agents ?? []).map((a) => (
<button
key={a.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate",
a.id === reportsTo && "bg-accent"
)}
onClick={() => { setReportsTo(a.id); setReportsToOpen(false); }}
>
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
{a.name}
<span className="text-muted-foreground ml-auto">{roleLabels[a.role] ?? a.role}</span>
</button>
))}
</PopoverContent>
</Popover>
</div>
{/* Shared config form */}
<AgentConfigForm
mode="create"
values={configValues}
onChange={(patch) => setConfigValues((prev) => ({ ...prev, ...patch }))}
adapterModels={adapterModels}
/>
{/* Footer */}
<div className="border-t border-border px-4 py-3">
{isFirstAgent && (
<p className="text-xs text-muted-foreground mb-2">This will be the CEO</p>
)}
{formError && (
<p className="text-xs text-destructive mb-2">{formError}</p>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => navigate("/agents")}>
Cancel
</Button>
<Button
size="sm"
disabled={!name.trim() || createAgent.isPending}
onClick={handleSubmit}
>
{createAgent.isPending ? "Creating…" : "Create agent"}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -121,6 +121,7 @@ const adapterLabels: Record<string, string> = {
opencode_local: "OpenCode",
cursor: "Cursor",
openclaw: "OpenClaw",
openclaw_gateway: "OpenClaw Gateway",
process: "Process",
http: "HTTP",
};