Merge pull request #252 from paperclipai/dotta
Dotta updates - sorry it's so large
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
14
doc/CLI.md
14
doc/CLI.md
@@ -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
|
||||
|
||||
55
doc/OPENCLAW_ONBOARDING.md
Normal file
55
doc/OPENCLAW_ONBOARDING.md
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
71
packages/adapters/openclaw-gateway/README.md
Normal file
71
packages/adapters/openclaw-gateway/README.md
Normal 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.
|
||||
@@ -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 user’s 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.
|
||||
52
packages/adapters/openclaw-gateway/package.json
Normal file
52
packages/adapters/openclaw-gateway/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
packages/adapters/openclaw-gateway/src/cli/format-event.ts
Normal file
23
packages/adapters/openclaw-gateway/src/cli/format-event.ts
Normal 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));
|
||||
}
|
||||
1
packages/adapters/openclaw-gateway/src/cli/index.ts
Normal file
1
packages/adapters/openclaw-gateway/src/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { printOpenClawGatewayStreamEvent } from "./format-event.js";
|
||||
41
packages/adapters/openclaw-gateway/src/index.ts
Normal file
41
packages/adapters/openclaw-gateway/src/index.ts
Normal 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)
|
||||
`;
|
||||
1091
packages/adapters/openclaw-gateway/src/server/execute.ts
Normal file
1091
packages/adapters/openclaw-gateway/src/server/execute.ts
Normal file
File diff suppressed because it is too large
Load Diff
2
packages/adapters/openclaw-gateway/src/server/index.ts
Normal file
2
packages/adapters/openclaw-gateway/src/server/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
317
packages/adapters/openclaw-gateway/src/server/test.ts
Normal file
317
packages/adapters/openclaw-gateway/src/server/test.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
16
packages/adapters/openclaw-gateway/src/shared/stream.ts
Normal file
16
packages/adapters/openclaw-gateway/src/shared/stream.ts
Normal 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 };
|
||||
}
|
||||
13
packages/adapters/openclaw-gateway/src/ui/build-config.ts
Normal file
13
packages/adapters/openclaw-gateway/src/ui/build-config.ts
Normal 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;
|
||||
}
|
||||
2
packages/adapters/openclaw-gateway/src/ui/index.ts
Normal file
2
packages/adapters/openclaw-gateway/src/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { parseOpenClawGatewayStdoutLine } from "./parse-stdout.js";
|
||||
export { buildOpenClawGatewayConfig } from "./build-config.js";
|
||||
75
packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts
Normal file
75
packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts
Normal 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 }];
|
||||
}
|
||||
8
packages/adapters/openclaw-gateway/tsconfig.json
Normal file
8
packages/adapters/openclaw-gateway/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
958
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
868
scripts/smoke/openclaw-gateway-e2e.sh
Executable file
868
scripts/smoke/openclaw-gateway-e2e.sh
Executable 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 "$@"
|
||||
@@ -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:*",
|
||||
|
||||
53
server/src/__tests__/error-handler.test.ts
Normal file
53
server/src/__tests__/error-handler.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
254
server/src/__tests__/openclaw-gateway-adapter.test.ts
Normal file
254
server/src/__tests__/openclaw-gateway-adapter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
45
server/src/__tests__/project-shortname-resolution.test.ts
Normal file
45
server/src/__tests__/project-shortname-resolution.test.ts
Normal 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("!!!");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
221
ui/src/adapters/openclaw-gateway/config-fields.tsx
Normal file
221
ui/src/adapters/openclaw-gateway/config-fields.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
ui/src/adapters/openclaw-gateway/index.ts
Normal file
12
ui/src/adapters/openclaw-gateway/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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++);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">›</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">×</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">×</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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,6 +5,8 @@ interface NewIssueDefaults {
|
||||
priority?: string;
|
||||
projectId?: string;
|
||||
assigneeAgentId?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface NewGoalDefaults {
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -26,6 +26,7 @@ const adapterLabels: Record<string, string> = {
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
289
ui/src/pages/NewAgent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -121,6 +121,7 @@ const adapterLabels: Record<string, string> = {
|
||||
opencode_local: "OpenCode",
|
||||
cursor: "Cursor",
|
||||
openclaw: "OpenClaw",
|
||||
openclaw_gateway: "OpenClaw Gateway",
|
||||
process: "Process",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user