diff --git a/Dockerfile b/Dockerfile index 0fcc3216..e99f9323 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ COPY packages/adapter-utils/package.json packages/adapter-utils/ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ -COPY packages/adapters/openclaw/package.json packages/adapters/openclaw/ +COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/ RUN pnpm install --frozen-lockfile diff --git a/cli/esbuild.config.mjs b/cli/esbuild.config.mjs index 495fad99..7976b7c9 100644 --- a/cli/esbuild.config.mjs +++ b/cli/esbuild.config.mjs @@ -21,7 +21,6 @@ const workspacePaths = [ "packages/adapter-utils", "packages/adapters/claude-local", "packages/adapters/codex-local", - "packages/adapters/openclaw", "packages/adapters/openclaw-gateway", ]; diff --git a/cli/package.json b/cli/package.json index 1bddae42..9670d997 100644 --- a/cli/package.json +++ b/cli/package.json @@ -39,7 +39,6 @@ "@paperclipai/adapter-cursor-local": "workspace:*", "@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:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 41d95f77..21b915f5 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -4,7 +4,6 @@ import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; 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"; @@ -34,11 +33,6 @@ const cursorLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printCursorStreamEvent, }; -const openclawCLIAdapter: CLIAdapterModule = { - type: "openclaw", - formatStdoutEvent: printOpenClawStreamEvent, -}; - const openclawGatewayCLIAdapter: CLIAdapterModule = { type: "openclaw_gateway", formatStdoutEvent: printOpenClawGatewayStreamEvent, @@ -51,7 +45,6 @@ const adaptersByType = new Map( openCodeLocalCLIAdapter, piLocalCLIAdapter, cursorLocalCLIAdapter, - openclawCLIAdapter, openclawGatewayCLIAdapter, processCLIAdapter, httpCLIAdapter, diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index c98ca158..36eb04e6 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -197,10 +197,16 @@ export function registerAgentCommands(program: Command): void { const agentRow = await ctx.api.get( `/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`, ); + if (!agentRow) { + throw new Error(`Agent not found: ${agentRef}`); + } const now = new Date().toISOString().replaceAll(":", "-"); const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`; const key = await ctx.api.post(`/api/agents/${agentRow.id}/keys`, { name: keyName }); + if (!key) { + throw new Error("Failed to create API key"); + } const installSummaries: SkillsInstallSummary[] = []; if (opts.installSkills !== false) { diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index e31a6f8b..bdb098b3 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -18,36 +18,75 @@ 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. +4. Use the OpenClaw invite prompt flow. +- In the Invites section, click `Generate OpenClaw Invite Prompt`. +- Copy the generated prompt from `OpenClaw Invite Prompt`. - Paste it into OpenClaw main chat as one message. - If it stalls, send one follow-up: `How is onboarding going? Continue setup now.` +Security/control note: +- The OpenClaw invite prompt is created from a controlled endpoint: + - `POST /api/companies/{companyId}/openclaw/invite-prompt` + - board users with invite permission can call it + - agent callers are limited to the company CEO agent + 5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents. -6. Case A (manual issue test). +6. Gateway preflight (required before task tests). +- Confirm the created agent uses `openclaw_gateway` (not `openclaw`). +- Confirm gateway URL is `ws://...` or `wss://...`. +- Confirm gateway token is non-trivial (not empty / not 1-char placeholder). +- The OpenClaw Gateway adapter UI should not expose `disableDeviceAuth` for normal onboarding. +- Confirm pairing mode is explicit: + - required default: device auth enabled (`adapterConfig.disableDeviceAuth` false/absent) with persisted `adapterConfig.devicePrivateKeyPem` + - do not rely on `disableDeviceAuth` for normal onboarding +- If you can run API checks with board auth: +```bash +AGENT_ID="" +curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT_ID" | jq '{adapterType,adapterConfig:{url:.adapterConfig.url,tokenLen:(.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // "" | length),disableDeviceAuth:(.adapterConfig.disableDeviceAuth // false),hasDeviceKey:(.adapterConfig.devicePrivateKeyPem // "" | length > 0)}}' +``` +- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`. + +Pairing handshake note: +- Clean run expectation: first task should succeed without manual pairing commands. +- The adapter attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid). +- If auto-pair cannot complete (for example token mismatch or no pending request), the first gateway run may still return `pairing required`. +- This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself. +- Approve it in OpenClaw, then retry the task. +- For local docker smoke, you can approve from host: +```bash +docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'openclaw devices approve --latest --json --url "ws://127.0.0.1:18789" --token "$(node -p \"require(process.env.HOME+\\\"/.openclaw/openclaw.json\\\").gateway.auth.token\")"' +``` +- You can inspect pending vs paired devices: +```bash +docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'TOK="$(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||\\\"\\\");\")\"; openclaw devices list --json --url \"ws://127.0.0.1:18789\" --token \"$TOK\"' +``` + +7. Case A (manual issue test). - Create an issue assigned to the OpenClaw agent. - Put instructions: “post comment `OPENCLAW_CASE_A_OK_` and mark done.” - Verify in UI: issue status becomes `done` and comment exists. -7. Case B (message tool test). +8. Case B (message tool test). - Create another issue assigned to OpenClaw. - Instructions: “send `OPENCLAW_CASE_B_OK_` 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). +9. 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_`. - Verify in Paperclip UI that new issue exists. -9. Watch logs during test (optional but helpful): +10. 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. +11. Expected pass criteria. +- Preflight: `openclaw_gateway` + non-placeholder token (`tokenLen >= 16`). +- Pairing mode: stable `devicePrivateKeyPem` configured with device auth enabled (default path). - 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. diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md new file mode 100644 index 00000000..896f5115 --- /dev/null +++ b/doc/plugins/PLUGIN_SPEC.md @@ -0,0 +1,1617 @@ +# Paperclip Plugin System Specification + +Status: proposed complete spec for the post-V1 plugin system + +This document is the complete specification for Paperclip's plugin and extension architecture. +It expands the brief plugin notes in [doc/SPEC.md](../SPEC.md) and should be read alongside the comparative analysis in [doc/plugins/ideas-from-opencode.md](./ideas-from-opencode.md). + +This is not part of the V1 implementation contract in [doc/SPEC-implementation.md](../SPEC-implementation.md). +It is the full target architecture for the plugin system that should follow V1. + +## 1. Scope + +This spec covers: + +- plugin packaging and installation +- runtime model +- trust model +- capability system +- UI extension surfaces +- plugin settings UI +- agent tool contributions +- event, job, and webhook surfaces +- plugin-to-plugin communication +- local tooling approach for workspace plugins +- Postgres persistence for extensions +- uninstall and data lifecycle +- plugin observability +- plugin development and testing +- operator workflows +- hot plugin lifecycle (no server restart) +- SDK versioning and compatibility rules + +This spec does not cover: + +- a public marketplace +- cloud/SaaS multi-tenancy +- arbitrary third-party schema migrations in the first plugin version +- iframe-sandboxed plugin UI in the first plugin version (plugins render as ES modules in host extension slots) + +## 2. Core Assumptions + +Paperclip plugin design is based on the following assumptions: + +1. Paperclip is single-tenant and self-hosted. +2. Plugin installation is global to the instance. +3. "Companies" remain core Paperclip business objects, but they are not plugin trust boundaries. +4. Board governance, approval gates, budget hard-stops, and core task invariants remain owned by Paperclip core. +5. Projects already have a real workspace model via `project_workspaces`, and local/runtime plugins should build on that instead of inventing a separate workspace abstraction. + +## 3. Goals + +The plugin system must: + +1. Let operators install global instance-wide plugins. +2. Let plugins add major capabilities without editing Paperclip core. +3. Keep core governance and auditing intact. +4. Support both local/runtime plugins and external SaaS connectors. +5. Support future plugin categories such as: + - new agent adapters + - revenue tracking + - knowledge base + - issue tracker sync + - metrics/dashboards + - file/project tooling +6. Use simple, explicit, typed contracts. +7. Keep failures isolated so one plugin does not crash the entire instance. + +## 4. Non-Goals + +The first plugin system must not: + +1. Allow arbitrary plugins to override core routes or core invariants. +2. Allow arbitrary plugins to mutate approval, auth, issue checkout, or budget enforcement logic. +3. Allow arbitrary third-party plugins to run free-form DB migrations. +4. Depend on project-local plugin folders such as `.paperclip/plugins`. +5. Depend on automatic install-and-execute behavior at server startup from arbitrary config files. + +## 5. Terminology + +### 5.1 Instance + +The single Paperclip deployment an operator installs and controls. + +### 5.2 Company + +A first-class Paperclip business object inside the instance. + +### 5.3 Project Workspace + +A workspace attached to a project through `project_workspaces`. +Plugins resolve workspace paths from this model to locate local directories for file, terminal, git, and process operations. + +### 5.4 Platform Module + +A trusted in-process extension loaded directly by Paperclip core. + +Examples: + +- agent adapters +- storage providers +- secret providers +- run-log backends + +### 5.5 Plugin + +An installable instance-wide extension package loaded through the Paperclip plugin runtime. + +Examples: + +- Linear sync +- GitHub Issues sync +- Grafana widgets +- Stripe revenue sync +- file browser +- terminal +- git workflow + +### 5.6 Plugin Worker + +The runtime process used for a plugin. +In this spec, third-party plugins run out-of-process by default. + +### 5.7 Capability + +A named permission the host grants to a plugin. +Plugins may only call host APIs that are covered by granted capabilities. + +## 6. Extension Classes + +Paperclip has two extension classes. + +## 6.1 Platform Modules + +Platform modules are: + +- trusted +- in-process +- host-integrated +- low-level + +They use explicit registries, not the general plugin worker protocol. + +Platform module surfaces: + +- `registerAgentAdapter()` +- `registerStorageProvider()` +- `registerSecretProvider()` +- `registerRunLogStore()` + +Platform modules are the right place for: + +- new agent adapter packages +- new storage backends +- new secret backends +- other host-internal systems that need direct process or DB integration + +## 6.2 Plugins + +Plugins are: + +- globally installed per instance +- loaded through the plugin runtime +- additive +- capability-gated +- isolated from core via a stable SDK and host protocol + +Plugin categories: + +- `connector` +- `workspace` +- `automation` +- `ui` + +A plugin may declare more than one category. + +## 7. Project Workspaces + +Paperclip already has a concrete workspace model: + +- projects expose `workspaces` +- projects expose `primaryWorkspace` +- the database contains `project_workspaces` +- project routes already manage workspaces + +Plugins that need local tooling (file browsing, git, terminals, process tracking) can resolve workspace paths through the project workspace APIs and then operate on the filesystem, spawn processes, and run git commands directly. The host does not wrap these operations — plugins own their own implementations. + +## 8. Installation Model + +Plugin installation is global and operator-driven. + +There is no per-company install table and no per-company enable/disable switch. + +If a plugin needs business-object-specific mappings, those are stored as plugin configuration or plugin state. + +Examples: + +- one global Linear plugin install +- mappings from company A to Linear team X and company B to Linear team Y +- one global git plugin install +- per-project workspace state stored under `project_workspace` + +## 8.1 On-Disk Layout + +Plugins live under the Paperclip instance directory. + +Suggested layout: + +- `~/.paperclip/instances/default/plugins/package.json` +- `~/.paperclip/instances/default/plugins/node_modules/` +- `~/.paperclip/instances/default/plugins/.cache/` +- `~/.paperclip/instances/default/data/plugins//` + +The package install directory and the plugin data directory are separate. + +## 8.2 Operator Commands + +Paperclip should add CLI commands: + +- `pnpm paperclipai plugin list` +- `pnpm paperclipai plugin install ` +- `pnpm paperclipai plugin uninstall ` +- `pnpm paperclipai plugin upgrade [version]` +- `pnpm paperclipai plugin doctor ` + +These commands are instance-level operations. + +## 8.3 Install Process + +The install process is: + +1. Resolve npm package and version. +2. Install into the instance plugin directory. +3. Read and validate plugin manifest. +4. Reject incompatible plugin API versions. +5. Display requested capabilities to the operator. +6. Persist install record in Postgres. +7. Start plugin worker and run health/validation. +8. Mark plugin `ready` or `error`. + +## 9. Load Order And Precedence + +Load order must be deterministic. + +1. core platform modules +2. built-in first-party plugins +3. installed plugins sorted by: + - explicit operator-configured order if present + - otherwise manifest `id` + +Rules: + +- plugin contributions are additive by default +- plugins may not override core routes or core actions by name collision +- UI slot IDs are automatically namespaced by plugin ID (e.g. `@paperclip/plugin-linear:sync-health-widget`), so cross-plugin collisions are structurally impossible +- if a single plugin declares duplicate slot IDs within its own manifest, the host must reject at install time + +## 10. Package Contract + +Each plugin package must export a manifest, a worker entrypoint, and optionally a UI bundle. + +Suggested package layout: + +- `dist/manifest.js` +- `dist/worker.js` +- `dist/ui/` (optional, contains the plugin's frontend bundle) + +Suggested `package.json` keys: + +```json +{ + "name": "@paperclip/plugin-linear", + "version": "0.1.0", + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + } +} +``` + +## 10.1 Manifest Shape + +Normative manifest shape: + +```ts +export interface PaperclipPluginManifestV1 { + id: string; + apiVersion: 1; + version: string; + displayName: string; + description: string; + categories: Array<"connector" | "workspace" | "automation" | "ui">; + minimumPaperclipVersion?: string; + capabilities: string[]; + entrypoints: { + worker: string; + ui?: string; + }; + instanceConfigSchema?: JsonSchema; + jobs?: PluginJobDeclaration[]; + webhooks?: PluginWebhookDeclaration[]; + tools?: Array<{ + name: string; + displayName: string; + description: string; + parametersSchema: JsonSchema; + }>; + ui?: { + slots: Array<{ + type: "page" | "detailTab" | "dashboardWidget" | "sidebar" | "settingsPage"; + id: string; + displayName: string; + /** Which export name in the UI bundle provides this component */ + exportName: string; + /** For detailTab: which entity types this tab appears on */ + entityTypes?: Array<"project" | "issue" | "agent" | "goal" | "run">; + }>; + }; +} +``` + +Rules: + +- `id` must be globally unique +- `id` should normally equal the npm package name +- `apiVersion` must match the host-supported plugin API version +- `capabilities` must be static and install-time visible +- config schema must be JSON Schema compatible +- `entrypoints.ui` points to the directory containing the built UI bundle +- `ui.slots` declares which extension slots the plugin fills, so the host knows what to mount without loading the bundle eagerly; each slot references an `exportName` from the UI bundle + +## 11. Agent Tools + +Plugins may contribute tools that Paperclip agents can use during runs. + +### 11.1 Tool Declaration + +Plugins declare tools in their manifest: + +```ts +tools?: Array<{ + name: string; + displayName: string; + description: string; + parametersSchema: JsonSchema; +}>; +``` + +Tool names are automatically namespaced by plugin ID at runtime (e.g. `linear:search-issues`), so plugins cannot shadow core tools or each other's tools. + +### 11.2 Tool Execution + +When an agent invokes a plugin tool during a run, the host routes the call to the plugin worker via a `executeTool` RPC method: + +- `executeTool(input)` — receives tool name, parsed parameters, and run context (agent ID, run ID, company ID, project ID) + +The worker executes the tool logic and returns a typed result. The host enforces capability gates — a plugin must declare `agent.tools.register` to contribute tools, and individual tools may require additional capabilities (e.g. `http.outbound` for tools that call external APIs). + +### 11.3 Tool Availability + +By default, plugin tools are available to all agents. The operator may restrict tool availability per agent or per project through plugin configuration. + +Plugin tools appear in the agent's tool list alongside core tools but are visually distinguished in the UI as plugin-contributed. + +### 11.4 Constraints + +- Plugin tools must not override or shadow core tools by name. +- Plugin tools must be idempotent where possible. +- Tool execution is subject to the same timeout and resource limits as other plugin worker calls. +- Tool results are included in run logs. + +## 12. Runtime Model + +## 12.1 Process Model + +Third-party plugins run out-of-process by default. + +Default runtime: + +- Paperclip server starts one worker process per installed plugin +- the worker process is a Node process +- host and worker communicate over JSON-RPC on stdio + +This design provides: + +- failure isolation +- clearer logging boundaries +- easier resource limits +- a cleaner trust boundary than arbitrary in-process execution + +## 12.2 Host Responsibilities + +The host is responsible for: + +- package install +- manifest validation +- capability enforcement +- process supervision +- job scheduling +- webhook routing +- activity log writes +- secret resolution +- UI route registration + +## 12.3 Worker Responsibilities + +The plugin worker is responsible for: + +- validating its own config +- handling domain events +- handling scheduled jobs +- handling webhooks +- serving data and handling actions for the plugin's own UI via `getData` and `performAction` +- invoking host services through the SDK +- reporting health information + +## 12.4 Failure Policy + +If a worker fails: + +- mark plugin status `error` +- surface error in plugin health UI +- keep the rest of the instance running +- retry start with bounded backoff +- do not drop other plugins or core services + +## 12.5 Graceful Shutdown Policy + +When the host needs to stop a plugin worker (for upgrade, uninstall, or instance shutdown): + +1. The host sends `shutdown()` to the worker. +2. The worker has 10 seconds to finish in-flight work and exit cleanly. +3. If the worker does not exit within the deadline, the host sends SIGTERM. +4. If the worker does not exit within 5 seconds after SIGTERM, the host sends SIGKILL. +5. Any in-flight job runs are marked `cancelled` with a note indicating forced shutdown. +6. Any in-flight `getData` or `performAction` calls return an error to the bridge. + +The shutdown deadline should be configurable per-plugin in plugin config for plugins that need longer drain periods. + +## 13. Host-Worker Protocol + +The host must support the following worker RPC methods. + +Required methods: + +- `initialize(input)` +- `health()` +- `shutdown()` + +Optional methods: + +- `validateConfig(input)` +- `configChanged(input)` +- `onEvent(input)` +- `runJob(input)` +- `handleWebhook(input)` +- `getData(input)` +- `performAction(input)` +- `executeTool(input)` + +### 13.1 `initialize` + +Called once on worker startup. + +Input includes: + +- plugin manifest +- resolved plugin config +- instance info +- host API version + +### 13.2 `health` + +Returns: + +- status +- current error if any +- optional plugin-reported diagnostics + +### 13.3 `validateConfig` + +Runs after config changes and startup. + +Returns: + +- `ok` +- warnings +- errors + +### 13.4 `configChanged` + +Called when the operator updates the plugin's instance config at runtime. + +Input includes: + +- new resolved config + +If the worker implements this method, it applies the new config without restarting. If the worker does not implement this method, the host restarts the worker process with the new config (graceful shutdown then restart). + +### 13.5 `onEvent` + +Receives one typed Paperclip domain event. + +Delivery semantics: + +- at least once +- plugin must be idempotent +- no global ordering guarantee across all event types +- per-entity ordering is best effort but not guaranteed after retries + +### 13.6 `runJob` + +Runs a declared scheduled job. + +The host provides: + +- job key +- trigger source +- run id +- schedule metadata + +### 13.7 `handleWebhook` + +Receives inbound webhook payload routed by the host. + +The host provides: + +- endpoint key +- headers +- raw body +- parsed body if applicable +- request id + +### 13.8 `getData` + +Returns plugin data requested by the plugin's own UI components. + +The plugin UI calls the host bridge, which forwards the request to the worker. The worker returns typed JSON that the plugin's own frontend components render. + +Input includes: + +- data key (plugin-defined, e.g. `"sync-health"`, `"issue-detail"`) +- context (company id, project id, entity id, etc.) +- optional query parameters + +### 13.9 `performAction` + +Runs an explicit plugin action initiated by the board UI. + +Examples: + +- "resync now" +- "link GitHub issue" +- "create branch from issue" +- "restart process" + +### 13.10 `executeTool` + +Runs a plugin-contributed agent tool during a run. + +The host provides: + +- tool name (without plugin namespace prefix) +- parsed parameters matching the tool's declared schema +- run context: agent ID, run ID, company ID, project ID + +The worker executes the tool and returns a typed result (string content, structured data, or error). + +## 14. SDK Surface + +Plugins do not talk to the DB directly. +Plugins do not read raw secret material from persisted config. + +The SDK exposed to workers must provide typed host clients. + +Required SDK clients: + +- `ctx.config` +- `ctx.events` +- `ctx.jobs` +- `ctx.http` +- `ctx.secrets` +- `ctx.assets` +- `ctx.activity` +- `ctx.state` +- `ctx.entities` +- `ctx.projects` +- `ctx.issues` +- `ctx.agents` +- `ctx.goals` +- `ctx.data` +- `ctx.actions` +- `ctx.tools` +- `ctx.logger` + +`ctx.data` and `ctx.actions` register handlers that the plugin's own UI calls through the host bridge. `ctx.data.register(key, handler)` backs `usePluginData(key)` on the frontend. `ctx.actions.register(key, handler)` backs `usePluginAction(key)`. + +Plugins that need filesystem, git, terminal, or process operations handle those directly using standard Node APIs or libraries. The host provides project workspace metadata through `ctx.projects` so plugins can resolve workspace paths, but the host does not proxy low-level OS operations. + +## 14.1 Example SDK Shape + +```ts +/** Top-level helper for defining a plugin with type checking */ +export function definePlugin(definition: PluginDefinition): PaperclipPlugin; + +/** Re-exported from Zod for config schema definitions */ +export { z } from "zod"; + +export interface PluginContext { + manifest: PaperclipPluginManifestV1; + config: { + get(): Promise>; + }; + events: { + on(name: string, fn: (event: unknown) => Promise): void; + on(name: string, filter: EventFilter, fn: (event: unknown) => Promise): void; + emit(name: string, payload: unknown): Promise; + }; + jobs: { + register(key: string, input: { cron: string }, fn: (job: PluginJobContext) => Promise): void; + }; + state: { + get(input: ScopeKey): Promise; + set(input: ScopeKey, value: unknown): Promise; + delete(input: ScopeKey): Promise; + }; + entities: { + upsert(input: PluginEntityUpsert): Promise; + list(input: PluginEntityQuery): Promise; + }; + data: { + register(key: string, handler: (params: Record) => Promise): void; + }; + actions: { + register(key: string, handler: (params: Record) => Promise): void; + }; + tools: { + register(name: string, input: PluginToolDeclaration, fn: (params: unknown, runCtx: ToolRunContext) => Promise): void; + }; + logger: { + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + error(message: string, meta?: Record): void; + debug(message: string, meta?: Record): void; + }; +} + +export interface EventFilter { + projectId?: string; + companyId?: string; + agentId?: string; + [key: string]: unknown; +} +``` + +## 15. Capability Model + +Capabilities are mandatory and static. +Every plugin declares them up front. + +The host enforces capabilities in the SDK layer and refuses calls outside the granted set. + +## 15.1 Capability Categories + +### Data Read + +- `companies.read` +- `projects.read` +- `project.workspaces.read` +- `issues.read` +- `issue.comments.read` +- `agents.read` +- `goals.read` +- `activity.read` +- `costs.read` + +### Data Write + +- `issues.create` +- `issues.update` +- `issue.comments.create` +- `assets.write` +- `assets.read` +- `activity.log.write` +- `metrics.write` + +### Plugin State + +- `plugin.state.read` +- `plugin.state.write` + +### Runtime / Integration + +- `events.subscribe` +- `events.emit` +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` + +### Agent Tools + +- `agent.tools.register` + +### UI + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` +- `ui.dashboardWidget.register` +- `ui.action.register` + +## 15.2 Forbidden Capabilities + +The host must not expose capabilities for: + +- approval decisions +- budget override +- auth bypass +- issue checkout lock override +- direct DB access + +## 15.3 Upgrade Rules + +If a plugin upgrade adds capabilities: + +1. the host must mark the plugin `upgrade_pending` +2. the operator must explicitly approve the new capability set +3. the new version does not become `ready` until approval completes + +## 16. Event System + +The host must emit typed domain events that plugins may subscribe to. + +Minimum event set: + +- `company.created` +- `company.updated` +- `project.created` +- `project.updated` +- `project.workspace_created` +- `project.workspace_updated` +- `project.workspace_deleted` +- `issue.created` +- `issue.updated` +- `issue.comment.created` +- `agent.created` +- `agent.updated` +- `agent.status_changed` +- `agent.run.started` +- `agent.run.finished` +- `agent.run.failed` +- `agent.run.cancelled` +- `approval.created` +- `approval.decided` +- `cost_event.created` +- `activity.logged` + +Each event must include: + +- event id +- event type +- occurred at +- actor metadata when applicable +- primary entity metadata +- typed payload + +### 16.1 Event Filtering + +Plugins may provide an optional filter when subscribing to events. The filter is evaluated by the host before dispatching to the worker, so filtered-out events never cross the process boundary. + +Supported filter fields: + +- `projectId` — only receive events for a specific project +- `companyId` — only receive events for a specific company +- `agentId` — only receive events for a specific agent + +Filters are optional. If omitted, the plugin receives all events of the subscribed type. Filters may be combined (e.g. filter by both company and project). + +### 16.2 Plugin-to-Plugin Events + +Plugins may emit custom events using `ctx.events.emit(name, payload)`. Plugin-emitted events use a namespaced event type: `plugin..`. + +Other plugins may subscribe to these events using the same `ctx.events.on()` API: + +```ts +ctx.events.on("plugin.@paperclip/plugin-git.push-detected", async (event) => { + // react to the git plugin detecting a push +}); +``` + +Rules: + +- Plugin events require the `events.emit` capability. +- Plugin events are not core domain events — they do not appear in the core activity log unless the emitting plugin explicitly logs them. +- Plugin events follow the same at-least-once delivery semantics as core events. +- The host must not allow plugins to emit events in the core namespace (events without the `plugin.` prefix). + +## 17. Scheduled Jobs + +Plugins may declare scheduled jobs in their manifest. + +Job rules: + +1. Each job has a stable `job_key`. +2. The host is the scheduler of record. +3. The host prevents overlapping execution of the same plugin/job combination unless explicitly allowed later. +4. Every job run is recorded in Postgres. +5. Failed jobs are retryable. + +## 18. Webhooks + +Plugins may declare webhook endpoints in their manifest. + +Webhook route shape: + +- `POST /api/plugins/:pluginId/webhooks/:endpointKey` + +Rules: + +1. The host owns the public route. +2. The worker receives the request body through `handleWebhook`. +3. Signature verification happens in plugin code using secret refs resolved by the host. +4. Every delivery is recorded. +5. Webhook handling must be idempotent. + +## 19. UI Extension Model + +Plugins ship their own frontend UI as a bundled React module. The host loads plugin UI into designated extension slots and provides a bridge for the plugin frontend to communicate with its own worker backend and with host APIs. + +### How Plugin UI Publishing Works In Practice + +A plugin's `dist/ui/` directory contains a built React bundle. The host serves this bundle and loads it into the page when the user navigates to a plugin surface (a plugin page, a detail tab, a dashboard widget, etc.). + +**The host provides, the plugin renders:** + +1. The host defines **extension slots** — designated mount points in the UI where plugin components can appear (pages, tabs, widgets, sidebar entries, action bars). +2. The plugin's UI bundle exports named components for each slot it wants to fill. +3. The host mounts the plugin component into the slot, passing it a **host bridge** object. +4. The plugin component uses the bridge to fetch data from its own worker (via `getData`), call actions (via `performAction`), read host context (current company, project, entity), and use shared host UI primitives (design tokens, common components). + +**Concrete example: a Linear plugin ships a dashboard widget.** + +The plugin's UI bundle exports: + +```tsx +// dist/ui/index.tsx +import { usePluginData, usePluginAction, MetricCard, StatusBadge } from "@paperclipai/plugin-sdk/ui"; + +export function DashboardWidget({ context }: PluginWidgetProps) { + const { data, loading } = usePluginData("sync-health", { companyId: context.companyId }); + const resync = usePluginAction("resync"); + + if (loading) return ; + + return ( +
+ + {data.mappings.map(m => ( + + ))} + +
+ ); +} +``` + +**What happens at runtime:** + +1. User opens the dashboard. The host sees that the Linear plugin registered a `DashboardWidget` export. +2. The host mounts the plugin's `DashboardWidget` component into the dashboard widget slot, passing `context` (current company, user, etc.) and the bridge. +3. `usePluginData("sync-health", ...)` calls through the bridge → host → plugin worker's `getData` RPC → returns JSON → the plugin component renders it however it wants. +4. When the user clicks "Resync Now", `usePluginAction("resync")` calls through the bridge → host → plugin worker's `performAction` RPC. + +**What the host controls:** + +- The host decides **where** plugin components appear (which slots exist and when they mount). +- The host provides the **bridge** — plugin UI cannot make arbitrary network requests or access host internals directly. +- The host enforces **capability gates** — if a plugin's worker does not have a capability, the bridge rejects the call even if the UI requests it. +- The host provides **design tokens and shared components** via `@paperclipai/plugin-sdk/ui` so plugins can match the host's visual language without being forced to. + +**What the plugin controls:** + +- The plugin decides **how** to render its data — it owns its React components, layout, interactions, and state management. +- The plugin decides **what data** to fetch and **what actions** to expose. +- The plugin can use any React patterns (hooks, context, third-party component libraries) inside its bundle. + +### 19.0.1 Plugin UI SDK (`@paperclipai/plugin-sdk/ui`) + +The SDK includes a `ui` subpath export that plugin frontends import. This subpath provides: + +- **Bridge hooks**: `usePluginData(key, params)`, `usePluginAction(key)`, `useHostContext()` +- **Design tokens**: colors, spacing, typography, shadows matching the host theme +- **Shared components**: `MetricCard`, `StatusBadge`, `DataTable`, `LogView`, `ActionBar`, `Spinner`, etc. +- **Type definitions**: `PluginPageProps`, `PluginWidgetProps`, `PluginDetailTabProps` + +Plugins are encouraged but not required to use the shared components. A plugin may render entirely custom UI as long as it communicates through the bridge. + +### 19.0.2 Bundle Isolation + +Plugin UI bundles are loaded as standard ES modules, not iframed. This gives plugins full rendering performance and access to the host's design tokens. + +Isolation rules: + +- Plugin bundles must not import from host internals. They may only import from `@paperclipai/plugin-sdk/ui` and their own dependencies. +- Plugin bundles must not access `window.fetch` or `XMLHttpRequest` directly for host API calls. All host communication goes through the bridge. +- The host may enforce Content Security Policy rules that restrict plugin network access to the bridge endpoint only. +- Plugin bundles must be statically analyzable — no dynamic `import()` of URLs outside the plugin's own bundle. + +If stronger isolation is needed later, the host can move to iframe-based mounting for untrusted plugins without changing the plugin's source code (the bridge API stays the same). + +### 19.0.3 Bundle Serving + +Plugin UI bundles must be pre-built ESM. The host does not compile or transform plugin UI code at runtime. + +The host serves the plugin's `dist/ui/` directory as static assets under a namespaced path: + +- `/_plugins/:pluginId/ui/*` + +When the host renders an extension slot, it dynamically imports the plugin's UI entry module from this path, resolves the named export declared in `ui.slots[].exportName`, and mounts it into the slot. + +In development, the host may support a `devUiUrl` override in plugin config that points to a local dev server (e.g. Vite) so plugin authors can use hot-reload during development without rebuilding. + +## 19.1 Global Operator Routes + +- `/settings/plugins` +- `/settings/plugins/:pluginId` + +These routes are instance-level. + +## 19.2 Company-Context Routes + +- `/:companyPrefix/plugins/:pluginId` + +These routes exist because the board UI is organized around companies even though plugin installation is global. + +## 19.3 Detail Tabs + +Plugins may add tabs to: + +- project detail +- issue detail +- agent detail +- goal detail +- run detail + +Recommended route pattern: + +- `/:companyPrefix//:id?tab=` + +## 19.4 Dashboard Widgets + +Plugins may add cards or sections to the dashboard. + +## 19.5 Sidebar Entries + +Plugins may add sidebar links to: + +- global plugin settings +- company-context plugin pages + +## 19.6 Shared Components In `@paperclipai/plugin-sdk/ui` + +The host SDK ships shared components that plugins can import to quickly build UIs that match the host's look and feel. These are convenience building blocks, not a requirement. + +| Component | What it renders | Typical use | +|---|---|---| +| `MetricCard` | Single number with label, optional trend/sparkline | KPIs, counts, rates | +| `StatusBadge` | Inline status indicator (ok/warning/error/info) | Sync health, connection status | +| `DataTable` | Rows and columns with optional sorting and pagination | Issue lists, job history, process lists | +| `TimeseriesChart` | Line or bar chart with timestamped data points | Revenue trends, sync volume, error rates | +| `MarkdownBlock` | Rendered markdown text | Descriptions, help text, notes | +| `KeyValueList` | Label/value pairs in a definition-list layout | Entity metadata, config summary | +| `ActionBar` | Row of buttons wired to `usePluginAction` | Resync, create branch, restart process | +| `LogView` | Scrollable log output with timestamps | Webhook deliveries, job output, process logs | +| `JsonTree` | Collapsible JSON tree for debugging | Raw API responses, plugin state inspection | +| `Spinner` | Loading indicator | Data fetch states | + +Plugins may also use entirely custom components. The shared components exist to reduce boilerplate and keep visual consistency, not to limit what plugins can render. + +## 19.7 Error Propagation Through The Bridge + +The bridge hooks must return structured errors so plugin UI can handle failures gracefully. + +`usePluginData` returns: + +```ts +{ + data: T | null; + loading: boolean; + error: PluginBridgeError | null; +} +``` + +`usePluginAction` returns an async function that either resolves with the result or throws a `PluginBridgeError`. + +`PluginBridgeError` shape: + +```ts +interface PluginBridgeError { + code: "WORKER_UNAVAILABLE" | "CAPABILITY_DENIED" | "WORKER_ERROR" | "TIMEOUT" | "UNKNOWN"; + message: string; + /** Original error details from the worker, if available */ + details?: unknown; +} +``` + +Error codes: + +- `WORKER_UNAVAILABLE` — the plugin worker is not running (crashed, shutting down, not yet started) +- `CAPABILITY_DENIED` — the plugin does not have the required capability for this operation +- `WORKER_ERROR` — the worker returned an error from its `getData` or `performAction` handler +- `TIMEOUT` — the worker did not respond within the configured timeout +- `UNKNOWN` — unexpected bridge-level failure + +The `@paperclipai/plugin-sdk/ui` subpath should also export an `ErrorBoundary` component that plugin authors can use to catch rendering errors without crashing the host page. + +## 19.8 Plugin Settings UI + +Each plugin that declares an `instanceConfigSchema` in its manifest gets an auto-generated settings form at `/settings/plugins/:pluginId`. The host renders the form from the JSON Schema. + +The auto-generated form supports: + +- text inputs, number inputs, toggles, select dropdowns derived from schema types and enums +- nested objects rendered as fieldsets +- arrays rendered as repeatable field groups with add/remove controls +- secret ref fields: any schema property annotated with `"format": "secret-ref"` renders as a secret picker that resolves through the Paperclip secret provider system rather than a plain text input +- validation messages derived from schema constraints (`required`, `minLength`, `pattern`, `minimum`, etc.) +- a "Test Connection" action if the plugin declares a `validateConfig` RPC method — the host calls it and displays the result inline + +For plugins that need richer settings UX beyond what JSON Schema can express, the plugin may declare a `settingsPage` slot in `ui.slots`. When present, the host renders the plugin's own React component instead of the auto-generated form. The plugin component communicates with its worker through the standard bridge to read and write config. + +Both approaches coexist: a plugin can use the auto-generated form for simple config and add a custom settings page slot for advanced configuration or operational dashboards. + +## 20. Local Tooling + +Plugins that need filesystem, git, terminal, or process operations implement those directly. The host does not wrap or proxy these operations. + +The host provides workspace metadata through `ctx.projects` (list workspaces, get primary workspace, resolve workspace from issue or agent/run). Plugins use this metadata to resolve local paths and then operate on the filesystem, spawn processes, shell out to `git`, or open PTY sessions using standard Node APIs or any libraries they choose. + +This keeps the host lean — it does not need to maintain a parallel API surface for every OS-level operation a plugin might need. Plugins own their own logic for file browsing, git workflows, terminal sessions, and process management. + +## 21. Persistence And Postgres + +## 21.1 Database Principles + +1. Core Paperclip data stays in first-party tables. +2. Most plugin-owned data starts in generic extension tables. +3. Plugin data should scope to existing Paperclip objects before new tables are introduced. +4. Arbitrary third-party schema migrations are out of scope for the first plugin system. + +## 21.2 Core Table Reuse + +If data becomes part of the actual Paperclip product model, it should become a first-party table. + +Examples: + +- `project_workspaces` is already first-party +- if Paperclip later decides git state is core product data, it should become a first-party table too + +## 21.3 Required Tables + +### `plugins` + +- `id` uuid pk +- `plugin_key` text unique not null +- `package_name` text not null +- `version` text not null +- `api_version` int not null +- `categories` text[] not null +- `manifest_json` jsonb not null +- `status` enum: `installed | ready | error | upgrade_pending` +- `install_order` int null +- `installed_at` timestamptz not null +- `updated_at` timestamptz not null +- `last_error` text null + +Indexes: + +- unique `plugin_key` +- `status` + +### `plugin_config` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` unique not null +- `config_json` jsonb not null +- `created_at` timestamptz not null +- `updated_at` timestamptz not null +- `last_error` text null + +### `plugin_state` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `scope_kind` enum: `instance | company | project | project_workspace | agent | issue | goal | run` +- `scope_id` uuid/text null +- `namespace` text not null +- `state_key` text not null +- `value_json` jsonb not null +- `updated_at` timestamptz not null + +Constraints: + +- unique `(plugin_id, scope_kind, scope_id, namespace, state_key)` + +Examples: + +- Linear external IDs keyed by `issue` +- GitHub sync cursors keyed by `project` +- file browser preferences keyed by `project_workspace` +- git branch metadata keyed by `project_workspace` +- process metadata keyed by `project_workspace` or `run` + +### `plugin_jobs` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `scope_kind` enum nullable +- `scope_id` uuid/text null +- `job_key` text not null +- `schedule` text null +- `status` enum: `idle | queued | running | error` +- `next_run_at` timestamptz null +- `last_started_at` timestamptz null +- `last_finished_at` timestamptz null +- `last_succeeded_at` timestamptz null +- `last_error` text null + +Constraints: + +- unique `(plugin_id, scope_kind, scope_id, job_key)` + +### `plugin_job_runs` + +- `id` uuid pk +- `plugin_job_id` uuid fk `plugin_jobs.id` not null +- `plugin_id` uuid fk `plugins.id` not null +- `status` enum: `queued | running | succeeded | failed | cancelled` +- `trigger` enum: `schedule | manual | retry` +- `started_at` timestamptz null +- `finished_at` timestamptz null +- `error` text null +- `details_json` jsonb null + +Indexes: + +- `(plugin_id, started_at desc)` +- `(plugin_job_id, started_at desc)` + +### `plugin_webhook_deliveries` + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `scope_kind` enum nullable +- `scope_id` uuid/text null +- `endpoint_key` text not null +- `status` enum: `received | processed | failed | ignored` +- `request_id` text null +- `headers_json` jsonb null +- `body_json` jsonb null +- `received_at` timestamptz not null +- `handled_at` timestamptz null +- `response_code` int null +- `error` text null + +Indexes: + +- `(plugin_id, received_at desc)` +- `(plugin_id, endpoint_key, received_at desc)` + +### `plugin_entities` (optional but recommended) + +- `id` uuid pk +- `plugin_id` uuid fk `plugins.id` not null +- `entity_type` text not null +- `scope_kind` enum not null +- `scope_id` uuid/text null +- `external_id` text null +- `title` text null +- `status` text null +- `data_json` jsonb not null +- `created_at` timestamptz not null +- `updated_at` timestamptz not null + +Indexes: + +- `(plugin_id, entity_type, external_id)` unique when `external_id` is not null +- `(plugin_id, scope_kind, scope_id, entity_type)` + +Use cases: + +- imported Linear issues +- imported GitHub issues +- plugin-owned process records +- plugin-owned external metric bindings + +## 21.4 Activity Log Changes + +The activity log should extend `actor_type` to include `plugin`. + +New actor enum: + +- `agent` +- `user` +- `system` +- `plugin` + +Plugin-originated mutations should write: + +- `actor_type = plugin` +- `actor_id = ` + +## 21.5 Plugin Migrations + +The first plugin system does not allow arbitrary third-party migrations. + +Later, if custom tables become necessary, the system may add a trusted-module-only migration path. + +## 22. Secrets + +Plugin config must never persist raw secret values. + +Rules: + +1. Plugin config stores secret refs only. +2. Secret refs resolve through the existing Paperclip secret provider system. +3. Plugin workers receive resolved secrets only at execution time. +4. Secret values must never be written to: + - plugin config JSON + - activity logs + - webhook delivery rows + - error messages + +## 23. Auditing + +All plugin-originated mutating actions must be auditable. + +Minimum requirements: + +- activity log entry for every mutation +- job run history +- webhook delivery history +- plugin health page +- install/upgrade history in `plugins` + +## 24. Operator UX + +## 24.1 Global Settings + +Global plugin settings page must show: + +- installed plugins +- versions +- status +- requested capabilities +- current errors +- install/upgrade/remove actions + +## 24.2 Plugin Settings Page + +Each plugin may expose: + +- config form derived from `instanceConfigSchema` +- health details +- recent job history +- recent webhook history +- capability list + +Route: + +- `/settings/plugins/:pluginId` + +## 24.3 Company-Context Plugin Page + +Each plugin may expose a company-context main page: + +- `/:companyPrefix/plugins/:pluginId` + +This page is where board users do most day-to-day work. + +## 25. Uninstall And Data Lifecycle + +When a plugin is uninstalled, the host must handle plugin-owned data explicitly. + +### 25.1 Uninstall Process + +1. The host sends `shutdown()` to the worker and follows the graceful shutdown policy. +2. The host marks the plugin status `uninstalled` in the `plugins` table (soft delete). +3. Plugin-owned data (`plugin_state`, `plugin_entities`, `plugin_jobs`, `plugin_job_runs`, `plugin_webhook_deliveries`, `plugin_config`) is retained for a configurable grace period (default: 30 days). +4. During the grace period, the operator can reinstall the same plugin and recover its state. +5. After the grace period, the host purges all plugin-owned data for the uninstalled plugin. +6. The operator may force-purge immediately via CLI: `pnpm paperclipai plugin purge `. + +### 25.2 Upgrade Data Considerations + +Plugin upgrades do not automatically migrate plugin state. If a plugin's `value_json` shape changes between versions: + +- The plugin worker is responsible for migrating its own state on first access after upgrade. +- The host does not run plugin-defined schema migrations. +- Plugins should version their state keys or use a schema version field inside `value_json` to detect and handle format changes. + +### 25.3 Upgrade Lifecycle + +When upgrading a plugin: + +1. The host sends `shutdown()` to the old worker. +2. The host waits for the old worker to drain in-flight work (respecting the shutdown deadline). +3. Any in-flight jobs that do not complete within the deadline are marked `cancelled`. +4. The host installs the new version and starts the new worker. +5. If the new version adds capabilities, the plugin enters `upgrade_pending` and the operator must approve before the new worker becomes `ready`. + +### 25.4 Hot Plugin Lifecycle + +Plugin install, uninstall, upgrade, and config changes **must** take effect without restarting the Paperclip server. This is a normative requirement, not optional. + +The architecture already supports this — plugins run as out-of-process workers with dynamic ESM imports, IPC bridges, and host-managed routing tables. This section makes the requirement explicit so implementations do not regress. + +#### 25.4.1 Hot Install + +When a plugin is installed at runtime: + +1. The host resolves and validates the manifest without stopping existing services. +2. The host spawns a new worker process for the plugin. +3. The host registers the plugin's event subscriptions, job schedules, webhook endpoints, and agent tool declarations in the live routing tables. +4. The host loads the plugin's UI bundle path into the extension slot registry so the frontend can discover it on the next navigation or via a live notification. +5. The plugin enters `ready` status (or `upgrade_pending` if capability approval is required). + +No other plugin or host service is interrupted. + +#### 25.4.2 Hot Uninstall + +When a plugin is uninstalled at runtime: + +1. The host sends `shutdown()` and follows the graceful shutdown policy (Section 12.5). +2. The host removes the plugin's event subscriptions, job schedules, webhook endpoints, and agent tool declarations from the live routing tables. +3. The host removes the plugin's UI bundle from the extension slot registry. Any currently mounted plugin UI components are unmounted and replaced with a placeholder or removed entirely. +4. The host marks the plugin `uninstalled` and starts the data retention grace period (Section 25.1). + +No server restart is needed. + +#### 25.4.3 Hot Upgrade + +When a plugin is upgraded at runtime: + +1. The host follows the upgrade lifecycle (Section 25.3) — shut down old worker, start new worker. +2. If the new version changes event subscriptions, job schedules, webhook endpoints, or agent tools, the host atomically swaps the old registrations for the new ones. +3. If the new version ships an updated UI bundle, the host invalidates any cached bundle assets and notifies the frontend to reload plugin UI components. Active users see the updated UI on next navigation or via a live refresh notification. +4. If the manifest `apiVersion` is unchanged and no new capabilities are added, the upgrade completes without operator interaction. + +#### 25.4.4 Hot Config Change + +When an operator updates a plugin's instance config at runtime: + +1. The host writes the new config to `plugin_config`. +2. The host sends a `configChanged` notification to the running worker via IPC. +3. The worker receives the new config through `ctx.config` and applies it without restarting. If the plugin needs to re-initialize connections (e.g. a new API token), it does so internally. +4. If the plugin does not handle `configChanged`, the host restarts the worker process with the new config (graceful shutdown then restart). + +#### 25.4.5 Frontend Cache Invalidation + +The host must version plugin UI bundle URLs (e.g. `/_plugins/:pluginId/ui/:version/*` or content-hash-based paths) so that browser caches do not serve stale bundles after upgrade or reinstall. + +The host should emit a `plugin.ui.updated` event that the frontend listens for to trigger re-import of updated plugin modules without a full page reload. + +#### 25.4.6 Worker Process Management + +The host's plugin process manager must support: + +- starting a worker for a newly installed plugin without affecting other workers +- stopping a worker for an uninstalled plugin without affecting other workers +- replacing a worker during upgrade (stop old, start new) atomically from the routing table's perspective +- restarting a worker after crash without operator intervention (with backoff) + +Each worker process is independent. There is no shared process pool or batch restart mechanism. + +## 26. Plugin Observability + +### 26.1 Logging + +Plugin workers use `ctx.logger` to emit structured logs. The host captures these logs and stores them in a queryable format. + +Log storage rules: + +- Plugin logs are stored in a `plugin_logs` table or appended to a log file under the plugin's data directory. +- Each log entry includes: plugin ID, timestamp, level, message, and optional structured metadata. +- Logs are queryable from the plugin settings page in the UI. +- Logs have a configurable retention period (default: 7 days). +- The host captures `stdout` and `stderr` from the worker process as fallback logs even if the worker does not use `ctx.logger`. + +### 26.2 Health Dashboard + +The plugin settings page must show: + +- current worker status (running, error, stopped) +- uptime since last restart +- recent log entries +- job run history with success/failure rates +- webhook delivery history with success/failure rates +- last health check result and diagnostics +- resource usage if available (memory, CPU) + +### 26.3 Alerting + +The host should emit internal events when plugin health degrades. These use the `plugin.*` namespace (not core domain events) and do not appear in the core activity log: + +- `plugin.health.degraded` — worker reporting errors or failing health checks +- `plugin.health.recovered` — worker recovered from error state +- `plugin.worker.crashed` — worker process exited unexpectedly +- `plugin.worker.restarted` — worker restarted after crash + +These events can be consumed by other plugins (e.g. a notification plugin) or surfaced in the dashboard. + +## 27. Plugin Development And Testing + +### 27.1 `@paperclipai/plugin-test-harness` + +The host should publish a test harness package that plugin authors use for local development and testing. + +The test harness provides: + +- a mock host that implements the full SDK interface (`ctx.config`, `ctx.events`, `ctx.state`, etc.) +- ability to send synthetic events and verify handler responses +- ability to trigger job runs and verify side effects +- ability to simulate `getData` and `performAction` calls as if coming from the UI bridge +- ability to simulate `executeTool` calls as if coming from an agent run +- in-memory state and entity stores for assertions +- configurable capability sets for testing capability denial paths + +Example usage: + +```ts +import { createTestHarness } from "@paperclipai/plugin-test-harness"; +import manifest from "../dist/manifest.js"; +import { register } from "../dist/worker.js"; + +const harness = createTestHarness({ manifest, capabilities: manifest.capabilities }); +await register(harness.ctx); + +// Simulate an event +await harness.emit("issue.created", { issueId: "iss-1", projectId: "proj-1" }); + +// Verify state was written +const state = await harness.state.get({ pluginId: manifest.id, scopeKind: "issue", scopeId: "iss-1", namespace: "sync", stateKey: "external-id" }); +expect(state).toBeDefined(); + +// Simulate a UI data request +const data = await harness.getData("sync-health", { companyId: "comp-1" }); +expect(data.syncedCount).toBeGreaterThan(0); +``` + +### 27.2 Local Plugin Development + +For developing a plugin against a running Paperclip instance: + +- The operator installs the plugin from a local path: `pnpm paperclipai plugin install ./path/to/plugin` +- The host watches the plugin directory for changes and restarts the worker on rebuild. +- `devUiUrl` in plugin config can point to a local Vite dev server for UI hot-reload. +- The plugin settings page shows real-time logs from the worker for debugging. + +### 27.3 Plugin Starter Template + +The host should publish a starter template (`create-paperclip-plugin`) that scaffolds: + +- `package.json` with correct `paperclipPlugin` keys +- manifest with placeholder values +- worker entry with SDK type imports and example event handler +- UI entry with example `DashboardWidget` using bridge hooks +- test file using the test harness +- build configuration (esbuild or similar) for both worker and UI bundles +- `.gitignore` and `tsconfig.json` + +## 28. Example Mappings + +This spec directly supports the following plugin types: + +- `@paperclip/plugin-workspace-files` +- `@paperclip/plugin-terminal` +- `@paperclip/plugin-git` +- `@paperclip/plugin-linear` +- `@paperclip/plugin-github-issues` +- `@paperclip/plugin-grafana` +- `@paperclip/plugin-runtime-processes` +- `@paperclip/plugin-stripe` + +## 29. Compatibility And Versioning + +### 29.1 API Version Rules + +1. Host supports one or more explicit plugin API versions. +2. Plugin manifest declares exactly one `apiVersion`. +3. Host rejects unsupported versions at install time. +4. Plugin upgrades are explicit operator actions. +5. Capability expansion requires explicit operator approval. + +### 29.2 SDK Versioning + +The host publishes a single SDK package for plugin authors: + +- `@paperclipai/plugin-sdk` — the complete plugin SDK + +The package uses subpath exports to separate worker and UI concerns: + +- `@paperclipai/plugin-sdk` — worker-side SDK (context, events, state, tools, logger, `definePlugin`, `z`) +- `@paperclipai/plugin-sdk/ui` — frontend SDK (bridge hooks, shared components, design tokens) + +A single package simplifies dependency management for plugin authors — one dependency, one version, one changelog. The subpath exports keep bundle separation clean: worker code imports from the root, UI code imports from `/ui`. Build tools tree-shake accordingly so the worker bundle does not include React components and the UI bundle does not include worker-only code. + +Versioning rules: + +1. **Semver**: The SDK follows strict semantic versioning. Major version bumps indicate breaking changes to either the worker or UI surface; minor versions add new features backwards-compatibly; patch versions are bug fixes only. +2. **Tied to API version**: Each major SDK version corresponds to exactly one plugin `apiVersion`. When `@paperclipai/plugin-sdk@2.x` ships, it targets `apiVersion: 2`. Plugins built with SDK 1.x continue to declare `apiVersion: 1`. +3. **Host multi-version support**: The host must support at least the current and one previous `apiVersion` simultaneously. This means plugins built against the previous SDK major version continue to work without modification. The host maintains separate IPC protocol handlers for each supported API version. +4. **Minimum SDK version in manifest**: Plugins declare `sdkVersion` in the manifest as a semver range (e.g. `">=1.4.0 <2.0.0"`). The host validates this at install time and warns if the plugin's declared range is outside the host's supported SDK versions. +5. **Deprecation timeline**: When a new `apiVersion` ships, the previous version enters a deprecation period of at least 6 months. During this period: + - The host continues to load plugins targeting the deprecated version. + - The host logs a deprecation warning at plugin startup. + - The plugin settings page shows a banner indicating the plugin should be upgraded. + - After the deprecation period ends, the host may drop support for the old version in a future release. +6. **SDK changelog and migration guides**: Each major SDK release must include a migration guide documenting every breaking change, the new API surface, and a step-by-step upgrade path for plugin authors. +7. **UI surface stability**: Breaking changes to shared UI components (removing a component, changing required props) or design tokens require a major version bump just like worker API changes. The single-package model means both surfaces are versioned together, avoiding drift between worker and UI compatibility. + +### 29.3 Version Compatibility Matrix + +The host should publish a compatibility matrix: + +| Host Version | Supported API Versions | SDK Range | +|---|---|---| +| 1.0 | 1 | 1.x | +| 2.0 | 1, 2 | 1.x, 2.x | +| 3.0 | 2, 3 | 2.x, 3.x | + +This matrix is published in the host docs and queryable via `GET /api/plugins/compatibility`. + +### 29.4 Plugin Author Workflow + +When a new SDK version is released: + +1. Plugin author updates `@paperclipai/plugin-sdk` dependency. +2. Plugin author follows the migration guide to update code. +3. Plugin author updates `apiVersion` and `sdkVersion` in the manifest. +4. Plugin author publishes a new plugin version. +5. Operators upgrade the plugin on their instances. The old version continues to work until explicitly upgraded. + +## 30. Recommended Delivery Order + +## Phase 1 + +- plugin manifest +- install/list/remove/upgrade CLI +- global settings UI +- plugin process manager +- capability enforcement +- `plugins`, `plugin_config`, `plugin_state`, `plugin_jobs`, `plugin_job_runs`, `plugin_webhook_deliveries` +- event bus +- jobs +- webhooks +- settings page +- plugin UI bundle loading, host bridge, and `@paperclipai/plugin-sdk/ui` +- extension slot mounting for pages, tabs, widgets, sidebar entries +- bridge error propagation (`PluginBridgeError`) +- auto-generated settings form from `instanceConfigSchema` +- plugin-contributed agent tools +- plugin-to-plugin events (`plugin..*` namespace) +- event filtering +- graceful shutdown with configurable deadlines +- plugin logging and health dashboard +- `@paperclipai/plugin-test-harness` +- `create-paperclip-plugin` starter template +- uninstall with data retention grace period +- hot plugin lifecycle (install, uninstall, upgrade, config change without server restart) +- SDK versioning with multi-version host support and deprecation policy + +This phase is enough for: + +- Linear +- GitHub Issues +- Grafana +- Stripe +- file browser +- terminal +- git workflow +- process/server tracking + +Workspace plugins (file browser, terminal, git, process tracking) do not require additional host APIs — they resolve workspace paths through `ctx.projects` and handle filesystem, git, PTY, and process operations directly. + +## Phase 2 + +- optional `plugin_entities` +- richer action systems +- trusted-module migration path if truly needed +- iframe-based isolation for untrusted plugin UI bundles +- plugin ecosystem/distribution work + +## 31. Final Design Decision + +Paperclip should not implement a generic in-process hook bag modeled directly after local coding tools. + +Paperclip should implement: + +- trusted platform modules for low-level host integration +- globally installed out-of-process plugins for additive instance-wide capabilities +- plugin-contributed agent tools (namespaced, capability-gated) +- plugin-shipped UI bundles rendered in host extension slots via a typed bridge with structured error propagation +- auto-generated settings UI from config schema, with custom settings pages as an option +- plugin-to-plugin events for cross-plugin coordination +- server-side event filtering for efficient event routing +- plugins own their local tooling logic (filesystem, git, terminal, processes) directly +- generic extension tables for most plugin state +- graceful shutdown, uninstall data lifecycle, and plugin observability +- hot plugin lifecycle — install, uninstall, upgrade, and config changes without server restart +- SDK versioning with multi-version host support and a clear deprecation policy +- test harness and starter template for low authoring friction +- strict preservation of core governance and audit rules + +That is the complete target design for the Paperclip plugin system. diff --git a/doc/plugins/ideas-from-opencode.md b/doc/plugins/ideas-from-opencode.md new file mode 100644 index 00000000..fcef3c62 --- /dev/null +++ b/doc/plugins/ideas-from-opencode.md @@ -0,0 +1,1738 @@ +# Plugin Ideas From OpenCode + +Status: design report, not a V1 commitment + +Paperclip V1 explicitly excludes a plugin framework in [doc/SPEC-implementation.md](../SPEC-implementation.md), but the long-horizon spec says the architecture should leave room for extensions. This report studies the `opencode` plugin system and translates the useful patterns into a Paperclip-shaped design. + +Assumption for this document: Paperclip is a single-tenant operator-controlled instance. Plugin installation should therefore be global across the instance. "Companies" are still first-class Paperclip objects, but they are organizational records, not tenant-isolation boundaries for plugin trust or installation. + +## Executive Summary + +`opencode` has a real plugin system already. It is intentionally low-friction: + +- plugins are plain JS/TS modules +- they load from local directories and npm packages +- they can hook many runtime events +- they can add custom tools +- they can extend provider auth flows +- they run in-process and can mutate runtime behavior directly + +That model works well for a local coding tool. It should not be copied literally into Paperclip. + +The main conclusion is: + +- Paperclip should copy `opencode`'s typed SDK, deterministic loading, low authoring friction, and clear extension surfaces. +- Paperclip should not copy `opencode`'s trust model, project-local plugin loading, "override by name collision" behavior, or arbitrary in-process mutation hooks for core business logic. +- Paperclip should use multiple extension classes instead of one generic plugin bag: + - trusted in-process modules for low-level platform concerns like agent adapters, storage providers, secret providers, and possibly run-log backends + - out-of-process plugins for most third-party integrations like Linear, GitHub Issues, Grafana, Stripe, and schedulers + - plugin-contributed agent tools (namespaced, not override-by-collision) + - plugin-shipped React UI loaded into host extension slots via a typed bridge + - a typed event bus with server-side filtering and plugin-to-plugin events, plus scheduled jobs for automation + +If Paperclip does this well, the examples you listed become straightforward: + +- file browser / terminal / git workflow / child process tracking become workspace plugins that resolve paths from the host and handle OS operations directly +- Linear / GitHub / Grafana / Stripe become connector plugins +- future knowledge base and accounting features can also fit the same model + +## Sources Examined + +I cloned `anomalyco/opencode` and reviewed commit: + +- `a965a062595403a8e0083e85770315d5dc9628ab` + +Primary files reviewed: + +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/plugin/src/index.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/plugin/src/tool.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/plugin/index.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/config/config.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/tool/registry.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/opencode/src/provider/auth.ts` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/web/src/content/docs/plugins.mdx` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/web/src/content/docs/custom-tools.mdx` +- `https://github.com/anomalyco/opencode/blob/a965a062595403a8e0083e85770315d5dc9628ab/packages/web/src/content/docs/ecosystem.mdx` + +Relevant Paperclip files reviewed for current extension seams: + +- [server/src/adapters/registry.ts](../../server/src/adapters/registry.ts) +- [ui/src/adapters/registry.ts](../../ui/src/adapters/registry.ts) +- [server/src/storage/provider-registry.ts](../../server/src/storage/provider-registry.ts) +- [server/src/secrets/provider-registry.ts](../../server/src/secrets/provider-registry.ts) +- [server/src/services/run-log-store.ts](../../server/src/services/run-log-store.ts) +- [server/src/services/activity-log.ts](../../server/src/services/activity-log.ts) +- [doc/SPEC.md](../SPEC.md) +- [doc/SPEC-implementation.md](../SPEC-implementation.md) + +## What OpenCode Actually Implements + +## 1. Plugin authoring API + +`opencode` exposes a small package, `@opencode-ai/plugin`, with a typed `Plugin` function and a typed `tool()` helper. + +Core shape: + +- a plugin is an async function that receives a context object +- the plugin returns a `Hooks` object +- hooks are optional +- plugins can also contribute tools and auth providers + +The plugin init context includes: + +- an SDK client +- current project info +- current directory +- current git worktree +- server URL +- Bun shell access + +That is important: `opencode` gives plugins rich runtime power immediately, not a narrow capability API. + +## 2. Hook model + +The hook set is broad. It includes: + +- event subscription +- config-time hook +- message hooks +- model parameter/header hooks +- permission decision hooks +- shell env injection +- tool execution before/after hooks +- tool definition mutation +- compaction prompt customization +- text completion transforms + +The implementation pattern is very simple: + +- core code constructs an `output` object +- each matching plugin hook runs sequentially +- hooks mutate the `output` +- final mutated output is used by core + +This is elegant and easy to extend. + +It is also extremely powerful. A plugin can change auth headers, model params, permission answers, tool inputs, tool descriptions, and shell environment. + +## 3. Plugin discovery and load order + +`opencode` supports two plugin sources: + +- local files +- npm packages + +Local directories: + +- `~/.config/opencode/plugins/` +- `.opencode/plugins/` + +Npm plugins: + +- listed in config under `plugin: []` + +Load order is deterministic and documented: + +1. global config +2. project config +3. global plugin directory +4. project plugin directory + +Important details: + +- config arrays are concatenated rather than replaced +- duplicate plugin names are deduplicated with higher-precedence entries winning +- internal first-party plugins and default plugins are also loaded through the plugin pipeline + +This gives `opencode` a real precedence model rather than "whatever loaded last by accident." + +## 4. Dependency handling + +For local config/plugin directories, `opencode` will: + +- ensure a `package.json` exists +- inject `@opencode-ai/plugin` +- run `bun install` + +That lets local plugins and local custom tools import dependencies. + +This is excellent for local developer ergonomics. + +It is not a safe default for an operator-controlled control plane server. + +## 5. Error handling + +Plugin load failures do not hard-crash the runtime by default. + +Instead, `opencode`: + +- logs the error +- publishes a session error event +- continues loading other plugins + +That is a good operational pattern. One bad plugin should not brick the entire product unless the operator has explicitly configured it as required. + +## 6. Tools are a first-class extension point + +`opencode` has two ways to add tools: + +- export tools directly from a plugin via `hook.tool` +- define local files in `.opencode/tools/` or global tools directories + +The tool API is strong: + +- tools have descriptions +- tools have Zod schemas +- tool execution gets context like session ID, message ID, directory, and worktree +- tools are merged into the same registry as built-in tools +- tool definitions themselves can be mutated by a `tool.definition` hook + +The most aggressive part of the design: + +- custom tools can override built-in tools by name + +That is very powerful for a local coding assistant. +It is too dangerous for Paperclip core actions. + +However, the concept of plugins contributing agent-usable tools is very valuable for Paperclip — as long as plugin tools are namespaced (cannot shadow core tools) and capability-gated. + +## 7. Auth is also a plugin surface + +`opencode` allows plugins to register auth methods for providers. + +A plugin can contribute: + +- auth method metadata +- prompt flows +- OAuth flows +- API key flows +- request loaders that adapt provider behavior after auth succeeds + +This is a strong pattern worth copying. Integrations often need custom auth UX and token handling. + +## 8. Ecosystem evidence + +The ecosystem page is the best proof that the model is working in practice. +Community plugins already cover: + +- sandbox/workspace systems +- auth providers +- session headers / telemetry +- memory/context features +- scheduling +- notifications +- worktree helpers +- background agents +- monitoring + +That validates the main thesis: a simple typed plugin API can create real ecosystem velocity. + +## What OpenCode Gets Right + +## 1. Separate plugin SDK from host runtime + +This is one of the best parts of the design. + +- plugin authors code against a clean public package +- host internals can evolve behind the loader +- runtime code and plugin code have a clean contract boundary + +Paperclip should absolutely do this. + +## 2. Deterministic loading and precedence + +`opencode` is explicit about: + +- where plugins come from +- how config merges +- what order wins + +Paperclip should copy this discipline. + +## 3. Low-ceremony authoring + +A plugin author does not have to learn a giant framework. + +- export async function +- return hooks +- optionally export tools + +That simplicity matters. + +## 4. Typed tool definitions + +The `tool()` helper is excellent: + +- typed +- schema-based +- easy to document +- easy for runtime validation + +Paperclip should adopt this style for plugin actions, automations, and UI schemas. + +## 5. Built-in features and plugins use similar shapes + +`opencode` uses the same hook system for internal and external plugin-style behavior in several places. +That reduces special cases. + +Paperclip can benefit from that with adapters, secret backends, storage providers, and connector modules. + +## 6. Incremental extension, not giant abstraction upfront + +`opencode` did not design a giant marketplace platform first. +It added concrete extension points that real features needed. + +That is the correct mindset for Paperclip too. + +## What Paperclip Should Not Copy Directly + +## 1. In-process arbitrary plugin code as the default + +`opencode` is basically a local agent runtime, so unsandboxed plugin execution is acceptable for its audience. + +Paperclip is a control plane for an operator-managed instance with company objects. +The risk profile is different: + +- secrets matter +- approval gates matter +- budgets matter +- mutating actions require auditability + +Default third-party plugins should not run with unrestricted in-process access to server memory, DB handles, and secrets. + +## 2. Project-local plugin loading + +`opencode` has project-local plugin folders because the tool is centered around a codebase. + +Paperclip is not project-scoped. It is instance-scoped. +The comparable unit is: + +- instance-installed plugin package + +Paperclip should not auto-load arbitrary code from a workspace repo like `.paperclip/plugins` or project directories. + +## 3. Arbitrary mutation hooks on core business decisions + +Hooks like: + +- `permission.ask` +- `tool.execute.before` +- `chat.headers` +- `shell.env` + +make sense in `opencode`. + +For Paperclip, equivalent hooks into: + +- approval decisions +- issue checkout semantics +- activity log behavior +- budget enforcement + +would be a mistake. + +Core invariants should stay in core code, not become hook-rewritable. + +## 4. Override-by-name collision + +Allowing a plugin to replace a built-in tool by name is useful in a local agent product. + +Paperclip should not allow plugins to silently replace: + +- core routes +- core mutating actions +- auth behaviors +- permission evaluators +- budget logic +- audit logic + +Extension should be additive or explicitly delegated, never accidental shadowing. + +## 5. Auto-install and execute from user config + +`opencode`'s "install dependencies at startup" flow is ergonomic. +For Paperclip it would be risky because it combines: + +- package installation +- code loading +- execution + +inside the control-plane server startup path. + +Paperclip should require an explicit operator install step. + +## Why Paperclip Needs A Different Shape + +The products are solving different problems. + +| Topic | OpenCode | Paperclip | +|---|---|---| +| Primary unit | local project/worktree | single-tenant operator instance with company objects | +| Trust assumption | local power user on own machine | operator managing one trusted Paperclip instance | +| Failure blast radius | local session/runtime | entire company control plane | +| Extension style | mutate runtime behavior freely | preserve governance and auditability | +| UI model | local app can load local behavior | board UI must stay coherent and safe | +| Security model | host-trusted local plugins | needs capability boundaries and auditability | + +That means Paperclip should borrow the good ideas from `opencode` but use a stricter architecture. + +## Paperclip Already Has Useful Pre-Plugin Seams + +Paperclip has several extension-like seams already: + +- server adapter registry: [server/src/adapters/registry.ts](../../server/src/adapters/registry.ts) +- UI adapter registry: [ui/src/adapters/registry.ts](../../ui/src/adapters/registry.ts) +- storage provider registry: [server/src/storage/provider-registry.ts](../../server/src/storage/provider-registry.ts) +- secret provider registry: [server/src/secrets/provider-registry.ts](../../server/src/secrets/provider-registry.ts) +- pluggable run-log store seam: [server/src/services/run-log-store.ts](../../server/src/services/run-log-store.ts) +- activity log and live event emission: [server/src/services/activity-log.ts](../../server/src/services/activity-log.ts) + +This is good news. +Paperclip does not need to invent extensibility from scratch. +It needs to unify and harden existing seams. + +## Recommended Paperclip Plugin Model + +## 1. Use multiple extension classes + +Do not create one giant `hooks` object for everything. + +Use distinct plugin classes with different trust models. + +| Extension class | Examples | Runtime model | Trust level | Why | +|---|---|---|---|---| +| Platform module | agent adapters, storage providers, secret providers, run-log backends | in-process | highly trusted | tight integration, performance, low-level APIs | +| Connector plugin | Linear, GitHub Issues, Grafana, Stripe | out-of-process worker or sidecar | medium | external sync, safer isolation, clearer failure boundary | +| Workspace plugin | file browser, terminal, git workflow, child process/server tracking | out-of-process, direct OS access | medium | resolves workspace paths from host, owns filesystem/git/PTY/process logic directly | +| UI contribution | dashboard widgets, settings forms, company panels | plugin-shipped React bundles in host extension slots via bridge | medium | plugins own their rendering; host controls slot placement and bridge access | +| Automation plugin | alerts, schedulers, sync jobs, webhook processors | out-of-process | medium | event-driven automation is a natural plugin fit | + +This split is the most important design recommendation in this report. + +## 2. Keep low-level modules separate from third-party plugins + +Paperclip already has this pattern implicitly: + +- adapters are one thing +- storage providers are another +- secret providers are another + +Keep that separation. + +I would formalize it like this: + +- `module` means trusted code loaded by the host for low-level runtime services +- `plugin` means integration code that talks to Paperclip through a typed plugin protocol and capability model + +This avoids trying to force Stripe, a PTY terminal, and a new agent adapter into the same abstraction. + +## 3. Prefer event-driven extensions over core-logic mutation + +For third-party plugins, the primary API should be: + +- subscribe to typed domain events (with optional server-side filtering) +- emit plugin-namespaced events for cross-plugin communication +- read instance state, including company-bound business records when relevant +- register webhooks +- run scheduled jobs +- contribute tools that agents can use during runs +- write plugin-owned state +- add additive UI surfaces +- invoke explicit Paperclip actions through the API + +Do not make third-party plugins responsible for: + +- deciding whether an approval passes +- intercepting issue checkout semantics +- rewriting activity log behavior +- overriding budget hard-stops + +Those are core invariants. + +## 4. Plugins ship their own UI + +Plugins ship their own React UI as a bundled module inside `dist/ui/`. The host loads plugin components into designated **extension slots** (pages, tabs, widgets, sidebar entries) and provides a **bridge** for the plugin frontend to talk to its own worker backend and to access host context. + +**How it works:** + +1. The plugin's UI exports named components for each slot it fills (e.g. `DashboardWidget`, `IssueDetailTab`, `SettingsPage`). +2. The host mounts the plugin component into the correct slot, passing a bridge object with hooks like `usePluginData(key, params)` and `usePluginAction(key)`. +3. The plugin component fetches data from its own worker via the bridge and renders it however it wants. +4. The host enforces capability gates through the bridge — if the worker doesn't have a capability, the bridge rejects the call. + +**What the host controls:** where plugin components appear, the bridge API, capability enforcement, and shared UI primitives (`@paperclipai/plugin-sdk/ui`) with design tokens and common components. + +**What the plugin controls:** how to render its data, what data to fetch, what actions to expose, and whether to use the host's shared components or build entirely custom UI. + +First version extension slots: + +- dashboard widgets +- settings pages +- detail-page tabs (project, issue, agent, goal, run) +- sidebar entries +- company-context plugin pages + +The host SDK ships shared components (MetricCard, DataTable, StatusBadge, LogView, etc.) for visual consistency, but these are optional. + +Later, if untrusted third-party plugins become common, the host can move to iframe-based isolation without changing the plugin's source code (the bridge API stays the same). + +## 5. Make installation global and keep mappings/config separate + +`opencode` is mostly user-level local config. +Paperclip should treat plugin installation as a global instance-level action. + +Examples: + +- install `@paperclip/plugin-linear` once +- make it available everywhere immediately +- optionally store mappings over Paperclip objects if one company maps to a different Linear team than another + +## 6. Use project workspaces as the primary anchor for local tooling + +Paperclip already has a concrete workspace model for projects: + +- projects expose `workspaces` and `primaryWorkspace` +- the database already has `project_workspaces` +- project routes already support creating, updating, and deleting workspaces +- heartbeat resolution already prefers project workspaces before falling back to task-session or agent-home workspaces + +That means local/runtime plugins should generally anchor themselves to projects first, not invent a parallel workspace model. + +Practical guidance: + +- file browser should browse project workspaces first +- terminal sessions should be launchable from a project workspace +- git should treat the project workspace as the repo root anchor +- dev server and child-process tracking should attach to project workspaces +- issue and agent views can still deep-link into the relevant project workspace context + +In other words: + +- `project` is the business object +- `project_workspace` is the local runtime anchor +- plugins should build on that instead of creating an unrelated workspace model first + +## 7. Let plugins contribute agent tools + +`opencode` makes tools a first-class extension point. This is one of the highest-value surfaces for Paperclip too. + +A Linear plugin should be able to contribute a `search-linear-issues` tool that agents use during runs. A git plugin should contribute `create-branch` and `get-diff`. A file browser plugin should contribute `read-file` and `list-directory`. + +The key constraints: + +- plugin tools are namespaced by plugin ID (e.g. `linear:search-issues`) so they cannot shadow core tools +- plugin tools require the `agent.tools.register` capability +- tool execution goes through the same worker RPC boundary as everything else +- tool results appear in run logs + +This is a natural fit — the plugin already has the SDK context, the external API credentials, and the domain logic. Wrapping that in a tool definition is minimal additional work for the plugin author. + +## 8. Support plugin-to-plugin events + +Plugins should be able to emit custom events that other plugins can subscribe to. For example, the git plugin detects a push and emits `plugin.@paperclip/plugin-git.push-detected`. The GitHub Issues plugin subscribes to that event and updates PR links. + +This avoids plugins needing to coordinate through shared state or external channels. The host routes plugin events through the same event bus with the same delivery semantics as core events. + +Plugin events use a `plugin..*` namespace so they cannot collide with core events. + +## 9. Auto-generate settings UI from config schema + +Plugins that declare an `instanceConfigSchema` should get an auto-generated settings form for free. The host renders text inputs, dropdowns, toggles, arrays, and secret-ref pickers directly from the JSON Schema. + +For plugins that need richer settings UX, they can declare a `settingsPage` extension slot and ship a custom React component. Both approaches coexist. + +This matters because settings forms are boilerplate that every plugin needs. Auto-generating them from the schema that already exists removes a significant chunk of authoring friction. + +## 10. Design for graceful shutdown and upgrade + +The spec should be explicit about what happens when a plugin worker stops — during upgrades, uninstalls, or instance restarts. + +The recommended policy: + +- send `shutdown()` with a configurable deadline (default 10 seconds) +- SIGTERM after deadline, SIGKILL after 5 more seconds +- in-flight jobs marked `cancelled` +- in-flight bridge calls return structured errors to the UI + +For upgrades specifically: the old worker drains, the new worker starts. If the new version adds capabilities, it enters `upgrade_pending` until the operator approves. + +## 11. Define uninstall data lifecycle + +When a plugin is uninstalled, its data (`plugin_state`, `plugin_entities`, `plugin_jobs`, etc.) should be retained for a grace period (default 30 days), not immediately deleted. The operator can reinstall within the grace period and recover state, or force-purge via CLI. + +This matters because accidental uninstalls should not cause irreversible data loss. + +## 12. Invest in plugin observability + +Plugin logs via `ctx.logger` should be stored and queryable from the plugin settings page. The host should also capture raw `stdout`/`stderr` from the worker process as fallback. + +The plugin health dashboard should show: worker status, uptime, recent logs, job success/failure rates, webhook delivery rates, and resource usage. The host should emit internal events (`plugin.health.degraded`, `plugin.worker.crashed`) that other plugins or dashboards can consume. + +This is critical for operators. Without observability, debugging plugin issues requires SSH access and manual log tailing. + +## 13. Ship a test harness and starter template + +A `@paperclipai/plugin-test-harness` package should provide a mock host with in-memory stores, synthetic event emission, and `getData`/`performAction`/`executeTool` simulation. Plugin authors should be able to write unit tests without a running Paperclip instance. + +A `create-paperclip-plugin` CLI should scaffold a working plugin with manifest, worker, UI bundle, test file, and build config. + +Low authoring friction was called out as one of `opencode`'s best qualities. The test harness and starter template are how Paperclip achieves the same. + +## 14. Support hot plugin lifecycle + +Plugin install, uninstall, upgrade, and config changes should take effect without restarting the Paperclip server. This is critical for developer workflow and operator experience. + +The out-of-process worker architecture makes this natural: + +- **Hot install**: spawn a new worker, register its event subscriptions, job schedules, webhook endpoints, and agent tools in live routing tables, load its UI bundle into the extension slot registry. +- **Hot uninstall**: graceful shutdown of the worker, remove all registrations from routing tables, unmount UI components, start data retention grace period. +- **Hot upgrade**: shut down old worker, start new worker, atomically swap routing table entries, invalidate UI bundle cache so the frontend loads the updated bundle. +- **Hot config change**: write new config to `plugin_config`, notify the running worker via IPC (`configChanged`). The worker applies the change without restarting. If it doesn't handle `configChanged`, the host restarts just that worker. + +Frontend cache invalidation uses versioned or content-hashed bundle URLs and a `plugin.ui.updated` event that triggers re-import without a full page reload. + +Each worker process is independent — starting, stopping, or replacing one worker never affects any other plugin or the host itself. + +## 15. Define SDK versioning and compatibility + +`opencode` does not have a formal SDK versioning story because plugins run in-process and are effectively pinned to the current runtime. Paperclip's out-of-process model means plugins may be built against one SDK version and run on a host that has moved forward. This needs explicit rules. + +Recommended approach: + +- **Single SDK package**: `@paperclipai/plugin-sdk` with subpath exports — root for worker code, `/ui` for frontend code. One dependency, one version, one changelog. +- **SDK major version = API version**: `@paperclipai/plugin-sdk@2.x` targets `apiVersion: 2`. Plugins built with SDK 1.x declare `apiVersion: 1` and continue to work. +- **Host multi-version support**: The host supports at least the current and one previous `apiVersion` simultaneously with separate IPC protocol handlers per version. +- **`sdkVersion` in manifest**: Plugins declare a semver range (e.g. `">=1.4.0 <2.0.0"`). The host validates this at install time. +- **Deprecation timeline**: Previous API versions get at least 6 months of continued support after a new version ships. The host logs deprecation warnings and shows a banner on the plugin settings page. +- **Migration guides**: Each major SDK release ships with a step-by-step migration guide covering every breaking change. +- **UI surface versioned with worker**: Both worker and UI surfaces are in the same package, so they version together. Breaking changes to shared UI components require a major version bump just like worker API changes. +- **Published compatibility matrix**: The host publishes a matrix of supported API versions and SDK ranges, queryable via API. + +## A Concrete SDK Shape For Paperclip + +An intentionally narrow first pass could look like this: + +```ts +import { definePlugin, z } from "@paperclipai/plugin-sdk"; + +export default definePlugin({ + id: "@paperclip/plugin-linear", + version: "0.1.0", + categories: ["connector", "ui"], + capabilities: [ + "events.subscribe", + "jobs.schedule", + "http.outbound", + "instance.settings.register", + "ui.dashboardWidget.register", + "secrets.read-ref", + ], + instanceConfigSchema: z.object({ + linearBaseUrl: z.string().url().optional(), + companyMappings: z.array( + z.object({ + companyId: z.string(), + teamId: z.string(), + apiTokenSecretRef: z.string(), + }), + ).default([]), + }), + async register(ctx) { + ctx.jobs.register("linear-pull", { cron: "*/5 * * * *" }, async (job) => { + // sync Linear issues into plugin-owned state or explicit Paperclip entities + }); + + // subscribe with optional server-side filter + ctx.events.on("issue.created", { projectId: "proj-1" }, async (event) => { + // only receives issue.created events for project proj-1 + }); + + // subscribe to events from another plugin + ctx.events.on("plugin.@paperclip/plugin-git.push-detected", async (event) => { + // react to the git plugin detecting a push + }); + + // contribute a tool that agents can use during runs + ctx.tools.register("search-linear-issues", { + displayName: "Search Linear Issues", + description: "Search for Linear issues by query", + parametersSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, async (params, runCtx) => { + // search Linear API and return results + return { content: JSON.stringify(results) }; + }); + + // getData is called by the plugin's own UI components via the host bridge + ctx.data.register("sync-health", async ({ companyId }) => { + // return typed JSON that the plugin's DashboardWidget component renders + return { syncedCount: 142, trend: "+12 today", mappings: [...] }; + }); + + ctx.actions.register("resync", async ({ companyId }) => { + // run sync logic + }); + }, +}); +``` + +The plugin's UI bundle (separate from the worker) might look like: + +```tsx +// dist/ui/index.tsx +import { usePluginData, usePluginAction, MetricCard, ErrorBoundary } from "@paperclipai/plugin-sdk/ui"; + +export function DashboardWidget({ context }: PluginWidgetProps) { + const { data, loading, error } = usePluginData("sync-health", { companyId: context.companyId }); + const resync = usePluginAction("resync"); + + if (loading) return ; + if (error) return
Plugin error: {error.message} ({error.code})
; + + return ( + Widget failed to render}> + + + + ); +} +``` + +The important point is not the exact syntax. +The important point is the contract shape: + +- typed manifest +- explicit capabilities +- explicit global config with optional company mappings +- event subscriptions with optional server-side filtering +- plugin-to-plugin events via namespaced event types +- agent tool contributions +- jobs +- plugin-shipped UI that communicates with its worker through the host bridge +- structured error propagation from worker to UI + +## Recommended Core Extension Surfaces + +## 1. Platform module surfaces + +These should stay close to the current registry style. + +Candidates: + +- `registerAgentAdapter()` +- `registerStorageProvider()` +- `registerSecretProvider()` +- `registerRunLogStore()` + +These are trusted platform modules, not casual plugins. + +## 2. Connector plugin surfaces + +These are the best near-term plugin candidates. + +Capabilities: + +- subscribe to domain events +- define scheduled sync jobs +- expose plugin-specific API routes under `/api/plugins/:pluginId/...` +- use company secret refs +- write plugin state +- publish dashboard data +- log activity through core APIs + +Examples: + +- Linear issue sync +- GitHub issue sync +- Grafana dashboard cards +- Stripe MRR / subscription rollups + +## 3. Workspace-runtime surfaces + +Workspace plugins handle local tooling directly: + +- file browser +- terminal +- git workflow +- child process tracking +- local dev server tracking + +Plugins resolve workspace paths through host APIs (`ctx.projects` provides workspace metadata including `cwd`, `repoUrl`, etc.) and then operate on the filesystem, spawn processes, shell out to `git`, or open PTY sessions using standard Node APIs or any libraries they choose. + +The host does not wrap or proxy these operations. This keeps the core lean — no need to maintain a parallel API surface for every OS-level operation a plugin might need. Plugins own their own implementations. + +## Governance And Safety Requirements + +Any Paperclip plugin system has to preserve core control-plane invariants from the repo docs. + +That means: + +- plugin install is global to the instance +- "companies" remain business objects in the API and data model, not tenant boundaries +- approval gates remain core-owned +- budget hard-stops remain core-owned +- mutating actions are activity-logged +- secrets remain ref-based and redacted in logs + +I would require the following for every plugin: + +## 1. Capability declaration + +Every plugin declares a static capability set such as: + +- `companies.read` +- `issues.read` +- `issues.write` +- `events.subscribe` +- `events.emit` +- `jobs.schedule` +- `http.outbound` +- `webhooks.receive` +- `assets.read` +- `assets.write` +- `secrets.read-ref` +- `agent.tools.register` +- `plugin.state.read` +- `plugin.state.write` + +The board/operator sees this before installation. + +## 2. Global installation + +A plugin is installed once and becomes available across the instance. +If it needs mappings over specific Paperclip objects, those are plugin data, not enable/disable boundaries. + +## 3. Activity logging + +Plugin-originated mutations should flow through the same activity log mechanism, with a dedicated `plugin` actor type: + +- `actor_type = plugin` +- `actor_id = ` (e.g. `@paperclip/plugin-linear`) + +## 4. Health and failure reporting + +Each plugin should expose: + +- enabled/disabled state +- last successful run +- last error +- recent webhook/job history + +One broken plugin must not break the rest of the company. + +## 5. Secret handling + +Plugins should receive secret refs, not raw secret values in config persistence. +Resolution should go through the existing secret provider abstraction. + +## 6. Resource limits + +Plugins should have: + +- timeout limits +- concurrency limits +- retry policies +- optional per-plugin budgets + +This matters especially for sync connectors and workspace plugins. + +## Data Model Additions To Consider + +I would avoid "arbitrary third-party plugin-defined SQL migrations" in the first version. +That is too much power too early. + +The right mental model is: + +- reuse core tables when the data is clearly part of Paperclip itself +- use generic extension tables for most plugin-owned state +- only allow plugin-specific tables later, and only for trusted platform modules or a tightly controlled migration workflow + +## Recommended Postgres Strategy For Extensions + +### 1. Core tables stay core + +If a concept is becoming part of Paperclip's actual product model, it should get a normal first-party table. + +Examples: + +- `project_workspaces` is already a core table because project workspaces are now part of Paperclip itself +- if a future "project git state" becomes a core feature rather than plugin-owned metadata, that should also be a first-party table + +### 2. Most plugins should start in generic extension tables + +For most plugins, the host should provide a few generic persistence tables and the plugin stores namespaced records there. + +This keeps the system manageable: + +- simpler migrations +- simpler backup/restore +- simpler portability story +- easier operator review +- fewer chances for plugin schema drift to break the instance + +### 3. Scope plugin data to Paperclip objects before adding custom schemas + +A lot of plugin data naturally hangs off existing Paperclip objects: + +- project workspace plugin state should often scope to `project` or `project_workspace` +- issue sync state should scope to `issue` +- metrics widgets may scope to `company`, `project`, or `goal` +- process tracking may scope to `project_workspace`, `agent`, or `run` + +That gives a good default keying model before introducing custom tables. + +### 4. Add trusted module migrations later, not arbitrary plugin migrations now + +If Paperclip eventually needs extension-owned tables, I would only allow that for: + +- trusted first-party packages +- trusted platform modules +- maybe explicitly installed admin-reviewed plugins with pinned versions + +I would not let random third-party plugins run free-form schema migrations on startup. + +Instead, add a controlled mechanism later if it becomes necessary. + +## Suggested baseline extension tables + +## 1. `plugins` + +Instance-level installation record. + +Suggested fields: + +- `id` +- `package_name` +- `version` +- `categories` +- `manifest_json` +- `installed_at` +- `status` + +## 2. `plugin_config` + +Instance-level plugin config. + +Suggested fields: + +- `id` +- `plugin_id` +- `config_json` +- `created_at` +- `updated_at` +- `last_error` + +## 3. `plugin_state` + +Generic key/value state for plugins. + +Suggested fields: + +- `id` +- `plugin_id` +- `scope_kind` (`instance | company | project | project_workspace | agent | issue | goal | run`) +- `scope_id` nullable +- `namespace` +- `state_key` +- `value_json` +- `updated_at` + +This is enough for many connectors before allowing custom tables. + +Examples: + +- Linear external IDs keyed by `issue` +- GitHub sync cursors keyed by `project` +- file browser preferences keyed by `project_workspace` +- git branch metadata keyed by `project_workspace` +- process metadata keyed by `project_workspace` or `run` + +## 4. `plugin_jobs` + +Scheduled job and run tracking. + +Suggested fields: + +- `id` +- `plugin_id` +- `scope_kind` nullable +- `scope_id` nullable +- `job_key` +- `status` +- `last_started_at` +- `last_finished_at` +- `last_error` + +## 5. `plugin_webhook_deliveries` + +If plugins expose webhooks, delivery history is worth storing. + +Suggested fields: + +- `id` +- `plugin_id` +- `scope_kind` nullable +- `scope_id` nullable +- `endpoint_key` +- `status` +- `received_at` +- `response_code` +- `error` + +## 6. Maybe later: `plugin_entities` + +If generic plugin state becomes too limiting, add a structured, queryable entity table for connector records before allowing arbitrary plugin migrations. + +Suggested fields: + +- `id` +- `plugin_id` +- `entity_type` +- `scope_kind` +- `scope_id` +- `external_id` +- `title` +- `status` +- `data_json` +- `updated_at` + +This is a useful middle ground: + +- much more queryable than opaque key/value state +- still avoids letting every plugin create its own relational schema immediately + +## How The Requested Examples Map To This Model + +| Use case | Best fit | Host primitives needed | Notes | +|---|---|---|---| +| File browser | workspace plugin | project workspace metadata | plugin owns filesystem ops directly | +| Terminal | workspace plugin | project workspace metadata | plugin spawns PTY sessions directly | +| Git workflow | workspace plugin | project workspace metadata | plugin shells out to git directly | +| Linear issue tracking | connector plugin | jobs, webhooks, secret refs, issue sync API | very strong plugin candidate | +| GitHub issue tracking | connector plugin | jobs, webhooks, secret refs | very strong plugin candidate | +| Grafana metrics | connector plugin + dashboard widget | outbound HTTP | probably read-only first | +| Child process/server tracking | workspace plugin | project workspace metadata | plugin manages processes directly | +| Stripe revenue tracking | connector plugin | secret refs, scheduled sync, company metrics API | strong plugin candidate | + +# Plugin Examples + +## Workspace File Browser + +Package idea: `@paperclip/plugin-workspace-files` + +This plugin lets the board inspect project workspaces, agent workspaces, generated artifacts, and issue-related files without dropping to the shell. It is useful for: + +- browsing files inside project workspaces +- debugging what an agent changed +- reviewing generated outputs before approval +- attaching files from a workspace to issues +- understanding repo layout for a company +- inspecting agent home workspaces in local-trusted mode + +### UX + +- Settings page: `/settings/plugins/workspace-files` +- Main page: `/:companyPrefix/plugins/workspace-files` +- Project tab: `/:companyPrefix/projects/:projectId?tab=files` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=files` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=workspace` + +Main screens and interactions: + +- Plugin settings: + - choose whether the plugin defaults to `project.primaryWorkspace` + - choose which project workspaces are visible + - choose whether file writes are allowed or read-only + - choose whether hidden files are visible +- Main explorer page: + - project picker at the top + - workspace picker scoped to the selected project's `workspaces` + - tree view on the left + - file preview pane on the right + - search box for filename/path search + - actions: copy path, download file, attach to issue, open diff +- Project tab: + - opens directly into the project's primary workspace + - lets the board switch among all project workspaces + - shows workspace metadata like `cwd`, `repoUrl`, and `repoRef` +- Issue tab: + - resolves the issue's project and opens that project's workspace context + - shows files linked to the issue + - lets the board pull files from the project workspace into issue attachments + - shows the path and last modified info for each linked file +- Agent tab: + - shows the agent's current resolved workspace + - if the run is attached to a project, links back to the project workspace view + - lets the board inspect files the agent is currently touching + +Core workflows: + +- Board opens a project and browses its primary workspace files. +- Board switches from one project workspace to another when a project has multiple checkouts or repo references. +- Board opens an issue, attaches a generated artifact from the file browser, and leaves a review comment. +- Board opens an agent detail page to inspect the exact files behind a failing run. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` for `project`, `issue`, and `agent` +- `projects.read` +- `project.workspaces.read` +- optional `assets.write` +- `activity.log.write` + +The plugin resolves workspace paths through `ctx.projects` and handles all filesystem operations (read, write, stat, search, list directory) directly using Node APIs. + +Optional event subscriptions: + +- `events.subscribe(agent.run.started)` +- `events.subscribe(agent.run.finished)` +- `events.subscribe(issue.attachment.created)` + +## Workspace Terminal + +Package idea: `@paperclip/plugin-terminal` + +This plugin gives the board a controlled terminal UI for project workspaces and agent workspaces. It is useful for: + +- debugging stuck runs +- verifying environment state +- running targeted manual commands +- watching long-running commands +- pairing a human operator with an agent workflow + +### UX + +- Settings page: `/settings/plugins/terminal` +- Main page: `/:companyPrefix/plugins/terminal` +- Project tab: `/:companyPrefix/projects/:projectId?tab=terminal` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=terminal` +- Optional run tab: `/:companyPrefix/agents/:agentId/runs/:runId?tab=terminal` + +Main screens and interactions: + +- Plugin settings: + - allowed shells and shell policy + - whether commands are read-only, free-form, or allow-listed + - whether terminals require an explicit operator confirmation before launch + - whether new terminal sessions default to the project's primary workspace +- Terminal home page: + - list of active terminal sessions + - button to open a new session + - project picker, then workspace picker from that project's workspaces + - optional agent association + - terminal panel with input, resize, and reconnect support + - controls: interrupt, kill, clear, save transcript +- Project terminal tab: + - opens a session already scoped to the project's primary workspace + - lets the board switch among the project's configured workspaces + - shows recent commands and related process/server state for that project +- Agent terminal tab: + - opens a session already scoped to the agent's workspace + - shows recent related runs and commands +- Run terminal tab: + - lets the board inspect the environment around a specific failed run + +Core workflows: + +- Board opens a terminal against an agent workspace to reproduce a failing command. +- Board opens a project page and launches a terminal directly in that project's primary workspace. +- Board watches a long-running dev server or test command from the terminal page. +- Board kills or interrupts a runaway process from the same UI. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` for `project`, `agent`, and `run` +- `projects.read` +- `project.workspaces.read` +- `activity.log.write` + +The plugin resolves workspace paths through `ctx.projects` and handles PTY session management (open, input, resize, terminate, subscribe) directly using Node PTY libraries. + +Optional event subscriptions: + +- `events.subscribe(agent.run.started)` +- `events.subscribe(agent.run.failed)` +- `events.subscribe(agent.run.cancelled)` + +## Git Workflow + +Package idea: `@paperclip/plugin-git` + +This plugin adds repo-aware workflow tooling around issues and workspaces. It is useful for: + +- branch creation tied to issues +- quick diff review +- commit and worktree visibility +- PR preparation +- treating the project's primary workspace as the canonical repo anchor +- seeing whether an agent's workspace is clean or dirty + +### UX + +- Settings page: `/settings/plugins/git` +- Main page: `/:companyPrefix/plugins/git` +- Project tab: `/:companyPrefix/projects/:projectId?tab=git` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=git` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=git` + +Main screens and interactions: + +- Plugin settings: + - branch naming template + - optional remote provider token secret ref + - whether write actions are enabled or read-only + - whether the plugin always uses `project.primaryWorkspace` unless a different project workspace is chosen +- Git overview page: + - project picker and workspace picker + - current branch + - ahead/behind status + - dirty files summary + - recent commits + - active worktrees + - actions: refresh, create branch, create worktree, stage all, commit, open diff +- Project tab: + - opens in the project's primary workspace + - shows workspace metadata and repo binding (`cwd`, `repoUrl`, `repoRef`) + - shows branch, diff, and commit history for that project workspace +- Issue tab: + - resolves the issue's project and uses that project's workspace context + - "create branch from issue" action + - diff view scoped to the project's selected workspace + - link branch/worktree metadata to the issue +- Agent tab: + - shows the agent's branch, worktree, and dirty state + - shows recent commits produced by that agent + - if the agent is working inside a project workspace, links back to the project git tab + +Core workflows: + +- Board creates a branch from an issue and ties it to the project's primary workspace. +- Board opens a project page and reviews the diff for that project's workspace without leaving Paperclip. +- Board reviews the diff after a run without leaving Paperclip. +- Board opens a worktree list to understand parallel branches across agents. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.detailTab.register` for `project`, `issue`, and `agent` +- `ui.action.register` +- `projects.read` +- `project.workspaces.read` +- optional `agent.tools.register` (e.g. `create-branch`, `get-diff`, `get-status`) +- optional `events.emit` (e.g. `plugin.@paperclip/plugin-git.push-detected`) +- `activity.log.write` + +The plugin resolves workspace paths through `ctx.projects` and handles all git operations (status, diff, log, branch create, commit, worktree create, push) directly using git CLI or a git library. + +Optional event subscriptions: + +- `events.subscribe(issue.created)` +- `events.subscribe(issue.updated)` +- `events.subscribe(agent.run.finished)` + +The git plugin can emit `plugin.@paperclip/plugin-git.push-detected` events that other plugins (e.g. GitHub Issues) subscribe to for cross-plugin coordination. + +Note: GitHub/GitLab PR creation should likely live in a separate connector plugin rather than overloading the local git plugin. + +## Linear Issue Tracking + +Package idea: `@paperclip/plugin-linear` + +This plugin syncs Paperclip work with Linear. It is useful for: + +- importing backlog from Linear +- linking Paperclip issues to Linear issues +- syncing status, comments, and assignees +- mapping company goals/projects to external product planning +- giving board operators a single place to see sync health + +### UX + +- Settings page: `/settings/plugins/linear` +- Main page: `/:companyPrefix/plugins/linear` +- Dashboard widget: `/:companyPrefix/dashboard` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=linear` +- Optional project tab: `/:companyPrefix/projects/:projectId?tab=linear` + +Main screens and interactions: + +- Plugin settings: + - Linear API token secret ref + - workspace/team/project mappings + - status mapping between Paperclip and Linear + - sync direction: import only, export only, bidirectional + - comment sync toggle +- Linear overview page: + - sync health card + - recent sync jobs + - mapped projects and teams + - unresolved conflicts queue + - import actions for teams, projects, and issues +- Issue tab: + - linked Linear issue key and URL + - sync status and last synced time + - actions: link existing, create in Linear, resync now, unlink + - timeline of synced comments/status changes +- Dashboard widget: + - open sync errors + - imported vs linked issues count + - recent webhook/job failures + +Core workflows: + +- Board enables the plugin, maps a Linear team, and imports a backlog into Paperclip. +- Paperclip issue status changes push to Linear and Linear comments arrive back through webhooks. +- Board resolves mapping conflicts from the plugin page instead of silently drifting state. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.dashboardWidget.register` +- `ui.detailTab.register` for `issue` and `project` +- `events.subscribe(issue.created)` +- `events.subscribe(issue.updated)` +- `events.subscribe(issue.comment.created)` +- `events.subscribe(project.updated)` +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- optional `issues.create` +- optional `issues.update` +- optional `issue.comments.create` +- optional `agent.tools.register` (e.g. `search-linear-issues`, `get-linear-issue`) +- `activity.log.write` + +Important constraint: + +- webhook processing should be idempotent and conflict-aware +- external IDs and sync cursors belong in plugin-owned state, not inline on core issue rows in the first version + +## GitHub Issue Tracking + +Package idea: `@paperclip/plugin-github-issues` + +This plugin syncs Paperclip issues with GitHub Issues and optionally links PRs. It is useful for: + +- importing repo backlogs +- mirroring issue status and comments +- linking PRs to Paperclip issues +- tracking cross-repo work from inside one company view +- bridging engineering workflow with Paperclip task governance + +### UX + +- Settings page: `/settings/plugins/github-issues` +- Main page: `/:companyPrefix/plugins/github-issues` +- Dashboard widget: `/:companyPrefix/dashboard` +- Optional issue tab: `/:companyPrefix/issues/:issueId?tab=github` +- Optional project tab: `/:companyPrefix/projects/:projectId?tab=github` + +Main screens and interactions: + +- Plugin settings: + - GitHub App or PAT secret ref + - org/repo mappings + - label/status mapping + - whether PR linking is enabled + - whether new Paperclip issues should create GitHub issues automatically +- GitHub overview page: + - repo mapping list + - sync health and recent webhook events + - import backlog action + - queue of unlinked GitHub issues +- Issue tab: + - linked GitHub issue and optional linked PRs + - actions: create GitHub issue, link existing issue, unlink, resync + - comment/status sync timeline +- Dashboard widget: + - open PRs linked to active Paperclip issues + - webhook failures + - sync lag metrics + +Core workflows: + +- Board imports GitHub Issues for a repo into Paperclip. +- GitHub webhooks update status/comment state in Paperclip. +- A PR is linked back to the Paperclip issue so the board can follow delivery status. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.dashboardWidget.register` +- `ui.detailTab.register` for `issue` and `project` +- `events.subscribe(issue.created)` +- `events.subscribe(issue.updated)` +- `events.subscribe(issue.comment.created)` +- `events.subscribe(plugin.@paperclip/plugin-git.push-detected)` (cross-plugin coordination) +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- optional `issues.create` +- optional `issues.update` +- optional `issue.comments.create` +- `activity.log.write` + +Important constraint: + +- keep "local git state" and "remote GitHub issue state" in separate plugins even if they work together — cross-plugin events handle coordination + +## Grafana Metrics + +Package idea: `@paperclip/plugin-grafana` + +This plugin surfaces external metrics and dashboards inside Paperclip. It is useful for: + +- company KPI visibility +- infrastructure/incident monitoring +- showing deploy, traffic, latency, or revenue charts next to work +- creating Paperclip issues from anomalous metrics + +### UX + +- Settings page: `/settings/plugins/grafana` +- Main page: `/:companyPrefix/plugins/grafana` +- Dashboard widgets: `/:companyPrefix/dashboard` +- Optional goal tab: `/:companyPrefix/goals/:goalId?tab=metrics` + +Main screens and interactions: + +- Plugin settings: + - Grafana base URL + - service account token secret ref + - dashboard and panel mappings + - refresh interval + - optional alert threshold rules +- Dashboard widgets: + - one or more metric cards on the main dashboard + - quick trend view and last refresh time + - link out to Grafana and link in to the full Paperclip plugin page +- Full metrics page: + - selected dashboard panels embedded or proxied + - metric selector + - time range selector + - "create issue from anomaly" action +- Goal tab: + - metric cards relevant to a specific goal or project + +Core workflows: + +- Board sees service degradation or business KPI movement directly on the Paperclip dashboard. +- Board clicks into the full metrics page to inspect the relevant Grafana panels. +- Board creates a Paperclip issue from a threshold breach with a metric snapshot attached. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.dashboardWidget.register` +- `ui.page.register` +- `ui.detailTab.register` for `goal` or `project` +- `jobs.schedule` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- optional `issues.create` +- optional `assets.write` +- `activity.log.write` + +Optional event subscriptions: + +- `events.subscribe(goal.created)` +- `events.subscribe(project.updated)` + +Important constraint: + +- start read-only first +- do not make Grafana alerting logic part of Paperclip core; keep it as additive signal and issue creation + +## Child Process / Server Tracking + +Package idea: `@paperclip/plugin-runtime-processes` + +This plugin tracks long-lived local processes and dev servers started in project workspaces. It is useful for: + +- seeing which agent started which local service +- tracking ports, health, and uptime +- restarting failed dev servers +- exposing process state alongside issue and run state +- making local development workflows visible to the board + +### UX + +- Settings page: `/settings/plugins/runtime-processes` +- Main page: `/:companyPrefix/plugins/runtime-processes` +- Dashboard widget: `/:companyPrefix/dashboard` +- Process detail page: `/:companyPrefix/plugins/runtime-processes/:processId` +- Project tab: `/:companyPrefix/projects/:projectId?tab=processes` +- Optional agent tab: `/:companyPrefix/agents/:agentId?tab=processes` + +Main screens and interactions: + +- Plugin settings: + - whether manual process registration is allowed + - health check behavior + - whether operators can stop/restart processes + - log retention preferences +- Process list page: + - status table with name, command, cwd, owner agent, port, uptime, and health + - filters for running/exited/crashed processes + - actions: inspect, stop, restart, tail logs +- Project tab: + - filters the process list to the project's workspaces + - shows which workspace each process belongs to + - groups processes by project workspace +- Process detail page: + - process metadata + - live log tail + - health check history + - links to associated issue or run +- Agent tab: + - shows processes started by or assigned to that agent + +Core workflows: + +- An agent starts a dev server; the plugin detects and tracks it. +- Board opens a project and immediately sees the processes attached to that project's workspace. +- Board sees a crashed process on the dashboard and restarts it from the plugin page. +- Board attaches process logs to an issue when debugging a failure. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.sidebar.register` +- `ui.page.register` +- `ui.dashboardWidget.register` +- `ui.detailTab.register` for `project` and `agent` +- `projects.read` +- `project.workspaces.read` +- `plugin.state.read` +- `plugin.state.write` +- `activity.log.write` + +The plugin resolves workspace paths through `ctx.projects` and handles process management (register, list, terminate, restart, read logs, health probes) directly using Node APIs. + +Optional event subscriptions: + +- `events.subscribe(agent.run.started)` +- `events.subscribe(agent.run.finished)` + +## Stripe Revenue Tracking + +Package idea: `@paperclip/plugin-stripe` + +This plugin pulls Stripe revenue and subscription data into Paperclip. It is useful for: + +- showing MRR and churn next to company goals +- tracking trials, conversions, and failed payments +- letting the board connect revenue movement to ongoing work +- enabling future financial dashboards beyond token costs + +### UX + +- Settings page: `/settings/plugins/stripe` +- Main page: `/:companyPrefix/plugins/stripe` +- Dashboard widgets: `/:companyPrefix/dashboard` +- Optional company/goal metric tabs if those surfaces exist later + +Main screens and interactions: + +- Plugin settings: + - Stripe secret key secret ref + - account selection if needed + - metric definitions such as MRR treatment and trial handling + - sync interval + - webhook signing secret ref +- Dashboard widgets: + - MRR card + - active subscriptions + - trial-to-paid conversion + - failed payment alerts +- Stripe overview page: + - time series charts + - recent customer/subscription events + - webhook health + - sync history + - action: create issue from billing anomaly + +Core workflows: + +- Board enables the plugin and connects a Stripe account. +- Webhooks and scheduled reconciliation keep plugin state current. +- Revenue widgets appear on the main dashboard and can be linked to company goals. +- Failed payment spikes or churn events can generate Paperclip issues for follow-up. + +### Hooks needed + +Recommended capabilities and extension points: + +- `instance.settings.register` +- `ui.dashboardWidget.register` +- `ui.page.register` +- `jobs.schedule` +- `webhooks.receive` +- `http.outbound` +- `secrets.read-ref` +- `plugin.state.read` +- `plugin.state.write` +- `metrics.write` +- optional `issues.create` +- `activity.log.write` + +Important constraint: + +- Stripe data should stay additive to Paperclip core +- it should not leak into core budgeting logic, which is specifically about model/token spend in V1 + +## Specific Patterns From OpenCode Worth Adopting + +## Adopt + +- separate SDK package from runtime loader +- deterministic load order and precedence +- very small authoring API +- typed schemas for plugin inputs/config/tools +- tools as a first-class plugin extension point (namespaced, not override-by-collision) +- internal extensions using the same registration shapes as external ones when reasonable +- plugin load errors isolated from host startup when possible +- explicit community-facing plugin docs and example templates +- test harness and starter template for low authoring friction +- hot plugin lifecycle without server restart (enabled by out-of-process workers) +- formal SDK versioning with multi-version host support + +## Adapt, not copy + +- local path loading +- dependency auto-install +- hook mutation model +- built-in override behavior +- broad runtime context objects + +## Avoid + +- project-local arbitrary code loading +- implicit trust of npm packages at startup +- plugins overriding core invariants +- unsandboxed in-process execution as the default extension model + +## Suggested Rollout Plan + +## Phase 0: Harden the seams that already exist + +- formalize adapter/storage/secret/run-log registries as "platform modules" +- remove ad-hoc fallback behavior where possible +- document stable registration contracts + +## Phase 1: Add connector plugins first + +This is the highest-value, lowest-risk plugin category. + +Build: + +- plugin manifest +- global install/update lifecycle +- global plugin config and optional company-mapping storage +- secret ref access +- typed domain event subscription +- scheduled jobs +- webhook endpoints +- activity logging helpers +- plugin UI bundle loading, host bridge, `@paperclipai/plugin-sdk/ui` +- extension slot mounting for pages, tabs, widgets, sidebar entries +- auto-generated settings form from `instanceConfigSchema` +- bridge error propagation (`PluginBridgeError`) +- plugin-contributed agent tools +- plugin-to-plugin events (`plugin..*` namespace) +- event filtering (server-side, per-subscription) +- graceful shutdown with configurable deadlines +- plugin logging and health dashboard +- uninstall with data retention grace period +- `@paperclipai/plugin-test-harness` and `create-paperclip-plugin` starter template +- hot plugin lifecycle (install, uninstall, upgrade, config change without server restart) +- SDK versioning with multi-version host support and deprecation policy + +This phase would immediately cover: + +- Linear +- GitHub +- Grafana +- Stripe +- file browser +- terminal +- git workflow +- child process/server tracking + +Workspace plugins do not require additional host APIs — they resolve workspace paths through `ctx.projects` and handle filesystem, git, PTY, and process operations directly. + +## Phase 2: Consider richer UI and plugin packaging + +Only after Phase 1 is stable: + +- iframe-based isolation for untrusted third-party plugin UI bundles +- signed/verified plugin packages +- plugin marketplace +- optional custom plugin storage backends or migrations + +## Recommended Architecture Decision + +If I had to collapse this report into one architectural decision, it would be: + +Paperclip should not implement "an OpenCode-style generic in-process hook system." +Paperclip should implement "a plugin platform with multiple trust tiers": + +- trusted platform modules for low-level runtime integration +- typed out-of-process plugins for instance-wide integrations and automation +- plugin-contributed agent tools (namespaced, capability-gated) +- plugin-shipped UI bundles rendered in host extension slots via a typed bridge with structured error propagation +- plugin-to-plugin events for cross-plugin coordination +- auto-generated settings UI from config schema +- core-owned invariants that plugins can observe and act around, but not replace +- plugin observability, graceful lifecycle management, and a test harness for low authoring friction +- hot plugin lifecycle — no server restart for install, uninstall, upgrade, or config changes +- SDK versioning with multi-version host support and clear deprecation policy + +That gets the upside of `opencode`'s extensibility without importing the wrong threat model. + +## Concrete Next Steps I Would Take In Paperclip + +1. Write a short extension architecture RFC that formalizes the distinction between `platform modules` and `plugins`. +2. Introduce a small plugin manifest type in `packages/shared` and a `plugins` install/config section in the instance config. +3. Build a typed domain event bus around existing activity/live-event patterns, with server-side event filtering and a `plugin.*` namespace for cross-plugin events. Keep core invariants non-hookable. +4. Implement plugin MVP: global install/config, secret refs, jobs, webhooks, plugin UI bundles, extension slots, auto-generated settings forms, bridge error propagation. +5. Add agent tool contributions — plugins register namespaced tools that agents can call during runs. +6. Add plugin observability: structured logging via `ctx.logger`, health dashboard, internal health events. +7. Add graceful shutdown policy and uninstall data lifecycle with retention grace period. +8. Ship `@paperclipai/plugin-test-harness` and `create-paperclip-plugin` starter template. +9. Implement hot plugin lifecycle — install, uninstall, upgrade, and config changes without server restart. +10. Define SDK versioning policy — semver, multi-version host support, deprecation timeline, migration guides, published compatibility matrix. +11. Build workspace plugins (file browser, terminal, git, process tracking) that resolve workspace paths from the host and handle OS-level operations directly. diff --git a/packages/adapters/cursor-local/package.json b/packages/adapters/cursor-local/package.json index 575f9e1b..4ef66052 100644 --- a/packages/adapters/cursor-local/package.json +++ b/packages/adapters/cursor-local/package.json @@ -45,6 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/cursor-local/src/index.ts b/packages/adapters/cursor-local/src/index.ts index 662bc8a7..5845fba8 100644 --- a/packages/adapters/cursor-local/src/index.ts +++ b/packages/adapters/cursor-local/src/index.ts @@ -56,7 +56,7 @@ Use when: - You want structured stream output in run logs via --output-format stream-json Don't use when: -- You need webhook-style external invocation (use openclaw or http) +- You need webhook-style external invocation (use openclaw_gateway or http) - You only need one-shot shell commands (use process) - Cursor Agent CLI is not installed on the machine diff --git a/packages/adapters/cursor-local/tsconfig.json b/packages/adapters/cursor-local/tsconfig.json index 2f355cfe..90314411 100644 --- a/packages/adapters/cursor-local/tsconfig.json +++ b/packages/adapters/cursor-local/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "types": ["node"] }, "include": ["src"] } diff --git a/packages/adapters/openclaw-gateway/README.md b/packages/adapters/openclaw-gateway/README.md index 61ebfaea..ba3edde2 100644 --- a/packages/adapters/openclaw-gateway/README.md +++ b/packages/adapters/openclaw-gateway/README.md @@ -32,12 +32,13 @@ 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 +- when `autoPairOnFirstConnect` is enabled (default), the adapter handles one initial `pairing required` by calling `device.pair.list` + `device.pair.approve` over shared auth, then retries once. ## Session Strategy The adapter supports the same session routing model as HTTP OpenClaw mode: -- `sessionKeyStrategy=fixed|issue|run` +- `sessionKeyStrategy=issue|fixed|run` - `sessionKey` is used when strategy is `fixed` Resolved session key is sent as `agent.sessionKey`. diff --git a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md index 6c804d22..66ff2a4a 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -1,351 +1,109 @@ # 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. +This plan is now **gateway-only**. Paperclip supports OpenClaw through `openclaw_gateway` only. -## 0) Cleanup Before Each Run -Use deterministic reset to avoid stale agents/runs/state. +- Removed path: legacy `openclaw` adapter (`/v1/responses`, `/hooks/*`, SSE/webhook transport switching) +- Supported path: `openclaw_gateway` over WebSocket (`ws://` or `wss://`) -1. OpenClaw Docker cleanup: +## Requirements +1. OpenClaw test image must be stock/clean every run. +2. Onboarding must work from one primary prompt pasted into OpenClaw (optional one follow-up ping allowed). +3. Device auth stays enabled by default; pairing is persisted via `adapterConfig.devicePrivateKeyPem`. +4. Invite/access flow must be secure: +- invite prompt endpoint is board-permission protected +- CEO agent is allowed to invoke the invite prompt endpoint for their own company +5. E2E pass criteria must include the 3 functional task cases. + +## Current Product Flow +1. Board/CEO opens company settings. +2. Click `Generate OpenClaw Invite Prompt`. +3. Paste generated prompt into OpenClaw chat. +4. OpenClaw submits invite acceptance with: +- `adapterType: "openclaw_gateway"` +- `agentDefaultsPayload.url: ws://... | wss://...` +- `agentDefaultsPayload.headers["x-openclaw-token"]` +5. Board approves join request. +6. OpenClaw claims API key and installs/uses Paperclip skill. +7. First task run may trigger pairing approval once; after approval, pairing persists via stored device key. + +## Technical Contract (Gateway) +`agentDefaultsPayload` minimum: +```json +{ + "url": "ws://127.0.0.1:18789", + "headers": { "x-openclaw-token": "" } +} +``` + +Recommended fields: +```json +{ + "paperclipApiUrl": "http://host.docker.internal:3100", + "waitTimeoutMs": 120000, + "sessionKeyStrategy": "issue", + "role": "operator", + "scopes": ["operator.admin"] +} +``` + +Security/pairing defaults: +- `disableDeviceAuth`: default false +- `devicePrivateKeyPem`: generated during join if missing + +## Codex Automation Workflow + +### 0) Reset and boot ```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 +### 1) Start Paperclip ```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. +### 2) Invite + join + approval +- create invite prompt via `POST /api/companies/:companyId/openclaw/invite-prompt` +- paste prompt to OpenClaw +- approve join request +- assert created agent: + - `adapterType == openclaw_gateway` + - token header exists and length >= 16 + - `devicePrivateKeyPem` exists -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). +### 3) Pairing stabilization +- if first run returns `pairing required`, approve pending device in OpenClaw +- rerun task and confirm success +- assert later runs do not require re-pairing for same agent -Note: -- CLI `--api-key` is for agent auth and is not enough for board-only routes in this flow. +### 4) Functional E2E assertions +1. Task assigned to OpenClaw is completed and closed. +2. Task asking OpenClaw to send main-webchat message succeeds (message visible in main chat). +3. In `/new` OpenClaw session, OpenClaw can still create a Paperclip task. -## 3) Resolve CLA Company ID -With board cookie: +## Manual Smoke Checklist +Use [doc/OPENCLAW_ONBOARDING.md](../../../../doc/OPENCLAW_ONBOARDING.md) as the operator runbook. + +## Regression Gates +Required before merge: ```bash -curl -sS -H "Cookie: $PAPERCLIP_COOKIE" http://127.0.0.1:3100/api/companies +pnpm -r typecheck +pnpm test:run +pnpm build ``` -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: +If full suite is too heavy locally, run at least: ```bash -POST /api/companies/$CLA_COMPANY_ID/invites -{ "allowedJoinTypes": "agent" } +pnpm --filter @paperclipai/server test:run -- openclaw-gateway +pnpm --filter @paperclipai/server typecheck +pnpm --filter @paperclipai/ui typecheck +pnpm --filter paperclipai typecheck ``` - -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": "" }, - "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. diff --git a/packages/adapters/openclaw-gateway/src/index.ts b/packages/adapters/openclaw-gateway/src/index.ts index ca16cdc9..2af13f99 100644 --- a/packages/adapters/openclaw-gateway/src/index.ts +++ b/packages/adapters/openclaw-gateway/src/index.ts @@ -12,7 +12,7 @@ Use when: - 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). +- You only expose OpenClaw HTTP endpoints. - Your deployment does not permit outbound WebSocket access from the Paperclip server. Core fields: @@ -33,9 +33,10 @@ 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) +- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true) - paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text Session routing fields: -- sessionKeyStrategy (string, optional): fixed (default), issue, or run +- sessionKeyStrategy (string, optional): issue (default), fixed, or run - sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip) `; diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index 407e455b..c8de510d 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -22,6 +22,7 @@ type GatewayDeviceIdentity = { deviceId: string; publicKeyRawBase64Url: string; privateKeyPem: string; + source: "configured" | "ephemeral"; }; type GatewayRequestFrame = { @@ -56,6 +57,11 @@ type PendingRequest = { timer: ReturnType | null; }; +type GatewayResponseError = Error & { + gatewayCode?: string; + gatewayDetails?: Record; +}; + type GatewayClientOptions = { url: string; headers: Record; @@ -111,9 +117,9 @@ function parseBoolean(value: unknown, fallback = false): boolean { } function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy { - const normalized = asString(value, "fixed").trim().toLowerCase(); - if (normalized === "issue" || normalized === "run") return normalized; - return "fixed"; + const normalized = asString(value, "issue").trim().toLowerCase(); + if (normalized === "fixed" || normalized === "run") return normalized; + return "issue"; } function resolveSessionKey(input: { @@ -163,6 +169,10 @@ function normalizeScopes(value: unknown): string[] { return parsed.length > 0 ? parsed : [...DEFAULT_SCOPES]; } +function uniqueScopes(scopes: string[]): string[] { + return Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean))); +} + function headerMapGetIgnoreCase(headers: Record, key: string): string | null { const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase()); return match ? match[1] : null; @@ -172,6 +182,21 @@ function headerMapHasIgnoreCase(headers: Record, key: string): b return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase()); } +function getGatewayErrorDetails(err: unknown): Record | null { + if (!err || typeof err !== "object") return null; + const candidate = (err as GatewayResponseError).gatewayDetails; + return asRecord(candidate); +} + +function extractPairingRequestId(err: unknown): string | null { + const details = getGatewayErrorDetails(err); + const fromDetails = nonEmpty(details?.requestId); + if (fromDetails) return fromDetails; + const message = err instanceof Error ? err.message : String(err); + const match = message.match(/requestId\s*[:=]\s*([A-Za-z0-9_-]+)/i); + return match?.[1] ?? null; +} + function toAuthorizationHeaderValue(rawToken: string): string { const trimmed = rawToken.trim(); if (!trimmed) return trimmed; @@ -486,6 +511,7 @@ function resolveDeviceIdentity(config: Record): GatewayDeviceId deviceId: crypto.createHash("sha256").update(raw).digest("hex"), publicKeyRawBase64Url: base64UrlEncode(raw), privateKeyPem: configuredPrivateKey, + source: "configured", }; } @@ -497,6 +523,7 @@ function resolveDeviceIdentity(config: Record): GatewayDeviceId deviceId: crypto.createHash("sha256").update(raw).digest("hex"), publicKeyRawBase64Url: base64UrlEncode(raw), privateKeyPem, + source: "ephemeral", }; } @@ -688,7 +715,101 @@ class GatewayWsClient { nonEmpty(errorRecord?.message) ?? nonEmpty(errorRecord?.code) ?? "gateway request failed"; - pending.reject(new Error(message)); + const err = new Error(message) as GatewayResponseError; + const code = nonEmpty(errorRecord?.code); + const details = asRecord(errorRecord?.details); + if (code) err.gatewayCode = code; + if (details) err.gatewayDetails = details; + pending.reject(err); + } +} + +async function autoApproveDevicePairing(params: { + url: string; + headers: Record; + connectTimeoutMs: number; + clientId: string; + clientMode: string; + clientVersion: string; + role: string; + scopes: string[]; + authToken: string | null; + password: string | null; + requestId: string | null; + deviceId: string | null; + onLog: AdapterExecutionContext["onLog"]; +}): Promise<{ ok: true; requestId: string } | { ok: false; reason: string }> { + if (!params.authToken && !params.password) { + return { ok: false, reason: "shared auth token/password is missing" }; + } + + const approvalScopes = uniqueScopes([...params.scopes, "operator.pairing"]); + const client = new GatewayWsClient({ + url: params.url, + headers: params.headers, + onEvent: () => {}, + onLog: params.onLog, + }); + + try { + await params.onLog( + "stdout", + "[openclaw-gateway] pairing required; attempting automatic pairing approval via gateway methods\n", + ); + + await client.connect( + () => ({ + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: params.clientId, + version: params.clientVersion, + platform: process.platform, + mode: params.clientMode, + }, + role: params.role, + scopes: approvalScopes, + auth: { + ...(params.authToken ? { token: params.authToken } : {}), + ...(params.password ? { password: params.password } : {}), + }, + }), + params.connectTimeoutMs, + ); + + let requestId = params.requestId; + if (!requestId) { + const listPayload = await client.request>("device.pair.list", {}, { + timeoutMs: params.connectTimeoutMs, + }); + const pending = Array.isArray(listPayload.pending) ? listPayload.pending : []; + const pendingRecords = pending + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record => Boolean(entry)); + const matching = + (params.deviceId + ? pendingRecords.find((entry) => nonEmpty(entry.deviceId) === params.deviceId) + : null) ?? pendingRecords[pendingRecords.length - 1]; + requestId = nonEmpty(matching?.requestId); + } + + if (!requestId) { + return { ok: false, reason: "no pending device pairing request found" }; + } + + await client.request( + "device.pair.approve", + { requestId }, + { + timeoutMs: params.connectTimeoutMs, + }, + ); + + return { ok: true, requestId }; + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : String(err) }; + } finally { + client.close(); } } @@ -821,63 +942,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise([ctx.runId]); - const assistantChunks: string[] = []; - let lifecycleError: string | null = null; - let latestResultPayload: unknown = null; - - const onEvent = async (frame: GatewayEventFrame) => { - if (frame.event !== "agent") { - if (frame.event === "shutdown") { - await ctx.onLog("stdout", `[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`); - } - return; - } - - const payload = asRecord(frame.payload); - if (!payload) return; - - const runId = nonEmpty(payload.runId); - if (!runId || !trackedRunIds.has(runId)) return; - - const stream = nonEmpty(payload.stream) ?? "unknown"; - const data = asRecord(payload.data) ?? {}; - await ctx.onLog( - "stdout", - `[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`, - ); - - if (stream === "assistant") { - const delta = nonEmpty(data.delta); - const text = nonEmpty(data.text); - if (delta) { - assistantChunks.push(delta); - } else if (text) { - assistantChunks.push(text); - } - return; - } - - if (stream === "error") { - lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; - return; - } - - if (stream === "lifecycle") { - const phase = nonEmpty(data.phase)?.toLowerCase(); - if (phase === "error" || phase === "failed" || phase === "cancelled") { - lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; - } - } - }; - - const client = new GatewayWsClient({ - url: parsedUrl.toString(), - headers, - onEvent, - onLog: ctx.onLog, - }); - if (ctx.onMeta) { await ctx.onMeta({ adapterType: "openclaw_gateway", @@ -910,182 +974,305 @@ export async function execute(ctx: AdapterExecutionContext): Promise([ctx.runId]); + const assistantChunks: string[] = []; + let lifecycleError: string | null = null; + let deviceIdentity: GatewayDeviceIdentity | null = null; - const hello = await client.connect((nonce) => { - const signedAtMs = Date.now(); - const connectParams: Record = { - minProtocol: PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: { - id: clientId, - version: clientVersion, - platform: process.platform, - ...(deviceFamily ? { deviceFamily } : {}), - mode: clientMode, - }, - role, - scopes, - auth: - authToken || password || deviceToken - ? { - ...(authToken ? { token: authToken } : {}), - ...(deviceToken ? { deviceToken } : {}), - ...(password ? { password } : {}), - } - : undefined, - }; - - if (deviceIdentity) { - const payload = buildDeviceAuthPayloadV3({ - deviceId: deviceIdentity.deviceId, - clientId, - clientMode, - role, - scopes, - signedAtMs, - token: authToken, - nonce, - platform: process.platform, - deviceFamily, - }); - connectParams.device = { - id: deviceIdentity.deviceId, - publicKey: deviceIdentity.publicKeyRawBase64Url, - signature: signDevicePayload(deviceIdentity.privateKeyPem, payload), - signedAt: signedAtMs, - nonce, - }; + const onEvent = async (frame: GatewayEventFrame) => { + if (frame.event !== "agent") { + if (frame.event === "shutdown") { + await ctx.onLog( + "stdout", + `[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`, + ); + } + return; } - return connectParams; - }, connectTimeoutMs); - await ctx.onLog( - "stdout", - `[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`, - ); + const payload = asRecord(frame.payload); + if (!payload) return; - const acceptedPayload = await client.request>("agent", agentParams, { - timeoutMs: connectTimeoutMs, + const runId = nonEmpty(payload.runId); + if (!runId || !trackedRunIds.has(runId)) return; + + const stream = nonEmpty(payload.stream) ?? "unknown"; + const data = asRecord(payload.data) ?? {}; + await ctx.onLog( + "stdout", + `[openclaw-gateway:event] run=${runId} stream=${stream} data=${stringifyForLog(data, 8_000)}\n`, + ); + + if (stream === "assistant") { + const delta = nonEmpty(data.delta); + const text = nonEmpty(data.text); + if (delta) { + assistantChunks.push(delta); + } else if (text) { + assistantChunks.push(text); + } + return; + } + + if (stream === "error") { + lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; + return; + } + + if (stream === "lifecycle") { + const phase = nonEmpty(data.phase)?.toLowerCase(); + if (phase === "error" || phase === "failed" || phase === "cancelled") { + lifecycleError = nonEmpty(data.error) ?? nonEmpty(data.message) ?? lifecycleError; + } + } + }; + + const client = new GatewayWsClient({ + url: parsedUrl.toString(), + headers, + onEvent, + onLog: ctx.onLog, }); - latestResultPayload = acceptedPayload; + try { + deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config)); + if (deviceIdentity) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] device auth enabled keySource=${deviceIdentity.source} deviceId=${deviceIdentity.deviceId}\n`, + ); + } else { + await ctx.onLog("stdout", "[openclaw-gateway] device auth disabled\n"); + } - const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? ""; - const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId; - trackedRunIds.add(acceptedRunId); + await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`); - await ctx.onLog( - "stdout", - `[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`, - ); + const hello = await client.connect((nonce) => { + const signedAtMs = Date.now(); + const connectParams: Record = { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: clientId, + version: clientVersion, + platform: process.platform, + ...(deviceFamily ? { deviceFamily } : {}), + mode: clientMode, + }, + role, + scopes, + auth: + authToken || password || deviceToken + ? { + ...(authToken ? { token: authToken } : {}), + ...(deviceToken ? { deviceToken } : {}), + ...(password ? { password } : {}), + } + : undefined, + }; + + if (deviceIdentity) { + const payload = buildDeviceAuthPayloadV3({ + deviceId: deviceIdentity.deviceId, + clientId, + clientMode, + role, + scopes, + signedAtMs, + token: authToken, + nonce, + platform: process.platform, + deviceFamily, + }); + connectParams.device = { + id: deviceIdentity.deviceId, + publicKey: deviceIdentity.publicKeyRawBase64Url, + signature: signDevicePayload(deviceIdentity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce, + }; + } + return connectParams; + }, connectTimeoutMs); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] connected protocol=${asNumber(asRecord(hello)?.protocol, PROTOCOL_VERSION)}\n`, + ); + + const acceptedPayload = await client.request>("agent", agentParams, { + timeoutMs: connectTimeoutMs, + }); + + latestResultPayload = acceptedPayload; + + const acceptedStatus = nonEmpty(acceptedPayload?.status)?.toLowerCase() ?? ""; + const acceptedRunId = nonEmpty(acceptedPayload?.runId) ?? ctx.runId; + trackedRunIds.add(acceptedRunId); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] agent accepted runId=${acceptedRunId} status=${acceptedStatus || "unknown"}\n`, + ); + + if (acceptedStatus === "error") { + const errorMessage = + nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed"; + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage, + errorCode: "openclaw_gateway_agent_error", + resultJson: acceptedPayload, + }; + } + + if (acceptedStatus !== "ok") { + const waitPayload = await client.request>( + "agent.wait", + { runId: acceptedRunId, timeoutMs: waitTimeoutMs }, + { timeoutMs: waitTimeoutMs + connectTimeoutMs }, + ); + + latestResultPayload = waitPayload; + + const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? ""; + if (waitStatus === "timeout") { + return { + exitCode: 1, + signal: null, + timedOut: true, + errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`, + errorCode: "openclaw_gateway_wait_timeout", + resultJson: waitPayload, + }; + } + + if (waitStatus === "error") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: + nonEmpty(waitPayload?.error) ?? + lifecycleError ?? + "OpenClaw gateway run failed", + errorCode: "openclaw_gateway_wait_error", + resultJson: waitPayload, + }; + } + + if (waitStatus && waitStatus !== "ok") { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`, + errorCode: "openclaw_gateway_wait_status_unexpected", + resultJson: waitPayload, + }; + } + } + + const summaryFromEvents = assistantChunks.join("").trim(); + const summaryFromPayload = + extractResultText(asRecord(acceptedPayload?.result)) ?? + extractResultText(acceptedPayload) ?? + extractResultText(asRecord(latestResultPayload)) ?? + null; + const summary = summaryFromEvents || summaryFromPayload || null; + + const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta); + const agentMeta = asRecord(meta?.agentMeta); + const usage = parseUsage(agentMeta?.usage ?? meta?.usage); + const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw"; + const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null; + const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0); + + await ctx.onLog( + "stdout", + `[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`, + ); + + return { + exitCode: 0, + signal: null, + timedOut: false, + provider, + ...(model ? { model } : {}), + ...(usage ? { usage } : {}), + ...(costUsd > 0 ? { costUsd } : {}), + resultJson: asRecord(latestResultPayload), + ...(summary ? { summary } : {}), + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const lower = message.toLowerCase(); + const timedOut = lower.includes("timeout"); + const pairingRequired = lower.includes("pairing required"); + + if ( + pairingRequired && + !disableDeviceAuth && + autoPairOnFirstConnect && + !autoPairAttempted && + (authToken || password) + ) { + autoPairAttempted = true; + const pairResult = await autoApproveDevicePairing({ + url: parsedUrl.toString(), + headers, + connectTimeoutMs, + clientId, + clientMode, + clientVersion, + role, + scopes, + authToken, + password, + requestId: extractPairingRequestId(err), + deviceId: deviceIdentity?.deviceId ?? null, + onLog: ctx.onLog, + }); + if (pairResult.ok) { + await ctx.onLog( + "stdout", + `[openclaw-gateway] auto-approved pairing request ${pairResult.requestId}; retrying\n`, + ); + continue; + } + await ctx.onLog( + "stderr", + `[openclaw-gateway] auto-pairing failed: ${pairResult.reason}\n`, + ); + } + + const detailedMessage = pairingRequired + ? `${message}. Approve the pending device in OpenClaw (for example: openclaw devices approve --latest --url --token ) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.` + : message; + + await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`); - if (acceptedStatus === "error") { - const errorMessage = nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed"; return { exitCode: 1, signal: null, - timedOut: false, - errorMessage, - errorCode: "openclaw_gateway_agent_error", - resultJson: acceptedPayload, + timedOut, + errorMessage: detailedMessage, + errorCode: timedOut + ? "openclaw_gateway_timeout" + : pairingRequired + ? "openclaw_gateway_pairing_required" + : "openclaw_gateway_request_failed", + resultJson: asRecord(latestResultPayload), }; + } finally { + client.close(); } - - if (acceptedStatus !== "ok") { - const waitPayload = await client.request>( - "agent.wait", - { runId: acceptedRunId, timeoutMs: waitTimeoutMs }, - { timeoutMs: waitTimeoutMs + connectTimeoutMs }, - ); - - latestResultPayload = waitPayload; - - const waitStatus = nonEmpty(waitPayload?.status)?.toLowerCase() ?? ""; - if (waitStatus === "timeout") { - return { - exitCode: 1, - signal: null, - timedOut: true, - errorMessage: `OpenClaw gateway run timed out after ${waitTimeoutMs}ms`, - errorCode: "openclaw_gateway_wait_timeout", - resultJson: waitPayload, - }; - } - - if (waitStatus === "error") { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - nonEmpty(waitPayload?.error) ?? - lifecycleError ?? - "OpenClaw gateway run failed", - errorCode: "openclaw_gateway_wait_error", - resultJson: waitPayload, - }; - } - - if (waitStatus && waitStatus !== "ok") { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: `Unexpected OpenClaw gateway agent.wait status: ${waitStatus}`, - errorCode: "openclaw_gateway_wait_status_unexpected", - resultJson: waitPayload, - }; - } - } - - const summaryFromEvents = assistantChunks.join("").trim(); - const summaryFromPayload = - extractResultText(asRecord(acceptedPayload?.result)) ?? - extractResultText(acceptedPayload) ?? - extractResultText(asRecord(latestResultPayload)) ?? - null; - const summary = summaryFromEvents || summaryFromPayload || null; - - const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta); - const agentMeta = asRecord(meta?.agentMeta); - const usage = parseUsage(agentMeta?.usage ?? meta?.usage); - const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw"; - const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null; - const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0); - - await ctx.onLog("stdout", `[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`); - - return { - exitCode: 0, - signal: null, - timedOut: false, - provider, - ...(model ? { model } : {}), - ...(usage ? { usage } : {}), - ...(costUsd > 0 ? { costUsd } : {}), - resultJson: asRecord(latestResultPayload), - ...(summary ? { summary } : {}), - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const lower = message.toLowerCase(); - const timedOut = lower.includes("timeout"); - - await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${message}\n`); - - return { - exitCode: 1, - signal: null, - timedOut, - errorMessage: message, - errorCode: timedOut ? "openclaw_gateway_timeout" : "openclaw_gateway_request_failed", - resultJson: asRecord(latestResultPayload), - }; - } finally { - client.close(); } } diff --git a/packages/adapters/openclaw-gateway/src/ui/build-config.ts b/packages/adapters/openclaw-gateway/src/ui/build-config.ts index fcbbbf4e..6a749f84 100644 --- a/packages/adapters/openclaw-gateway/src/ui/build-config.ts +++ b/packages/adapters/openclaw-gateway/src/ui/build-config.ts @@ -5,8 +5,7 @@ export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record; - payloadTemplate: Record; - wakePayload: WakePayload; - sessionKey: string; - paperclipEnv: Record; - wakeText: string; -}; - -const SENSITIVE_LOG_KEY_PATTERN = - /(^|[_-])(auth|authorization|token|secret|password|api[_-]?key|private[_-]?key)([_-]|$)|^x-openclaw-(auth|token)$/i; - -export function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -export function toAuthorizationHeaderValue(rawToken: string): string { - const trimmed = rawToken.trim(); - if (!trimmed) return trimmed; - return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`; -} - -export function resolvePaperclipApiUrlOverride(value: unknown): string | null { - const raw = nonEmpty(value); - if (!raw) return null; - try { - const parsed = new URL(raw); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; - return parsed.toString(); - } catch { - return null; - } -} - -export function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy { - const normalized = asString(value, "fixed").trim().toLowerCase(); - if (normalized === "issue" || normalized === "run") return normalized; - return "fixed"; -} - -export function resolveSessionKey(input: { - strategy: SessionKeyStrategy; - configuredSessionKey: string | null; - runId: string; - issueId: string | null; -}): string { - const fallback = input.configuredSessionKey ?? "paperclip"; - if (input.strategy === "run") return `paperclip:run:${input.runId}`; - if (input.strategy === "issue" && input.issueId) return `paperclip:issue:${input.issueId}`; - return fallback; -} - -function normalizeUrlPath(pathname: string): string { - const trimmed = pathname.trim().toLowerCase(); - if (!trimmed) return "/"; - return trimmed.endsWith("/") && trimmed !== "/" ? trimmed.slice(0, -1) : trimmed; -} - -function isWakePath(pathname: string): boolean { - const normalized = normalizeUrlPath(pathname); - return normalized === "/hooks/wake" || normalized.endsWith("/hooks/wake"); -} - -function isHookAgentPath(pathname: string): boolean { - const normalized = normalizeUrlPath(pathname); - return normalized === "/hooks/agent" || normalized.endsWith("/hooks/agent"); -} - -function isHookPath(pathname: string): boolean { - const normalized = normalizeUrlPath(pathname); - return ( - normalized === "/hooks" || - normalized.startsWith("/hooks/") || - normalized.endsWith("/hooks") || - normalized.includes("/hooks/") - ); -} - -export function isHookEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - return isHookPath(parsed.pathname); - } catch { - return false; - } -} - -export function isWakeCompatibilityEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - return isWakePath(parsed.pathname); - } catch { - return false; - } -} - -export function isHookAgentEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - return isHookAgentPath(parsed.pathname); - } catch { - return false; - } -} - -export function isOpenResponsesEndpoint(url: string): boolean { - try { - const parsed = new URL(url); - const path = normalizeUrlPath(parsed.pathname); - return path === "/v1/responses" || path.endsWith("/v1/responses"); - } catch { - return false; - } -} - -export function resolveEndpointKind(url: string): OpenClawEndpointKind { - if (isOpenResponsesEndpoint(url)) return "open_responses"; - if (isWakeCompatibilityEndpoint(url)) return "hook_wake"; - if (isHookAgentEndpoint(url)) return "hook_agent"; - return "generic"; -} - -export function deriveHookAgentUrlFromResponses(url: string): string | null { - try { - const parsed = new URL(url); - const path = normalizeUrlPath(parsed.pathname); - if (path === "/v1/responses") { - parsed.pathname = "/hooks/agent"; - return parsed.toString(); - } - if (path.endsWith("/v1/responses")) { - parsed.pathname = `${path.slice(0, -"/v1/responses".length)}/hooks/agent`; - return parsed.toString(); - } - return null; - } catch { - return null; - } -} - -export function toStringRecord(value: unknown): Record { - const parsed = parseObject(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(parsed)) { - if (typeof entry === "string") { - out[key] = entry; - } - } - return out; -} - -function isSensitiveLogKey(key: string): boolean { - return SENSITIVE_LOG_KEY_PATTERN.test(key.trim()); -} - -function sha256Prefix(value: string): string { - return createHash("sha256").update(value).digest("hex").slice(0, 12); -} - -function redactSecretForLog(value: string): string { - return `[redacted len=${value.length} sha256=${sha256Prefix(value)}]`; -} - -function truncateForLog(value: string, maxChars = 320): string { - if (value.length <= maxChars) return value; - return `${value.slice(0, maxChars)}... [truncated ${value.length - maxChars} chars]`; -} - -export function redactForLog(value: unknown, keyPath: string[] = [], depth = 0): unknown { - const currentKey = keyPath[keyPath.length - 1] ?? ""; - if (typeof value === "string") { - if (isSensitiveLogKey(currentKey)) return redactSecretForLog(value); - return truncateForLog(value); - } - if (typeof value === "number" || typeof value === "boolean" || value == null) { - return value; - } - if (Array.isArray(value)) { - if (depth >= 6) return "[array-truncated]"; - const out = value.slice(0, 20).map((entry, index) => redactForLog(entry, [...keyPath, `${index}`], depth + 1)); - if (value.length > 20) out.push(`[+${value.length - 20} more items]`); - return out; - } - if (typeof value === "object") { - if (depth >= 6) return "[object-truncated]"; - const entries = Object.entries(value as Record); - const out: Record = {}; - for (const [key, entry] of entries.slice(0, 80)) { - out[key] = redactForLog(entry, [...keyPath, key], depth + 1); - } - if (entries.length > 80) { - out.__truncated__ = `+${entries.length - 80} keys`; - } - return out; - } - return String(value); -} - -export function stringifyForLog(value: unknown, maxChars: number): string { - const text = JSON.stringify(value); - if (text.length <= maxChars) return text; - return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`; -} - -export function buildWakePayload(ctx: AdapterExecutionContext): WakePayload { - const { runId, agent, context } = ctx; - return { - runId, - agentId: agent.id, - companyId: agent.companyId, - taskId: nonEmpty(context.taskId) ?? nonEmpty(context.issueId), - issueId: nonEmpty(context.issueId), - wakeReason: nonEmpty(context.wakeReason), - wakeCommentId: nonEmpty(context.wakeCommentId) ?? nonEmpty(context.commentId), - approvalId: nonEmpty(context.approvalId), - approvalStatus: nonEmpty(context.approvalStatus), - issueIds: Array.isArray(context.issueIds) - ? context.issueIds.filter( - (value): value is string => typeof value === "string" && value.trim().length > 0, - ) - : [], - }; -} - -export function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: WakePayload): Record { - const paperclipApiUrlOverride = resolvePaperclipApiUrlOverride(ctx.config.paperclipApiUrl); - const paperclipEnv: Record = { - ...buildPaperclipEnv(ctx.agent), - PAPERCLIP_RUN_ID: ctx.runId, - }; - - if (paperclipApiUrlOverride) { - paperclipEnv.PAPERCLIP_API_URL = paperclipApiUrlOverride; - } - if (wakePayload.taskId) paperclipEnv.PAPERCLIP_TASK_ID = wakePayload.taskId; - if (wakePayload.wakeReason) paperclipEnv.PAPERCLIP_WAKE_REASON = wakePayload.wakeReason; - if (wakePayload.wakeCommentId) paperclipEnv.PAPERCLIP_WAKE_COMMENT_ID = wakePayload.wakeCommentId; - if (wakePayload.approvalId) paperclipEnv.PAPERCLIP_APPROVAL_ID = wakePayload.approvalId; - if (wakePayload.approvalStatus) paperclipEnv.PAPERCLIP_APPROVAL_STATUS = wakePayload.approvalStatus; - if (wakePayload.issueIds.length > 0) { - paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = wakePayload.issueIds.join(","); - } - - return paperclipEnv; -} - -export function buildWakeText(payload: WakePayload, paperclipEnv: Record): string { - const claimedApiKeyPath = "~/.openclaw/workspace/paperclip-claimed-api-key.json"; - const orderedKeys = [ - "PAPERCLIP_RUN_ID", - "PAPERCLIP_AGENT_ID", - "PAPERCLIP_COMPANY_ID", - "PAPERCLIP_API_URL", - "PAPERCLIP_TASK_ID", - "PAPERCLIP_WAKE_REASON", - "PAPERCLIP_WAKE_COMMENT_ID", - "PAPERCLIP_APPROVAL_ID", - "PAPERCLIP_APPROVAL_STATUS", - "PAPERCLIP_LINKED_ISSUE_IDS", - ]; - - const envLines: string[] = []; - for (const key of orderedKeys) { - const value = paperclipEnv[key]; - if (!value) continue; - envLines.push(`${key}=${value}`); - } - - const issueIdHint = payload.taskId ?? payload.issueId ?? ""; - const apiBaseHint = paperclipEnv.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=`, - "", - `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 ?? ""}`, - `wake_comment_id=${payload.wakeCommentId ?? ""}`, - `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.", - ]; - return lines.join("\n"); -} - -export function appendWakeText(baseText: string, wakeText: string): string { - const trimmedBase = baseText.trim(); - return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText; -} - -function buildOpenResponsesWakeInputMessage(wakeText: string): Record { - return { - type: "message", - role: "user", - content: [ - { - type: "input_text", - text: wakeText, - }, - ], - }; -} - -export function appendWakeTextToOpenResponsesInput(input: unknown, wakeText: string): unknown { - if (typeof input === "string") { - return appendWakeText(input, wakeText); - } - - if (Array.isArray(input)) { - return [...input, buildOpenResponsesWakeInputMessage(wakeText)]; - } - - if (typeof input === "object" && input !== null) { - const parsed = parseObject(input); - const content = parsed.content; - if (typeof content === "string") { - return { - ...parsed, - content: appendWakeText(content, wakeText), - }; - } - if (Array.isArray(content)) { - return { - ...parsed, - content: [ - ...content, - { - type: "input_text", - text: wakeText, - }, - ], - }; - } - return [parsed, buildOpenResponsesWakeInputMessage(wakeText)]; - } - - return wakeText; -} - -export function isTextRequiredResponse(responseText: string): boolean { - const parsed = parseOpenClawResponse(responseText); - const parsedError = parsed && typeof parsed.error === "string" ? parsed.error : null; - if (parsedError && parsedError.toLowerCase().includes("text required")) { - return true; - } - return responseText.toLowerCase().includes("text required"); -} - -function extractResponseErrorMessage(responseText: string): string { - const parsed = parseOpenClawResponse(responseText); - if (!parsed) return responseText; - - const directError = parsed.error; - if (typeof directError === "string") return directError; - if (directError && typeof directError === "object") { - const nestedMessage = (directError as Record).message; - if (typeof nestedMessage === "string") return nestedMessage; - } - - const directMessage = parsed.message; - if (typeof directMessage === "string") return directMessage; - - return responseText; -} - -export function isWakeCompatibilityRetryableResponse(responseText: string): boolean { - if (isTextRequiredResponse(responseText)) return true; - - const normalized = extractResponseErrorMessage(responseText).toLowerCase(); - const expectsStringInput = - normalized.includes("invalid input") && - normalized.includes("expected string") && - normalized.includes("undefined"); - if (expectsStringInput) return true; - - const missingInputField = - normalized.includes("input") && - (normalized.includes("required") || normalized.includes("missing")); - if (missingInputField) return true; - - return false; -} - -export async function sendJsonRequest(params: { - url: string; - method: string; - headers: Record; - payload: Record; - signal: AbortSignal; -}): Promise { - return fetch(params.url, { - method: params.method, - headers: params.headers, - body: JSON.stringify(params.payload), - signal: params.signal, - }); -} - -export async function readAndLogResponseText(params: { - response: Response; - onLog: AdapterExecutionContext["onLog"]; -}): Promise { - const responseText = await params.response.text(); - if (responseText.trim().length > 0) { - await params.onLog( - "stdout", - `[openclaw] response (${params.response.status}) ${responseText.slice(0, 2000)}\n`, - ); - } else { - await params.onLog("stdout", `[openclaw] response (${params.response.status}) \n`); - } - return responseText; -} - -export function buildExecutionState(ctx: AdapterExecutionContext): OpenClawExecutionState { - const method = asString(ctx.config.method, "POST").trim().toUpperCase() || "POST"; - const timeoutSecRaw = asNumber(ctx.config.timeoutSec, 0); - const timeoutSec = timeoutSecRaw > 0 ? Math.max(1, Math.floor(timeoutSecRaw)) : 0; - const headersConfig = parseObject(ctx.config.headers) as Record; - const payloadTemplate = parseObject(ctx.config.payloadTemplate); - const webhookAuthHeader = nonEmpty(ctx.config.webhookAuthHeader); - const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy); - - const headers: Record = { - "content-type": "application/json", - }; - for (const [key, value] of Object.entries(headersConfig)) { - if (typeof value === "string" && value.trim().length > 0) { - headers[key] = value; - } - } - - const openClawAuthHeader = nonEmpty( - headers["x-openclaw-token"] ?? - headers["X-OpenClaw-Token"] ?? - headers["x-openclaw-auth"] ?? - headers["X-OpenClaw-Auth"], - ); - if (openClawAuthHeader && !headers.authorization && !headers.Authorization) { - headers.authorization = toAuthorizationHeaderValue(openClawAuthHeader); - } - if (webhookAuthHeader && !headers.authorization && !headers.Authorization) { - headers.authorization = webhookAuthHeader; - } - - const wakePayload = buildWakePayload(ctx); - const sessionKey = resolveSessionKey({ - strategy: sessionKeyStrategy, - configuredSessionKey: nonEmpty(ctx.config.sessionKey), - runId: ctx.runId, - issueId: wakePayload.issueId ?? wakePayload.taskId, - }); - - const paperclipEnv = buildPaperclipEnvForWake(ctx, wakePayload); - const wakeText = buildWakeText(wakePayload, paperclipEnv); - - return { - method, - timeoutSec, - headers, - payloadTemplate, - wakePayload, - sessionKey, - paperclipEnv, - wakeText, - }; -} - -export function buildWakeCompatibilityPayload(wakeText: string): Record { - return { - text: wakeText, - mode: "now", - }; -} diff --git a/packages/adapters/openclaw/src/server/execute-sse.ts b/packages/adapters/openclaw/src/server/execute-sse.ts deleted file mode 100644 index 2729f466..00000000 --- a/packages/adapters/openclaw/src/server/execute-sse.ts +++ /dev/null @@ -1,469 +0,0 @@ -import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { - appendWakeTextToOpenResponsesInput, - buildExecutionState, - isOpenResponsesEndpoint, - isTextRequiredResponse, - readAndLogResponseText, - redactForLog, - sendJsonRequest, - stringifyForLog, - toStringRecord, - type OpenClawExecutionState, -} from "./execute-common.js"; -import { parseOpenClawResponse } from "./parse.js"; - -type ConsumedSse = { - eventCount: number; - lastEventType: string | null; - lastData: string | null; - lastPayload: Record | null; - terminal: boolean; - failed: boolean; - errorMessage: string | null; -}; - -function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function inferSseTerminal(input: { - eventType: string; - data: string; - parsedPayload: Record | null; -}): { terminal: boolean; failed: boolean; errorMessage: string | null } { - const normalizedType = input.eventType.trim().toLowerCase(); - const trimmedData = input.data.trim(); - const payload = input.parsedPayload; - const payloadType = nonEmpty(payload?.type)?.toLowerCase() ?? null; - const payloadStatus = nonEmpty(payload?.status)?.toLowerCase() ?? null; - - if (trimmedData === "[DONE]") { - return { terminal: true, failed: false, errorMessage: null }; - } - - const failType = - normalizedType.includes("error") || - normalizedType.includes("failed") || - normalizedType.includes("cancel"); - if (failType) { - return { - terminal: true, - failed: true, - errorMessage: - nonEmpty(payload?.error) ?? - nonEmpty(payload?.message) ?? - (trimmedData.length > 0 ? trimmedData : "OpenClaw SSE error"), - }; - } - - const doneType = - normalizedType === "done" || - normalizedType.endsWith(".completed") || - normalizedType === "completed"; - if (doneType) { - return { terminal: true, failed: false, errorMessage: null }; - } - - if (payloadStatus) { - if ( - payloadStatus === "completed" || - payloadStatus === "succeeded" || - payloadStatus === "done" - ) { - return { terminal: true, failed: false, errorMessage: null }; - } - if ( - payloadStatus === "failed" || - payloadStatus === "cancelled" || - payloadStatus === "error" - ) { - return { - terminal: true, - failed: true, - errorMessage: - nonEmpty(payload?.error) ?? - nonEmpty(payload?.message) ?? - `OpenClaw SSE status ${payloadStatus}`, - }; - } - } - - if (payloadType) { - if (payloadType.endsWith(".completed")) { - return { terminal: true, failed: false, errorMessage: null }; - } - if ( - payloadType.endsWith(".failed") || - payloadType.endsWith(".cancelled") || - payloadType.endsWith(".error") - ) { - return { - terminal: true, - failed: true, - errorMessage: - nonEmpty(payload?.error) ?? - nonEmpty(payload?.message) ?? - `OpenClaw SSE type ${payloadType}`, - }; - } - } - - if (payload?.done === true) { - return { terminal: true, failed: false, errorMessage: null }; - } - - return { terminal: false, failed: false, errorMessage: null }; -} - -async function consumeSseResponse(params: { - response: Response; - onLog: AdapterExecutionContext["onLog"]; -}): Promise { - const reader = params.response.body?.getReader(); - if (!reader) { - throw new Error("OpenClaw SSE response body is missing"); - } - - const decoder = new TextDecoder(); - let buffer = ""; - let eventType = "message"; - let dataLines: string[] = []; - let eventCount = 0; - let lastEventType: string | null = null; - let lastData: string | null = null; - let lastPayload: Record | null = null; - let terminal = false; - let failed = false; - let errorMessage: string | null = null; - - const dispatchEvent = async (): Promise => { - if (dataLines.length === 0) { - eventType = "message"; - return false; - } - - const data = dataLines.join("\n"); - const trimmedData = data.trim(); - const parsedPayload = parseOpenClawResponse(trimmedData); - - eventCount += 1; - lastEventType = eventType; - lastData = data; - if (parsedPayload) lastPayload = parsedPayload; - - const preview = - trimmedData.length > 1000 ? `${trimmedData.slice(0, 1000)}...` : trimmedData; - await params.onLog("stdout", `[openclaw:sse] event=${eventType} data=${preview}\n`); - - const resolution = inferSseTerminal({ - eventType, - data, - parsedPayload, - }); - - dataLines = []; - eventType = "message"; - - if (resolution.terminal) { - terminal = true; - failed = resolution.failed; - errorMessage = resolution.errorMessage; - return true; - } - - return false; - }; - - let shouldStop = false; - while (!shouldStop) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - while (!shouldStop) { - const newlineIndex = buffer.indexOf("\n"); - if (newlineIndex === -1) break; - - let line = buffer.slice(0, newlineIndex); - buffer = buffer.slice(newlineIndex + 1); - if (line.endsWith("\r")) line = line.slice(0, -1); - - if (line.length === 0) { - shouldStop = await dispatchEvent(); - continue; - } - - if (line.startsWith(":")) continue; - - const colonIndex = line.indexOf(":"); - const field = colonIndex === -1 ? line : line.slice(0, colonIndex); - const rawValue = - colonIndex === -1 ? "" : line.slice(colonIndex + 1).replace(/^ /, ""); - - if (field === "event") { - eventType = rawValue || "message"; - } else if (field === "data") { - dataLines.push(rawValue); - } - } - } - - buffer += decoder.decode(); - if (!shouldStop && buffer.trim().length > 0) { - for (const rawLine of buffer.split(/\r?\n/)) { - const line = rawLine.trimEnd(); - if (line.length === 0) { - shouldStop = await dispatchEvent(); - if (shouldStop) break; - continue; - } - if (line.startsWith(":")) continue; - - const colonIndex = line.indexOf(":"); - const field = colonIndex === -1 ? line : line.slice(0, colonIndex); - const rawValue = - colonIndex === -1 ? "" : line.slice(colonIndex + 1).replace(/^ /, ""); - - if (field === "event") { - eventType = rawValue || "message"; - } else if (field === "data") { - dataLines.push(rawValue); - } - } - } - - if (!shouldStop && dataLines.length > 0) { - await dispatchEvent(); - } - - return { - eventCount, - lastEventType, - lastData, - lastPayload, - terminal, - failed, - errorMessage, - }; -} - -function buildSseBody(input: { - url: string; - state: OpenClawExecutionState; - context: AdapterExecutionContext["context"]; - configModel: unknown; -}): { headers: Record; body: Record } { - const { url, state, context, configModel } = input; - const templateText = nonEmpty(state.payloadTemplate.text); - const payloadText = templateText ? `${templateText}\n\n${state.wakeText}` : state.wakeText; - - const isOpenResponses = isOpenResponsesEndpoint(url); - const openResponsesInput = Object.prototype.hasOwnProperty.call(state.payloadTemplate, "input") - ? appendWakeTextToOpenResponsesInput(state.payloadTemplate.input, state.wakeText) - : payloadText; - - const body: Record = isOpenResponses - ? { - ...state.payloadTemplate, - stream: true, - model: - nonEmpty(state.payloadTemplate.model) ?? - nonEmpty(configModel) ?? - "openclaw", - input: openResponsesInput, - metadata: { - ...toStringRecord(state.payloadTemplate.metadata), - ...state.paperclipEnv, - paperclip_session_key: state.sessionKey, - }, - } - : { - ...state.payloadTemplate, - stream: true, - sessionKey: state.sessionKey, - text: payloadText, - paperclip: { - ...state.wakePayload, - sessionKey: state.sessionKey, - streamTransport: "sse", - env: state.paperclipEnv, - context, - }, - }; - - const headers: Record = { - ...state.headers, - accept: "text/event-stream", - }; - - if (isOpenResponses && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) { - headers["x-openclaw-session-key"] = state.sessionKey; - } - - return { headers, body }; -} - -export async function executeSse(ctx: AdapterExecutionContext, url: string): Promise { - const { onLog, onMeta, context } = ctx; - const state = buildExecutionState(ctx); - - if (onMeta) { - await onMeta({ - adapterType: "openclaw", - command: "sse", - commandArgs: [state.method, url], - context, - }); - } - - const { headers, body } = buildSseBody({ - url, - state, - context, - configModel: ctx.config.model, - }); - - const outboundHeaderKeys = Object.keys(headers).sort(); - await onLog( - "stdout", - `[openclaw] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] outbound payload (redacted): ${stringifyForLog(redactForLog(body), 12_000)}\n`, - ); - await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`); - await onLog("stdout", `[openclaw] invoking ${state.method} ${url} (transport=sse)\n`); - - const controller = new AbortController(); - const timeout = state.timeoutSec > 0 ? setTimeout(() => controller.abort(), state.timeoutSec * 1000) : null; - - try { - const response = await sendJsonRequest({ - url, - method: state.method, - headers, - payload: body, - signal: controller.signal, - }); - - if (!response.ok) { - const responseText = await readAndLogResponseText({ response, onLog }); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - isTextRequiredResponse(responseText) - ? "OpenClaw endpoint rejected the payload as text-required." - : `OpenClaw SSE request failed with status ${response.status}`, - errorCode: isTextRequiredResponse(responseText) - ? "openclaw_text_required" - : "openclaw_http_error", - resultJson: { - status: response.status, - statusText: response.statusText, - response: parseOpenClawResponse(responseText) ?? responseText, - }, - }; - } - - const contentType = (response.headers.get("content-type") ?? "").toLowerCase(); - if (!contentType.includes("text/event-stream")) { - const responseText = await readAndLogResponseText({ response, onLog }); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw SSE endpoint did not return text/event-stream", - errorCode: "openclaw_sse_expected_event_stream", - resultJson: { - status: response.status, - statusText: response.statusText, - contentType, - response: parseOpenClawResponse(responseText) ?? responseText, - }, - }; - } - - const consumed = await consumeSseResponse({ response, onLog }); - if (consumed.failed) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: consumed.errorMessage ?? "OpenClaw SSE stream failed", - errorCode: "openclaw_sse_stream_failed", - resultJson: { - eventCount: consumed.eventCount, - terminal: consumed.terminal, - lastEventType: consumed.lastEventType, - lastData: consumed.lastData, - response: consumed.lastPayload ?? consumed.lastData, - }, - }; - } - - if (!consumed.terminal) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw SSE stream closed without a terminal event", - errorCode: "openclaw_sse_stream_incomplete", - resultJson: { - eventCount: consumed.eventCount, - terminal: consumed.terminal, - lastEventType: consumed.lastEventType, - lastData: consumed.lastData, - response: consumed.lastPayload ?? consumed.lastData, - }, - }; - } - - return { - exitCode: 0, - signal: null, - timedOut: false, - provider: "openclaw", - model: null, - summary: `OpenClaw SSE ${state.method} ${url}`, - resultJson: { - eventCount: consumed.eventCount, - terminal: consumed.terminal, - lastEventType: consumed.lastEventType, - lastData: consumed.lastData, - response: consumed.lastPayload ?? consumed.lastData, - }, - }; - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - const timeoutMessage = - state.timeoutSec > 0 - ? `[openclaw] SSE request timed out after ${state.timeoutSec}s\n` - : "[openclaw] SSE request aborted\n"; - await onLog("stderr", timeoutMessage); - return { - exitCode: null, - signal: null, - timedOut: true, - errorMessage: state.timeoutSec > 0 ? `Timed out after ${state.timeoutSec}s` : "Request aborted", - errorCode: "openclaw_sse_timeout", - }; - } - - const message = err instanceof Error ? err.message : String(err); - await onLog("stderr", `[openclaw] request failed: ${message}\n`); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: message, - errorCode: "openclaw_request_failed", - }; - } finally { - if (timeout) clearTimeout(timeout); - } -} diff --git a/packages/adapters/openclaw/src/server/execute-webhook.ts b/packages/adapters/openclaw/src/server/execute-webhook.ts deleted file mode 100644 index a4f55989..00000000 --- a/packages/adapters/openclaw/src/server/execute-webhook.ts +++ /dev/null @@ -1,463 +0,0 @@ -import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { - appendWakeText, - appendWakeTextToOpenResponsesInput, - buildExecutionState, - buildWakeCompatibilityPayload, - deriveHookAgentUrlFromResponses, - isTextRequiredResponse, - isWakeCompatibilityRetryableResponse, - readAndLogResponseText, - redactForLog, - resolveEndpointKind, - sendJsonRequest, - stringifyForLog, - toStringRecord, - type OpenClawEndpointKind, - type OpenClawExecutionState, -} from "./execute-common.js"; -import { parseOpenClawResponse } from "./parse.js"; - -function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -function asBooleanFlag(value: unknown, fallback = false): boolean { - if (typeof value === "boolean") return value; - if (typeof value === "string") { - const normalized = value.trim().toLowerCase(); - if (normalized === "true" || normalized === "1") return true; - if (normalized === "false" || normalized === "0") return false; - } - return fallback; -} - -function normalizeWakeMode(value: unknown): "now" | "next-heartbeat" | null { - if (typeof value !== "string") return null; - const normalized = value.trim().toLowerCase(); - if (normalized === "now" || normalized === "next-heartbeat") return normalized; - return null; -} - -function parseOptionalPositiveInteger(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) { - const normalized = Math.max(1, Math.floor(value)); - return Number.isFinite(normalized) ? normalized : null; - } - if (typeof value === "string" && value.trim().length > 0) { - const parsed = Number.parseInt(value.trim(), 10); - if (Number.isFinite(parsed)) { - const normalized = Math.max(1, Math.floor(parsed)); - return Number.isFinite(normalized) ? normalized : null; - } - } - return null; -} - -function buildOpenResponsesWebhookBody(input: { - state: OpenClawExecutionState; - configModel: unknown; -}): Record { - const { state, configModel } = input; - const templateText = nonEmpty(state.payloadTemplate.text); - const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; - const openResponsesInput = Object.prototype.hasOwnProperty.call(state.payloadTemplate, "input") - ? appendWakeTextToOpenResponsesInput(state.payloadTemplate.input, state.wakeText) - : payloadText; - - return { - ...state.payloadTemplate, - stream: false, - model: - nonEmpty(state.payloadTemplate.model) ?? - nonEmpty(configModel) ?? - "openclaw", - input: openResponsesInput, - metadata: { - ...toStringRecord(state.payloadTemplate.metadata), - ...state.paperclipEnv, - paperclip_session_key: state.sessionKey, - paperclip_stream_transport: "webhook", - }, - }; -} - -function buildHookWakeBody(state: OpenClawExecutionState): Record { - const templateText = nonEmpty(state.payloadTemplate.text) ?? nonEmpty(state.payloadTemplate.message); - const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; - const wakeMode = normalizeWakeMode(state.payloadTemplate.mode ?? state.payloadTemplate.wakeMode) ?? "now"; - - return { - text: payloadText, - mode: wakeMode, - }; -} - -function buildHookAgentBody(input: { - state: OpenClawExecutionState; - includeSessionKey: boolean; -}): Record { - const { state, includeSessionKey } = input; - const templateMessage = nonEmpty(state.payloadTemplate.message) ?? nonEmpty(state.payloadTemplate.text); - const message = templateMessage ? appendWakeText(templateMessage, state.wakeText) : state.wakeText; - const payload: Record = { - message, - }; - - const name = nonEmpty(state.payloadTemplate.name); - if (name) payload.name = name; - - const agentId = nonEmpty(state.payloadTemplate.agentId); - if (agentId) payload.agentId = agentId; - - const wakeMode = normalizeWakeMode(state.payloadTemplate.wakeMode ?? state.payloadTemplate.mode); - if (wakeMode) payload.wakeMode = wakeMode; - - const deliver = state.payloadTemplate.deliver; - if (typeof deliver === "boolean") payload.deliver = deliver; - - const channel = nonEmpty(state.payloadTemplate.channel); - if (channel) payload.channel = channel; - - const to = nonEmpty(state.payloadTemplate.to); - if (to) payload.to = to; - - const model = nonEmpty(state.payloadTemplate.model); - if (model) payload.model = model; - - const thinking = nonEmpty(state.payloadTemplate.thinking); - if (thinking) payload.thinking = thinking; - - const timeoutSeconds = parseOptionalPositiveInteger(state.payloadTemplate.timeoutSeconds); - if (timeoutSeconds != null) payload.timeoutSeconds = timeoutSeconds; - - const explicitSessionKey = nonEmpty(state.payloadTemplate.sessionKey); - if (explicitSessionKey) { - payload.sessionKey = explicitSessionKey; - } else if (includeSessionKey) { - payload.sessionKey = state.sessionKey; - } - - return payload; -} - -function buildLegacyWebhookBody(input: { - state: OpenClawExecutionState; - context: AdapterExecutionContext["context"]; -}): Record { - const { state, context } = input; - const templateText = nonEmpty(state.payloadTemplate.text); - const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; - return { - ...state.payloadTemplate, - stream: false, - sessionKey: state.sessionKey, - text: payloadText, - paperclip: { - ...state.wakePayload, - sessionKey: state.sessionKey, - streamTransport: "webhook", - env: state.paperclipEnv, - context, - }, - }; -} - -function buildWebhookBody(input: { - endpointKind: OpenClawEndpointKind; - state: OpenClawExecutionState; - context: AdapterExecutionContext["context"]; - configModel: unknown; - includeHookSessionKey: boolean; -}): Record { - const { endpointKind, state, context, configModel, includeHookSessionKey } = input; - if (endpointKind === "open_responses") { - return buildOpenResponsesWebhookBody({ state, configModel }); - } - if (endpointKind === "hook_wake") { - return buildHookWakeBody(state); - } - if (endpointKind === "hook_agent") { - return buildHookAgentBody({ state, includeSessionKey: includeHookSessionKey }); - } - - return buildLegacyWebhookBody({ state, context }); -} - -async function sendWebhookRequest(params: { - url: string; - method: string; - headers: Record; - payload: Record; - onLog: AdapterExecutionContext["onLog"]; - signal: AbortSignal; -}): Promise<{ response: Response; responseText: string }> { - const response = await sendJsonRequest({ - url: params.url, - method: params.method, - headers: params.headers, - payload: params.payload, - signal: params.signal, - }); - - const responseText = await readAndLogResponseText({ response, onLog: params.onLog }); - return { response, responseText }; -} - -export async function executeWebhook(ctx: AdapterExecutionContext, url: string): Promise { - const { onLog, onMeta, context } = ctx; - const state = buildExecutionState(ctx); - const originalUrl = url; - const originalEndpointKind = resolveEndpointKind(originalUrl); - let targetUrl = originalUrl; - let endpointKind = resolveEndpointKind(targetUrl); - const remappedFromResponses = originalEndpointKind === "open_responses"; - - // In webhook mode, /v1/responses is legacy wiring. Prefer hooks/agent. - if (remappedFromResponses) { - const rewritten = deriveHookAgentUrlFromResponses(targetUrl); - if (rewritten) { - await onLog( - "stdout", - `[openclaw] webhook transport selected; remapping ${targetUrl} -> ${rewritten}\n`, - ); - targetUrl = rewritten; - endpointKind = resolveEndpointKind(targetUrl); - } - } - - const headers = { ...state.headers }; - if (endpointKind === "open_responses" && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) { - headers["x-openclaw-session-key"] = state.sessionKey; - } - - if (onMeta) { - await onMeta({ - adapterType: "openclaw", - command: "webhook", - commandArgs: [state.method, targetUrl], - context, - }); - } - - const includeHookSessionKey = asBooleanFlag(ctx.config.hookIncludeSessionKey, false); - const webhookBody = buildWebhookBody({ - endpointKind, - state, - context, - configModel: ctx.config.model, - includeHookSessionKey, - }); - const wakeCompatibilityBody = buildWakeCompatibilityPayload(state.wakeText); - const preferWakeCompatibilityBody = endpointKind === "hook_wake"; - const initialBody = webhookBody; - - const outboundHeaderKeys = Object.keys(headers).sort(); - await onLog( - "stdout", - `[openclaw] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] outbound payload (redacted): ${stringifyForLog(redactForLog(initialBody), 12_000)}\n`, - ); - await onLog("stdout", `[openclaw] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`); - await onLog("stdout", `[openclaw] invoking ${state.method} ${targetUrl} (transport=webhook kind=${endpointKind})\n`); - - if (preferWakeCompatibilityBody) { - await onLog("stdout", "[openclaw] using webhook wake payload for /hooks/wake\n"); - } - - const controller = new AbortController(); - const timeout = state.timeoutSec > 0 ? setTimeout(() => controller.abort(), state.timeoutSec * 1000) : null; - - try { - const initialResponse = await sendWebhookRequest({ - url: targetUrl, - method: state.method, - headers, - payload: initialBody, - onLog, - signal: controller.signal, - }); - - let activeResponse = initialResponse; - let activeEndpointKind = endpointKind; - let activeUrl = targetUrl; - let activeHeaders = headers; - let usedLegacyResponsesFallback = false; - - if ( - remappedFromResponses && - targetUrl !== originalUrl && - initialResponse.response.status === 404 - ) { - await onLog( - "stdout", - `[openclaw] remapped hook endpoint returned 404; retrying legacy endpoint ${originalUrl}\n`, - ); - - activeEndpointKind = originalEndpointKind; - activeUrl = originalUrl; - usedLegacyResponsesFallback = true; - const fallbackHeaders = { ...state.headers }; - if ( - activeEndpointKind === "open_responses" && - !fallbackHeaders["x-openclaw-session-key"] && - !fallbackHeaders["X-OpenClaw-Session-Key"] - ) { - fallbackHeaders["x-openclaw-session-key"] = state.sessionKey; - } - - const fallbackBody = buildWebhookBody({ - endpointKind: activeEndpointKind, - state, - context, - configModel: ctx.config.model, - includeHookSessionKey, - }); - - await onLog( - "stdout", - `[openclaw] fallback headers (redacted): ${stringifyForLog(redactForLog(fallbackHeaders), 4_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] fallback payload (redacted): ${stringifyForLog(redactForLog(fallbackBody), 12_000)}\n`, - ); - await onLog( - "stdout", - `[openclaw] invoking fallback ${state.method} ${activeUrl} (transport=webhook kind=${activeEndpointKind})\n`, - ); - - activeResponse = await sendWebhookRequest({ - url: activeUrl, - method: state.method, - headers: fallbackHeaders, - payload: fallbackBody, - onLog, - signal: controller.signal, - }); - activeHeaders = fallbackHeaders; - } - - if (!activeResponse.response.ok) { - const canRetryWithWakeCompatibility = - (activeEndpointKind === "open_responses" || activeEndpointKind === "generic") && - isWakeCompatibilityRetryableResponse(activeResponse.responseText); - - if (canRetryWithWakeCompatibility) { - await onLog( - "stdout", - "[openclaw] endpoint requires text payload; retrying with wake compatibility format\n", - ); - - const retryResponse = await sendWebhookRequest({ - url: activeUrl, - method: state.method, - headers: activeHeaders, - payload: wakeCompatibilityBody, - onLog, - signal: controller.signal, - }); - - if (retryResponse.response.ok) { - return { - exitCode: 0, - signal: null, - timedOut: false, - provider: "openclaw", - model: null, - summary: `OpenClaw webhook ${state.method} ${activeUrl} (wake compatibility)`, - resultJson: { - status: retryResponse.response.status, - statusText: retryResponse.response.statusText, - compatibilityMode: "wake_text", - usedLegacyResponsesFallback, - response: parseOpenClawResponse(retryResponse.responseText) ?? retryResponse.responseText, - }, - }; - } - - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - isTextRequiredResponse(retryResponse.responseText) - ? "OpenClaw endpoint rejected the wake compatibility payload as text-required." - : `OpenClaw webhook failed with status ${retryResponse.response.status}`, - errorCode: isTextRequiredResponse(retryResponse.responseText) - ? "openclaw_text_required" - : "openclaw_http_error", - resultJson: { - status: retryResponse.response.status, - statusText: retryResponse.response.statusText, - compatibilityMode: "wake_text", - response: parseOpenClawResponse(retryResponse.responseText) ?? retryResponse.responseText, - }, - }; - } - - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: - isTextRequiredResponse(activeResponse.responseText) - ? "OpenClaw endpoint rejected the payload as text-required." - : `OpenClaw webhook failed with status ${activeResponse.response.status}`, - errorCode: isTextRequiredResponse(activeResponse.responseText) - ? "openclaw_text_required" - : "openclaw_http_error", - resultJson: { - status: activeResponse.response.status, - statusText: activeResponse.response.statusText, - response: parseOpenClawResponse(activeResponse.responseText) ?? activeResponse.responseText, - }, - }; - } - - return { - exitCode: 0, - signal: null, - timedOut: false, - provider: "openclaw", - model: null, - summary: `OpenClaw webhook ${state.method} ${activeUrl}`, - resultJson: { - status: activeResponse.response.status, - statusText: activeResponse.response.statusText, - usedLegacyResponsesFallback, - response: parseOpenClawResponse(activeResponse.responseText) ?? activeResponse.responseText, - }, - }; - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - const timeoutMessage = - state.timeoutSec > 0 - ? `[openclaw] webhook request timed out after ${state.timeoutSec}s\n` - : "[openclaw] webhook request aborted\n"; - await onLog("stderr", timeoutMessage); - return { - exitCode: null, - signal: null, - timedOut: true, - errorMessage: state.timeoutSec > 0 ? `Timed out after ${state.timeoutSec}s` : "Request aborted", - errorCode: "openclaw_webhook_timeout", - }; - } - - const message = err instanceof Error ? err.message : String(err); - await onLog("stderr", `[openclaw] request failed: ${message}\n`); - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: message, - errorCode: "openclaw_request_failed", - }; - } finally { - if (timeout) clearTimeout(timeout); - } -} diff --git a/packages/adapters/openclaw/src/server/execute.ts b/packages/adapters/openclaw/src/server/execute.ts deleted file mode 100644 index c560a067..00000000 --- a/packages/adapters/openclaw/src/server/execute.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; -import { asString } from "@paperclipai/adapter-utils/server-utils"; -import { isHookEndpoint } from "./execute-common.js"; -import { executeSse } from "./execute-sse.js"; -import { executeWebhook } from "./execute-webhook.js"; - -function normalizeTransport(value: unknown): "sse" | "webhook" | null { - const normalized = asString(value, "sse").trim().toLowerCase(); - if (!normalized || normalized === "sse") return "sse"; - if (normalized === "webhook") return "webhook"; - return null; -} - -export async function execute(ctx: AdapterExecutionContext): Promise { - const url = asString(ctx.config.url, "").trim(); - if (!url) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw adapter missing url", - errorCode: "openclaw_url_missing", - }; - } - - const transportInput = ctx.config.streamTransport ?? ctx.config.transport; - const transport = normalizeTransport(transportInput); - if (!transport) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: `OpenClaw adapter does not support transport: ${String(transportInput)}`, - errorCode: "openclaw_stream_transport_unsupported", - }; - } - - if (transport === "sse" && isHookEndpoint(url)) { - return { - exitCode: 1, - signal: null, - timedOut: false, - errorMessage: "OpenClaw /hooks/* endpoints are not stream-capable. Use webhook transport for hooks.", - errorCode: "openclaw_sse_incompatible_endpoint", - }; - } - - if (transport === "webhook") { - return executeWebhook(ctx, url); - } - - return executeSse(ctx, url); -} diff --git a/packages/adapters/openclaw/src/server/hire-hook.ts b/packages/adapters/openclaw/src/server/hire-hook.ts deleted file mode 100644 index 2b6262c9..00000000 --- a/packages/adapters/openclaw/src/server/hire-hook.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { HireApprovedPayload, HireApprovedHookResult } from "@paperclipai/adapter-utils"; -import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; - -const HIRE_CALLBACK_TIMEOUT_MS = 10_000; - -function nonEmpty(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - -/** - * OpenClaw adapter lifecycle hook: when an agent is approved/hired, POST the payload to a - * configured callback URL so the cloud operator can notify the user (e.g. "you're hired"). - * Best-effort; failures are non-fatal to the approval flow. - */ -export async function onHireApproved( - payload: HireApprovedPayload, - adapterConfig: Record, -): Promise { - const config = parseObject(adapterConfig); - const url = nonEmpty(config.hireApprovedCallbackUrl); - if (!url) { - return { ok: true }; - } - - const method = (asString(config.hireApprovedCallbackMethod, "POST").trim().toUpperCase()) || "POST"; - const authHeader = nonEmpty(config.hireApprovedCallbackAuthHeader) ?? nonEmpty(config.webhookAuthHeader); - - const headers: Record = { - "content-type": "application/json", - }; - if (authHeader && !headers.authorization && !headers.Authorization) { - headers.Authorization = authHeader; - } - const extraHeaders = parseObject(config.hireApprovedCallbackHeaders) as Record; - for (const [key, value] of Object.entries(extraHeaders)) { - if (typeof value === "string" && value.trim().length > 0) { - headers[key] = value; - } - } - - const body = JSON.stringify({ - ...payload, - event: "hire_approved", - }); - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), HIRE_CALLBACK_TIMEOUT_MS); - - try { - const response = await fetch(url, { - method, - headers, - body, - signal: controller.signal, - }); - clearTimeout(timeout); - - if (!response.ok) { - const text = await response.text().catch(() => ""); - return { - ok: false, - error: `HTTP ${response.status} ${response.statusText}`, - detail: { status: response.status, statusText: response.statusText, body: text.slice(0, 500) }, - }; - } - return { ok: true }; - } catch (err) { - clearTimeout(timeout); - const message = err instanceof Error ? err.message : String(err); - const cause = err instanceof Error ? err.cause : undefined; - return { - ok: false, - error: message, - detail: cause != null ? { cause: String(cause) } : undefined, - }; - } -} diff --git a/packages/adapters/openclaw/src/server/index.ts b/packages/adapters/openclaw/src/server/index.ts deleted file mode 100644 index 05c4b355..00000000 --- a/packages/adapters/openclaw/src/server/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { execute } from "./execute.js"; -export { testEnvironment } from "./test.js"; -export { parseOpenClawResponse, isOpenClawUnknownSessionError } from "./parse.js"; -export { onHireApproved } from "./hire-hook.js"; diff --git a/packages/adapters/openclaw/src/server/parse.ts b/packages/adapters/openclaw/src/server/parse.ts deleted file mode 100644 index 5045c202..00000000 --- a/packages/adapters/openclaw/src/server/parse.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function parseOpenClawResponse(text: string): Record | null { - try { - const parsed = JSON.parse(text); - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - return null; - } - return parsed as Record; - } catch { - return null; - } -} - -export function isOpenClawUnknownSessionError(_text: string): boolean { - return false; -} diff --git a/packages/adapters/openclaw/src/server/test.ts b/packages/adapters/openclaw/src/server/test.ts deleted file mode 100644 index ea5bcd85..00000000 --- a/packages/adapters/openclaw/src/server/test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { - AdapterEnvironmentCheck, - AdapterEnvironmentTestContext, - AdapterEnvironmentTestResult, -} from "@paperclipai/adapter-utils"; -import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils"; - -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 isLoopbackHost(hostname: string): boolean { - const value = hostname.trim().toLowerCase(); - return value === "localhost" || value === "127.0.0.1" || value === "::1"; -} - -function normalizeHostname(value: string | null | undefined): string | null { - if (!value) return null; - const trimmed = value.trim(); - if (!trimmed) return null; - if (trimmed.startsWith("[")) { - const end = trimmed.indexOf("]"); - return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase(); - } - const firstColon = trimmed.indexOf(":"); - if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase(); - return trimmed.toLowerCase(); -} - -function isWakePath(pathname: string): boolean { - const value = pathname.trim().toLowerCase(); - return value === "/hooks/wake" || value.endsWith("/hooks/wake"); -} - -function isHooksPath(pathname: string): boolean { - const value = pathname.trim().toLowerCase(); - return ( - value === "/hooks" || - value.startsWith("/hooks/") || - value.endsWith("/hooks") || - value.includes("/hooks/") - ); -} - -function normalizeTransport(value: unknown): "sse" | "webhook" | null { - const normalized = asString(value, "sse").trim().toLowerCase(); - if (!normalized || normalized === "sse") return "sse"; - if (normalized === "webhook") return "webhook"; - return null; -} - -function pushDeploymentDiagnostics( - checks: AdapterEnvironmentCheck[], - ctx: AdapterEnvironmentTestContext, - endpointUrl: URL | null, -) { - const mode = ctx.deployment?.mode; - const exposure = ctx.deployment?.exposure; - const bindHost = normalizeHostname(ctx.deployment?.bindHost ?? null); - const allowSet = new Set( - (ctx.deployment?.allowedHostnames ?? []) - .map((entry) => normalizeHostname(entry)) - .filter((entry): entry is string => Boolean(entry)), - ); - const endpointHost = endpointUrl ? normalizeHostname(endpointUrl.hostname) : null; - - if (!mode) return; - - checks.push({ - code: "openclaw_deployment_context", - level: "info", - message: `Deployment context: mode=${mode}${exposure ? ` exposure=${exposure}` : ""}`, - }); - - if (mode === "authenticated" && exposure === "private") { - if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) { - checks.push({ - code: "openclaw_private_bind_hostname_not_allowed", - level: "warn", - message: `Paperclip bind host "${bindHost}" is not in allowed hostnames.`, - hint: `Run pnpm paperclipai allowed-hostname ${bindHost} so remote OpenClaw callbacks can pass host checks.`, - }); - } - - if (!bindHost || isLoopbackHost(bindHost)) { - checks.push({ - code: "openclaw_private_bind_loopback", - level: "warn", - message: "Paperclip is bound to loopback in authenticated/private mode.", - hint: "Bind to a reachable private hostname/IP so remote OpenClaw agents can call back.", - }); - } - - if (endpointHost && !isLoopbackHost(endpointHost) && allowSet.size === 0) { - checks.push({ - code: "openclaw_private_no_allowed_hostnames", - level: "warn", - message: "No explicit allowed hostnames are configured for authenticated/private mode.", - hint: "Set one with pnpm paperclipai allowed-hostname when OpenClaw runs on another machine.", - }); - } - } - - if (mode === "authenticated" && exposure === "public" && endpointUrl && endpointUrl.protocol !== "https:") { - checks.push({ - code: "openclaw_public_http_endpoint", - level: "warn", - message: "OpenClaw endpoint uses HTTP in authenticated/public mode.", - hint: "Prefer HTTPS for public deployments.", - }); - } -} - -export async function testEnvironment( - ctx: AdapterEnvironmentTestContext, -): Promise { - const checks: AdapterEnvironmentCheck[] = []; - const config = parseObject(ctx.config); - const urlValue = asString(config.url, ""); - const streamTransportValue = config.streamTransport ?? config.transport; - const streamTransport = normalizeTransport(streamTransportValue); - - if (!urlValue) { - checks.push({ - code: "openclaw_url_missing", - level: "error", - message: "OpenClaw adapter requires an endpoint URL.", - hint: "Set adapterConfig.url to your OpenClaw transport endpoint.", - }); - 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_url_invalid", - level: "error", - message: `Invalid URL: ${urlValue}`, - }); - } - - if (url && url.protocol !== "http:" && url.protocol !== "https:") { - checks.push({ - code: "openclaw_url_protocol_invalid", - level: "error", - message: `Unsupported URL protocol: ${url.protocol}`, - hint: "Use an http:// or https:// endpoint.", - }); - } - - if (url) { - checks.push({ - code: "openclaw_url_valid", - level: "info", - message: `Configured endpoint: ${url.toString()}`, - }); - - if (isLoopbackHost(url.hostname)) { - checks.push({ - code: "openclaw_loopback_endpoint", - level: "warn", - message: "Endpoint uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.", - hint: "Use a reachable hostname/IP (for example Tailscale/private hostname or public domain).", - }); - } - - if (streamTransport === "sse" && (isWakePath(url.pathname) || isHooksPath(url.pathname))) { - checks.push({ - code: "openclaw_wake_endpoint_incompatible", - level: "error", - message: "Endpoint targets /hooks/*, which is not stream-capable for SSE transport.", - hint: "Use webhook transport for /hooks/* endpoints.", - }); - } - } - - if (!streamTransport) { - checks.push({ - code: "openclaw_stream_transport_unsupported", - level: "error", - message: `Unsupported streamTransport: ${String(streamTransportValue)}`, - hint: "Use streamTransport=sse or streamTransport=webhook.", - }); - } else { - checks.push({ - code: "openclaw_stream_transport_configured", - level: "info", - message: `Configured stream transport: ${streamTransport}`, - }); - } - - pushDeploymentDiagnostics(checks, ctx, url); - - const method = asString(config.method, "POST").trim().toUpperCase() || "POST"; - checks.push({ - code: "openclaw_method_configured", - level: "info", - message: `Configured method: ${method}`, - }); - - if (url && (url.protocol === "http:" || url.protocol === "https:")) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - try { - const response = await fetch(url, { method: "HEAD", signal: controller.signal }); - if (!response.ok && response.status !== 405 && response.status !== 501) { - checks.push({ - code: "openclaw_endpoint_probe_unexpected_status", - level: "warn", - message: `Endpoint probe returned HTTP ${response.status}.`, - hint: "Verify OpenClaw endpoint reachability and auth/network settings.", - }); - } else { - checks.push({ - code: "openclaw_endpoint_probe_ok", - level: "info", - message: "Endpoint responded to a HEAD probe.", - }); - } - } catch (err) { - checks.push({ - code: "openclaw_endpoint_probe_failed", - level: "warn", - message: err instanceof Error ? err.message : "Endpoint probe failed", - hint: "This may be expected in restricted networks; validate from the Paperclip server host.", - }); - } finally { - clearTimeout(timeout); - } - } - - return { - adapterType: ctx.adapterType, - status: summarizeStatus(checks), - checks, - testedAt: new Date().toISOString(), - }; -} diff --git a/packages/adapters/openclaw/src/shared/stream.ts b/packages/adapters/openclaw/src/shared/stream.ts deleted file mode 100644 index a2e84357..00000000 --- a/packages/adapters/openclaw/src/shared/stream.ts +++ /dev/null @@ -1,16 +0,0 @@ -export function normalizeOpenClawStreamLine(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 }; -} diff --git a/packages/adapters/openclaw/src/ui/build-config.ts b/packages/adapters/openclaw/src/ui/build-config.ts deleted file mode 100644 index f1386780..00000000 --- a/packages/adapters/openclaw/src/ui/build-config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { CreateConfigValues } from "@paperclipai/adapter-utils"; - -export function buildOpenClawConfig(v: CreateConfigValues): Record { - const ac: Record = {}; - if (v.url) ac.url = v.url; - ac.method = "POST"; - ac.timeoutSec = 0; - ac.streamTransport = "sse"; - ac.sessionKeyStrategy = "fixed"; - ac.sessionKey = "paperclip"; - return ac; -} diff --git a/packages/adapters/openclaw/src/ui/index.ts b/packages/adapters/openclaw/src/ui/index.ts deleted file mode 100644 index f3f1905e..00000000 --- a/packages/adapters/openclaw/src/ui/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { parseOpenClawStdoutLine } from "./parse-stdout.js"; -export { buildOpenClawConfig } from "./build-config.js"; diff --git a/packages/adapters/openclaw/src/ui/parse-stdout.ts b/packages/adapters/openclaw/src/ui/parse-stdout.ts deleted file mode 100644 index 55c7f3fe..00000000 --- a/packages/adapters/openclaw/src/ui/parse-stdout.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { TranscriptEntry } from "@paperclipai/adapter-utils"; -import { normalizeOpenClawStreamLine } from "../shared/stream.js"; - -function safeJsonParse(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return null; - } -} - -function asRecord(value: unknown): Record | null { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - return value as Record; -} - -function asString(value: unknown, fallback = ""): string { - return typeof value === "string" ? value : fallback; -} - -function asNumber(value: unknown, fallback = 0): number { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; -} - -function stringifyUnknown(value: unknown): string { - if (typeof value === "string") return value; - if (value === null || value === undefined) return ""; - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -function readErrorText(value: unknown): string { - if (typeof value === "string") return value; - const obj = asRecord(value); - if (!obj) return stringifyUnknown(value); - return ( - asString(obj.message).trim() || - asString(obj.error).trim() || - asString(obj.code).trim() || - stringifyUnknown(obj) - ); -} - -function readDeltaText(payload: Record | null): string { - if (!payload) return ""; - - if (typeof payload.delta === "string") return payload.delta; - - const deltaObj = asRecord(payload.delta); - if (deltaObj) { - const nestedDelta = - asString(deltaObj.text) || - asString(deltaObj.value) || - asString(deltaObj.delta); - if (nestedDelta.length > 0) return nestedDelta; - } - - const part = asRecord(payload.part); - if (part) { - const partText = asString(part.text); - if (partText.length > 0) return partText; - } - - return ""; -} - -function extractResponseOutputText(response: Record | null): string { - if (!response) return ""; - - const output = Array.isArray(response.output) ? response.output : []; - const parts: string[] = []; - for (const itemRaw of output) { - const item = asRecord(itemRaw); - if (!item) continue; - const content = Array.isArray(item.content) ? item.content : []; - for (const partRaw of content) { - const part = asRecord(partRaw); - if (!part) continue; - const type = asString(part.type).trim().toLowerCase(); - if (type !== "output_text" && type !== "text" && type !== "refusal") continue; - const text = asString(part.text).trim(); - if (text) parts.push(text); - } - } - return parts.join("\n\n").trim(); -} - -function parseOpenClawSseLine(line: string, ts: string): TranscriptEntry[] { - const match = line.match(/^\[openclaw:sse\]\s+event=([^\s]+)\s+data=(.*)$/s); - if (!match) return [{ kind: "stdout", ts, text: line }]; - - const eventType = (match[1] ?? "").trim(); - const dataText = (match[2] ?? "").trim(); - const parsed = asRecord(safeJsonParse(dataText)); - const normalizedEventType = eventType.toLowerCase(); - - if (dataText === "[DONE]") { - return []; - } - - const delta = readDeltaText(parsed); - if (normalizedEventType.endsWith(".delta") && delta.length > 0) { - return [{ kind: "assistant", ts, text: delta, delta: true }]; - } - - if ( - normalizedEventType.includes("error") || - normalizedEventType.includes("failed") || - normalizedEventType.includes("cancel") - ) { - const message = readErrorText(parsed?.error) || readErrorText(parsed?.message) || dataText; - return message ? [{ kind: "stderr", ts, text: message }] : []; - } - - if (normalizedEventType === "response.completed" || normalizedEventType.endsWith(".completed")) { - const response = asRecord(parsed?.response); - const usage = asRecord(response?.usage); - const status = asString(response?.status, asString(parsed?.status, eventType)); - const statusLower = status.trim().toLowerCase(); - const errorText = - readErrorText(response?.error).trim() || - readErrorText(parsed?.error).trim() || - readErrorText(parsed?.message).trim(); - const isError = - statusLower === "failed" || - statusLower === "error" || - statusLower === "cancelled"; - - return [{ - kind: "result", - ts, - text: extractResponseOutputText(response), - inputTokens: asNumber(usage?.input_tokens), - outputTokens: asNumber(usage?.output_tokens), - cachedTokens: asNumber(usage?.cached_input_tokens), - costUsd: asNumber(usage?.cost_usd, asNumber(usage?.total_cost_usd)), - subtype: status || eventType, - isError, - errors: errorText ? [errorText] : [], - }]; - } - - return []; -} - -export function parseOpenClawStdoutLine(line: string, ts: string): TranscriptEntry[] { - const normalized = normalizeOpenClawStreamLine(line); - if (normalized.stream === "stderr") { - return [{ kind: "stderr", ts, text: normalized.line }]; - } - - const trimmed = normalized.line.trim(); - if (!trimmed) return []; - - if (trimmed.startsWith("[openclaw:sse]")) { - return parseOpenClawSseLine(trimmed, ts); - } - - if (trimmed.startsWith("[openclaw]")) { - return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw\]\s*/, "") }]; - } - - return [{ kind: "stdout", ts, text: normalized.line }]; -} diff --git a/packages/adapters/openclaw/tsconfig.json b/packages/adapters/openclaw/tsconfig.json deleted file mode 100644 index 2f355cfe..00000000 --- a/packages/adapters/openclaw/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 1661a85b..0c16e2d8 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -13,7 +13,7 @@ Use when: - You want OpenCode session resume across heartbeats via --session Don't use when: -- You need webhook-style external invocation (use openclaw or http) +- You need webhook-style external invocation (use openclaw_gateway or http) - You only need one-shot shell commands (use process) - OpenCode CLI is not installed on the machine diff --git a/packages/adapters/pi-local/src/index.ts b/packages/adapters/pi-local/src/index.ts index 3794426f..a81750c3 100644 --- a/packages/adapters/pi-local/src/index.ts +++ b/packages/adapters/pi-local/src/index.ts @@ -14,7 +14,7 @@ Use when: - You need Pi's tool set (read, bash, edit, write, grep, find, ls) Don't use when: -- You need webhook-style external invocation (use openclaw or http) +- You need webhook-style external invocation (use openclaw_gateway or http) - You only need one-shot shell commands (use process) - Pi CLI is not installed on the machine diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index c7f85b57..252c3690 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -29,7 +29,6 @@ export const AGENT_ADAPTER_TYPES = [ "opencode_local", "pi_local", "cursor", - "openclaw", "openclaw_gateway", ] as const; export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 59ec9eb6..a91f8844 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -197,6 +197,7 @@ export { updateBudgetSchema, createAssetImageMetadataSchema, createCompanyInviteSchema, + createOpenClawInvitePromptSchema, acceptInviteSchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, @@ -206,6 +207,7 @@ export { type UpdateBudget, type CreateAssetImageMetadata, type CreateCompanyInvite, + type CreateOpenClawInvitePrompt, type AcceptInvite, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 614b302e..75b31709 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -15,6 +15,14 @@ export const createCompanyInviteSchema = z.object({ export type CreateCompanyInvite = z.infer; +export const createOpenClawInvitePromptSchema = z.object({ + agentMessage: z.string().max(4000).optional().nullable(), +}); + +export type CreateOpenClawInvitePrompt = z.infer< + typeof createOpenClawInvitePromptSchema +>; + export const acceptInviteSchema = z.object({ requestType: z.enum(JOIN_REQUEST_TYPES), agentName: z.string().min(1).max(120).optional(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 12ad7ffb..f4130c67 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -119,12 +119,14 @@ export { export { createCompanyInviteSchema, + createOpenClawInvitePromptSchema, acceptInviteSchema, listJoinRequestsQuerySchema, claimJoinRequestApiKeySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, type CreateCompanyInvite, + type CreateOpenClawInvitePrompt, type AcceptInvite, type ListJoinRequestsQuery, type ClaimJoinRequestApiKey, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f46a192d..ff4f3e35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 diff --git a/scripts/generate-npm-package-json.mjs b/scripts/generate-npm-package-json.mjs index 635a3e15..c18bce72 100644 --- a/scripts/generate-npm-package-json.mjs +++ b/scripts/generate-npm-package-json.mjs @@ -33,7 +33,7 @@ const workspacePaths = [ "packages/adapters/claude-local", "packages/adapters/codex-local", "packages/adapters/opencode-local", - "packages/adapters/openclaw", + "packages/adapters/openclaw-gateway", ]; // Workspace packages that are NOT bundled and must stay as npm dependencies. diff --git a/scripts/release.sh b/scripts/release.sh index 769b5f47..6827e0fa 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -115,7 +115,7 @@ const { readFileSync } = require('fs'); const { resolve } = require('path'); const root = '$REPO_ROOT'; const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', - 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/openclaw', + 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway', 'server', 'cli']; const names = []; for (const d of dirs) { @@ -221,7 +221,7 @@ const { resolve } = require('path'); const root = '$REPO_ROOT'; const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8'); const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', - 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw', + 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway', 'server', 'cli']; const names = []; for (const d of dirs) { @@ -279,7 +279,7 @@ pnpm --filter @paperclipai/db build pnpm --filter @paperclipai/adapter-claude-local build pnpm --filter @paperclipai/adapter-codex-local build pnpm --filter @paperclipai/adapter-opencode-local build -pnpm --filter @paperclipai/adapter-openclaw build +pnpm --filter @paperclipai/adapter-openclaw-gateway build pnpm --filter @paperclipai/server build # Build UI and bundle into server package for static serving @@ -314,7 +314,7 @@ if [ "$dry_run" = true ]; then echo "" echo " Preview what would be published:" for dir in packages/shared packages/adapter-utils packages/db \ - packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw \ + packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw-gateway \ server cli; do echo " --- $dir ---" cd "$REPO_ROOT/$dir" diff --git a/scripts/smoke/openclaw-gateway-e2e.sh b/scripts/smoke/openclaw-gateway-e2e.sh index e45df9f9..b1a17e50 100755 --- a/scripts/smoke/openclaw-gateway-e2e.sh +++ b/scripts/smoke/openclaw-gateway-e2e.sh @@ -53,6 +53,7 @@ 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}" +PAIRING_AUTO_APPROVE="${PAIRING_AUTO_APPROVE:-1}" PAYLOAD_TEMPLATE_MESSAGE_APPEND="${PAYLOAD_TEMPLATE_MESSAGE_APPEND:-}" AUTH_HEADERS=() @@ -418,7 +419,6 @@ create_and_approve_gateway_join() { headers: { "x-openclaw-token": $token }, role: "operator", scopes: ["operator.admin"], - disableDeviceAuth: true, sessionKeyStrategy: "fixed", sessionKey: "paperclip", timeoutSec: $timeoutSec, @@ -524,6 +524,73 @@ inject_agent_api_key_payload_template() { assert_status "200" } +validate_joined_gateway_agent() { + local expected_gateway_token="$1" + + api_request "GET" "/agents/${AGENT_ID}" + assert_status "200" + + local adapter_type gateway_url configured_token disable_device_auth device_key_len + adapter_type="$(jq -r '.adapterType // empty' <<<"$RESPONSE_BODY")" + gateway_url="$(jq -r '.adapterConfig.url // empty' <<<"$RESPONSE_BODY")" + configured_token="$(jq -r '.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // empty' <<<"$RESPONSE_BODY")" + disable_device_auth="$(jq -r 'if .adapterConfig.disableDeviceAuth == true then "true" else "false" end' <<<"$RESPONSE_BODY")" + device_key_len="$(jq -r '(.adapterConfig.devicePrivateKeyPem // "" | length)' <<<"$RESPONSE_BODY")" + + [[ "$adapter_type" == "openclaw_gateway" ]] || fail "joined agent adapterType is '${adapter_type}', expected 'openclaw_gateway'" + [[ "$gateway_url" =~ ^wss?:// ]] || fail "joined agent gateway url is invalid: '${gateway_url}'" + [[ -n "$configured_token" ]] || fail "joined agent missing adapterConfig.headers.x-openclaw-token" + if (( ${#configured_token} < 16 )); then + fail "joined agent gateway token looks too short (${#configured_token} chars)" + fi + + local expected_hash configured_hash + expected_hash="$(hash_prefix "$expected_gateway_token")" + configured_hash="$(hash_prefix "$configured_token")" + if [[ "$expected_hash" != "$configured_hash" ]]; then + fail "joined agent gateway token hash mismatch (expected ${expected_hash}, got ${configured_hash})" + fi + + [[ "$disable_device_auth" == "false" ]] || fail "joined agent has disableDeviceAuth=true; smoke requires device auth enabled with persistent key" + if (( device_key_len < 32 )); then + fail "joined agent missing persistent devicePrivateKeyPem (length=${device_key_len})" + fi + + log "validated joined gateway agent config (token sha256 prefix ${configured_hash})" +} + +run_log_contains_pairing_required() { + local run_id="$1" + api_request "GET" "/heartbeat-runs/${run_id}/log?limitBytes=262144" + if [[ "$RESPONSE_CODE" != "200" ]]; then + return 1 + fi + local content + content="$(jq -r '.content // ""' <<<"$RESPONSE_BODY")" + grep -qi "pairing required" <<<"$content" +} + +approve_latest_pairing_request() { + local gateway_token="$1" + local container + container="$(detect_openclaw_container || true)" + [[ -n "$container" ]] || return 1 + + log "approving latest gateway pairing request in ${container}" + local output + if output="$(docker exec \ + -e OPENCLAW_GATEWAY_URL="$OPENCLAW_GATEWAY_URL" \ + -e OPENCLAW_GATEWAY_TOKEN="$gateway_token" \ + "$container" \ + sh -lc 'openclaw devices approve --latest --json --url "$OPENCLAW_GATEWAY_URL" --token "$OPENCLAW_GATEWAY_TOKEN"' 2>&1)"; then + log "pairing approval response: $(printf "%s" "$output" | tr '\n' ' ' | cut -c1-400)" + return 0 + fi + + warn "pairing auto-approve failed: $(printf "%s" "$output" | tr '\n' ' ' | cut -c1-400)" + return 1 +} + trigger_wakeup() { local reason="$1" local issue_id="${2:-}" @@ -764,8 +831,9 @@ run_case_c() { local marker="OPENCLAW_CASE_C_CREATED_$(date +%s)" local ack_marker="OPENCLAW_CASE_C_ACK_$(date +%s)" + local original_issue_reference="the original case issue you are currently reading" 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." + 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 ${original_issue_reference} containing exactly: ${ack_marker}\nDo NOT post the ACK comment on the newly created issue.\nThen mark the original case issue done." local created created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case C" "$description")" @@ -840,14 +908,32 @@ main() { create_and_approve_gateway_join "$gateway_token" log "joined/approved agent ${AGENT_ID} invite=${INVITE_ID} joinRequest=${JOIN_REQUEST_ID}" + validate_joined_gateway_agent "$gateway_token" - trigger_wakeup "openclaw_gateway_smoke_connectivity" - if [[ -n "$RUN_ID" ]]; then - local connect_status + local connect_status="unknown" + local connect_attempt + for connect_attempt in 1 2; do + trigger_wakeup "openclaw_gateway_smoke_connectivity_attempt_${connect_attempt}" + if [[ -z "$RUN_ID" ]]; then + connect_status="unknown" + break + fi 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 + if [[ "$connect_status" == "succeeded" ]]; then + log "connectivity wake run ${RUN_ID} succeeded (attempt=${connect_attempt})" + break + fi + + if [[ "$PAIRING_AUTO_APPROVE" == "1" && "$connect_attempt" -eq 1 ]] && run_log_contains_pairing_required "$RUN_ID"; then + log "connectivity run hit pairing gate; attempting one-time pairing approval" + approve_latest_pairing_request "$gateway_token" || fail "pairing approval failed after pairing-required run ${RUN_ID}" + sleep 2 + continue + fi + + fail "connectivity wake run failed: ${connect_status} (attempt=${connect_attempt}, runId=${RUN_ID})" + done + [[ "$connect_status" == "succeeded" ]] || fail "connectivity wake run did not succeed after retries" run_case_a run_case_b diff --git a/scripts/smoke/openclaw-join.sh b/scripts/smoke/openclaw-join.sh index 151ae277..23896e8a 100755 --- a/scripts/smoke/openclaw-join.sh +++ b/scripts/smoke/openclaw-join.sh @@ -179,24 +179,32 @@ if [[ -z "$ONBOARDING_TEXT_PATH" ]]; then fi api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt" assert_status "200" -if ! grep -q "Paperclip OpenClaw Onboarding" <<<"$RESPONSE_BODY"; then +if ! grep -q "Paperclip OpenClaw Gateway Onboarding" <<<"$RESPONSE_BODY"; then fail "onboarding.txt response missing expected header" fi -log "submitting OpenClaw agent join request" +OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-ws://127.0.0.1:18789}" +OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-${OPENCLAW_WEBHOOK_AUTH#Bearer }}" +if [[ -z "$OPENCLAW_GATEWAY_TOKEN" ]]; then + fail "OPENCLAW_GATEWAY_TOKEN (or OPENCLAW_WEBHOOK_AUTH) is required for gateway join" +fi + +log "submitting OpenClaw gateway agent join request" JOIN_PAYLOAD="$(jq -nc \ --arg name "$OPENCLAW_AGENT_NAME" \ - --arg url "$OPENCLAW_WEBHOOK_URL" \ - --arg auth "$OPENCLAW_WEBHOOK_AUTH" \ + --arg url "$OPENCLAW_GATEWAY_URL" \ + --arg token "$OPENCLAW_GATEWAY_TOKEN" \ '{ requestType: "agent", agentName: $name, - adapterType: "openclaw", - capabilities: "Automated OpenClaw smoke harness", - agentDefaultsPayload: ( - { url: $url, method: "POST", timeoutSec: 30 } - + (if ($auth | length) > 0 then { webhookAuthHeader: $auth } else {} end) - ) + adapterType: "openclaw_gateway", + capabilities: "Automated OpenClaw gateway smoke harness", + agentDefaultsPayload: { + url: $url, + headers: { "x-openclaw-token": $token }, + sessionKeyStrategy: "issue", + waitTimeoutMs: 120000 + } }')" api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD" assert_status "202" diff --git a/server/package.json b/server/package.json index eaf73505..3e74286b 100644 --- a/server/package.json +++ b/server/package.json @@ -36,7 +36,6 @@ "@paperclipai/adapter-cursor-local": "workspace:*", "@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:*", diff --git a/server/src/__tests__/hire-hook.test.ts b/server/src/__tests__/hire-hook.test.ts index 3161949a..0a2cbbfd 100644 --- a/server/src/__tests__/hire-hook.test.ts +++ b/server/src/__tests__/hire-hook.test.ts @@ -40,7 +40,7 @@ afterEach(() => { describe("notifyHireApproved", () => { it("writes success activity when adapter hook returns ok", async () => { vi.mocked(findServerAdapter).mockReturnValue({ - type: "openclaw", + type: "openclaw_gateway", onHireApproved: vi.fn().mockResolvedValue({ ok: true }), } as any); @@ -48,7 +48,7 @@ describe("notifyHireApproved", () => { id: "a1", companyId: "c1", name: "OpenClaw Agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", }); await expect( @@ -65,7 +65,7 @@ describe("notifyHireApproved", () => { expect.objectContaining({ action: "hire_hook.succeeded", entityId: "a1", - details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw" }), + details: expect.objectContaining({ source: "approval", sourceId: "ap1", adapterType: "openclaw_gateway" }), }), ); }); @@ -116,7 +116,7 @@ describe("notifyHireApproved", () => { it("logs failed result when adapter onHireApproved returns ok=false", async () => { vi.mocked(findServerAdapter).mockReturnValue({ - type: "openclaw", + type: "openclaw_gateway", onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }), } as any); @@ -124,7 +124,7 @@ describe("notifyHireApproved", () => { id: "a1", companyId: "c1", name: "OpenClaw Agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", }); await expect( @@ -148,7 +148,7 @@ describe("notifyHireApproved", () => { it("does not throw when adapter onHireApproved throws (non-fatal)", async () => { vi.mocked(findServerAdapter).mockReturnValue({ - type: "openclaw", + type: "openclaw_gateway", onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")), } as any); @@ -156,7 +156,7 @@ describe("notifyHireApproved", () => { id: "a1", companyId: "c1", name: "OpenClaw Agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", }); await expect( diff --git a/server/src/__tests__/invite-accept-gateway-defaults.test.ts b/server/src/__tests__/invite-accept-gateway-defaults.test.ts new file mode 100644 index 00000000..3ff239f6 --- /dev/null +++ b/server/src/__tests__/invite-accept-gateway-defaults.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + buildJoinDefaultsPayloadForAccept, + normalizeAgentDefaultsForJoin, +} from "../routes/access.js"; + +describe("buildJoinDefaultsPayloadForAccept (openclaw_gateway)", () => { + it("leaves non-gateway payloads unchanged", () => { + const defaultsPayload = { command: "echo hello" }; + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "process", + defaultsPayload, + inboundOpenClawAuthHeader: "ignored-token", + }); + + expect(result).toEqual(defaultsPayload); + }); + + it("normalizes wrapped x-openclaw-token header", () => { + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + headers: { + "x-openclaw-token": { + value: "gateway-token-1234567890", + }, + }, + }, + }) as Record; + + expect(result).toMatchObject({ + url: "ws://127.0.0.1:18789", + headers: { + "x-openclaw-token": "gateway-token-1234567890", + }, + }); + }); + + it("accepts inbound x-openclaw-token for gateway joins", () => { + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + }, + inboundOpenClawTokenHeader: "gateway-token-1234567890", + }) as Record; + + expect(result).toMatchObject({ + headers: { + "x-openclaw-token": "gateway-token-1234567890", + }, + }); + }); + + it("derives x-openclaw-token from authorization header", () => { + const result = buildJoinDefaultsPayloadForAccept({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + headers: { + authorization: "Bearer gateway-token-1234567890", + }, + }, + }) as Record; + + expect(result).toMatchObject({ + headers: { + authorization: "Bearer gateway-token-1234567890", + "x-openclaw-token": "gateway-token-1234567890", + }, + }); + }); +}); + +describe("normalizeAgentDefaultsForJoin (openclaw_gateway)", () => { + it("generates persistent device key when device auth is enabled", () => { + const normalized = normalizeAgentDefaultsForJoin({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + headers: { + "x-openclaw-token": "gateway-token-1234567890", + }, + disableDeviceAuth: false, + }, + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }); + + expect(normalized.fatalErrors).toEqual([]); + expect(normalized.normalized?.disableDeviceAuth).toBe(false); + expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string"); + expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64); + }); + + it("does not generate device key when disableDeviceAuth=true", () => { + const normalized = normalizeAgentDefaultsForJoin({ + adapterType: "openclaw_gateway", + defaultsPayload: { + url: "ws://127.0.0.1:18789", + headers: { + "x-openclaw-token": "gateway-token-1234567890", + }, + disableDeviceAuth: true, + }, + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }); + + expect(normalized.fatalErrors).toEqual([]); + expect(normalized.normalized?.disableDeviceAuth).toBe(true); + expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined(); + }); +}); diff --git a/server/src/__tests__/invite-accept-openclaw-defaults.test.ts b/server/src/__tests__/invite-accept-openclaw-defaults.test.ts deleted file mode 100644 index b94dd55d..00000000 --- a/server/src/__tests__/invite-accept-openclaw-defaults.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildJoinDefaultsPayloadForAccept } from "../routes/access.js"; - -describe("buildJoinDefaultsPayloadForAccept", () => { - it("maps OpenClaw compatibility fields into agent defaults", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: null, - responsesWebhookUrl: "http://localhost:18789/v1/responses", - paperclipApiUrl: "http://host.docker.internal:3100", - inboundOpenClawAuthHeader: "gateway-token", - }) as Record; - - expect(result).toMatchObject({ - url: "http://localhost:18789/v1/responses", - paperclipApiUrl: "http://host.docker.internal:3100", - webhookAuthHeader: "Bearer gateway-token", - headers: { - "x-openclaw-auth": "gateway-token", - }, - }); - }); - - it("does not overwrite explicit OpenClaw endpoint defaults when already provided", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - url: "https://example.com/v1/responses", - method: "POST", - headers: { - "x-openclaw-auth": "existing-token", - }, - paperclipApiUrl: "https://paperclip.example.com", - }, - responsesWebhookUrl: "https://legacy.example.com/v1/responses", - responsesWebhookMethod: "PUT", - paperclipApiUrl: "https://legacy-paperclip.example.com", - inboundOpenClawAuthHeader: "legacy-token", - }) as Record; - - expect(result).toMatchObject({ - url: "https://example.com/v1/responses", - method: "POST", - paperclipApiUrl: "https://paperclip.example.com", - webhookAuthHeader: "Bearer existing-token", - headers: { - "x-openclaw-auth": "existing-token", - }, - }); - }); - - it("preserves explicit webhookAuthHeader when configured", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - url: "https://example.com/v1/responses", - webhookAuthHeader: "Bearer explicit-token", - headers: { - "x-openclaw-auth": "existing-token", - }, - }, - inboundOpenClawAuthHeader: "legacy-token", - }) as Record; - - expect(result).toMatchObject({ - webhookAuthHeader: "Bearer explicit-token", - headers: { - "x-openclaw-auth": "existing-token", - }, - }); - }); - - it("accepts auth from agentDefaultsPayload.headers.x-openclaw-auth", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - url: "http://127.0.0.1:18789/v1/responses", - method: "POST", - headers: { - "x-openclaw-auth": "gateway-token", - }, - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-auth": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts auth from agentDefaultsPayload.headers.x-openclaw-token", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - url: "http://127.0.0.1:18789/hooks/agent", - method: "POST", - headers: { - "x-openclaw-token": "gateway-token", - }, - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-token": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts inbound x-openclaw-token compatibility header", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: null, - inboundOpenClawTokenHeader: "gateway-token", - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-token": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts wrapped auth values in headers for compatibility", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - headers: { - "x-openclaw-auth": { - value: "gateway-token", - }, - }, - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-auth": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts auth headers provided as tuple entries", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - headers: [["x-openclaw-auth", "gateway-token"]], - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-auth": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts auth headers provided as name/value entries", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - headers: [{ name: "x-openclaw-auth", value: { authToken: "gateway-token" } }], - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-auth": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("accepts auth headers wrapped in a single unknown key", () => { - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", - defaultsPayload: { - headers: { - "x-openclaw-auth": { - gatewayToken: "gateway-token", - }, - }, - }, - }) as Record; - - expect(result).toMatchObject({ - headers: { - "x-openclaw-auth": "gateway-token", - }, - webhookAuthHeader: "Bearer gateway-token", - }); - }); - - it("leaves non-openclaw payloads unchanged", () => { - const defaultsPayload = { command: "echo hello" }; - const result = buildJoinDefaultsPayloadForAccept({ - adapterType: "process", - defaultsPayload, - responsesWebhookUrl: "https://ignored.example.com", - inboundOpenClawAuthHeader: "ignored-token", - }); - - expect(result).toEqual(defaultsPayload); - }); -}); diff --git a/server/src/__tests__/invite-accept-replay.test.ts b/server/src/__tests__/invite-accept-replay.test.ts index 78a2bb1c..dba43dbd 100644 --- a/server/src/__tests__/invite-accept-replay.test.ts +++ b/server/src/__tests__/invite-accept-replay.test.ts @@ -1,63 +1,55 @@ import { describe, expect, it } from "vitest"; import { buildJoinDefaultsPayloadForAccept, - canReplayOpenClawInviteAccept, + canReplayOpenClawGatewayInviteAccept, mergeJoinDefaultsPayloadForReplay, } from "../routes/access.js"; -describe("canReplayOpenClawInviteAccept", () => { - it("allows replay only for openclaw agent joins in pending or approved state", () => { +describe("canReplayOpenClawGatewayInviteAccept", () => { + it("allows replay only for openclaw_gateway agent joins in pending or approved state", () => { expect( - canReplayOpenClawInviteAccept({ + canReplayOpenClawGatewayInviteAccept({ requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", existingJoinRequest: { requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", status: "pending_approval", }, }), ).toBe(true); + expect( - canReplayOpenClawInviteAccept({ + canReplayOpenClawGatewayInviteAccept({ requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", existingJoinRequest: { requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", status: "approved", }, }), ).toBe(true); + expect( - canReplayOpenClawInviteAccept({ + canReplayOpenClawGatewayInviteAccept({ requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", existingJoinRequest: { requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", status: "rejected", }, }), ).toBe(false); + expect( - canReplayOpenClawInviteAccept({ + canReplayOpenClawGatewayInviteAccept({ requestType: "human", - adapterType: "openclaw", + adapterType: "openclaw_gateway", existingJoinRequest: { requestType: "agent", - adapterType: "openclaw", - status: "pending_approval", - }, - }), - ).toBe(false); - expect( - canReplayOpenClawInviteAccept({ - requestType: "agent", - adapterType: "process", - existingJoinRequest: { - requestType: "agent", - adapterType: "openclaw", + adapterType: "openclaw_gateway", status: "pending_approval", }, }), @@ -66,36 +58,34 @@ describe("canReplayOpenClawInviteAccept", () => { }); describe("mergeJoinDefaultsPayloadForReplay", () => { - it("merges replay payloads and preserves existing fields while allowing auth/header overrides", () => { + it("merges replay payloads and allows gateway token override", () => { const merged = mergeJoinDefaultsPayloadForReplay( { - url: "https://old.example/v1/responses", - method: "POST", + url: "ws://old.example:18789", paperclipApiUrl: "http://host.docker.internal:3100", headers: { - "x-openclaw-auth": "old-token", + "x-openclaw-token": "old-token-1234567890", "x-custom": "keep-me", }, }, { paperclipApiUrl: "https://paperclip.example.com", headers: { - "x-openclaw-auth": "new-token", + "x-openclaw-token": "new-token-1234567890", }, }, ); const normalized = buildJoinDefaultsPayloadForAccept({ - adapterType: "openclaw", + adapterType: "openclaw_gateway", defaultsPayload: merged, inboundOpenClawAuthHeader: null, }) as Record; - expect(normalized.url).toBe("https://old.example/v1/responses"); + expect(normalized.url).toBe("ws://old.example:18789"); expect(normalized.paperclipApiUrl).toBe("https://paperclip.example.com"); - expect(normalized.webhookAuthHeader).toBe("Bearer new-token"); expect(normalized.headers).toMatchObject({ - "x-openclaw-auth": "new-token", + "x-openclaw-token": "new-token-1234567890", "x-custom": "keep-me", }); }); diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index 6f22c2d5..8ba30115 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -37,21 +37,22 @@ describe("buildInviteOnboardingTextDocument", () => { allowedHostnames: [], }); - expect(text).toContain("Paperclip OpenClaw Onboarding"); + expect(text).toContain("Paperclip OpenClaw Gateway Onboarding"); expect(text).toContain("/api/invites/token-123/accept"); expect(text).toContain("/api/join-requests/{requestId}/claim-api-key"); expect(text).toContain("/api/invites/token-123/onboarding.txt"); - expect(text).toContain("/api/invites/token-123/test-resolution"); expect(text).toContain("Suggested Paperclip base URLs to try"); expect(text).toContain("http://localhost:3100"); expect(text).toContain("host.docker.internal"); expect(text).toContain("paperclipApiUrl"); - expect(text).toContain("You MUST include agentDefaultsPayload.headers.x-openclaw-auth"); - expect(text).toContain("will fail with 401 Unauthorized"); + expect(text).toContain("adapterType \"openclaw_gateway\""); + expect(text).toContain("headers.x-openclaw-token"); + expect(text).toContain("Do NOT use /v1/responses or /hooks/*"); expect(text).toContain("set the first reachable candidate as agentDefaultsPayload.paperclipApiUrl"); expect(text).toContain("~/.openclaw/workspace/paperclip-claimed-api-key.json"); expect(text).toContain("PAPERCLIP_API_KEY"); expect(text).toContain("saved token field"); + expect(text).toContain("Gateway token unexpectedly short"); }); it("includes loopback diagnostics for authenticated/private onboarding", () => { diff --git a/server/src/__tests__/openclaw-adapter.test.ts b/server/src/__tests__/openclaw-adapter.test.ts deleted file mode 100644 index f3e5dd59..00000000 --- a/server/src/__tests__/openclaw-adapter.test.ts +++ /dev/null @@ -1,1063 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { execute, testEnvironment, onHireApproved } from "@paperclipai/adapter-openclaw/server"; -import { parseOpenClawStdoutLine } from "@paperclipai/adapter-openclaw/ui"; -import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; - -function buildContext( - config: Record, - overrides?: Partial, -): AdapterExecutionContext { - return { - runId: "run-123", - agent: { - id: "agent-123", - companyId: "company-123", - name: "OpenClaw Agent", - adapterType: "openclaw", - 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, - }; -} - -function sseResponse(lines: string[]) { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - for (const line of lines) { - controller.enqueue(encoder.encode(line)); - } - controller.close(); - }, - }); - return new Response(stream, { - status: 200, - statusText: "OK", - headers: { - "content-type": "text/event-stream", - }, - }); -} - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -describe("openclaw ui stdout parser", () => { - it("parses SSE deltas into assistant streaming entries", () => { - const ts = "2026-03-05T23:07:16.296Z"; - const line = - '[openclaw:sse] event=response.output_text.delta data={"type":"response.output_text.delta","delta":"hello"}'; - - expect(parseOpenClawStdoutLine(line, ts)).toEqual([ - { - kind: "assistant", - ts, - text: "hello", - delta: true, - }, - ]); - }); - - it("parses stdout-prefixed SSE deltas and preserves spacing", () => { - const ts = "2026-03-05T23:07:16.296Z"; - const line = - 'stdout[openclaw:sse] event=response.output_text.delta data={"type":"response.output_text.delta","delta":" can"}'; - - expect(parseOpenClawStdoutLine(line, ts)).toEqual([ - { - kind: "assistant", - ts, - text: " can", - delta: true, - }, - ]); - }); - - it("parses response.completed into usage-aware result entries", () => { - const ts = "2026-03-05T23:07:20.269Z"; - const line = JSON.stringify({ - type: "response.completed", - response: { - status: "completed", - usage: { - input_tokens: 12, - output_tokens: 34, - cached_input_tokens: 5, - }, - output: [ - { - type: "message", - content: [ - { - type: "output_text", - text: "All done", - }, - ], - }, - ], - }, - }); - - expect(parseOpenClawStdoutLine(`[openclaw:sse] event=response.completed data=${line}`, ts)).toEqual([ - { - kind: "result", - ts, - text: "All done", - inputTokens: 12, - outputTokens: 34, - cachedTokens: 5, - costUsd: 0, - subtype: "completed", - isError: false, - errors: [], - }, - ]); - }); - - it("maps SSE errors to stderr entries", () => { - const ts = "2026-03-05T23:07:20.269Z"; - const line = - '[openclaw:sse] event=response.failed data={"type":"response.failed","error":"timeout"}'; - - expect(parseOpenClawStdoutLine(line, ts)).toEqual([ - { - kind: "stderr", - ts, - text: "timeout", - }, - ]); - }); - - it("maps stderr-prefixed lines to stderr transcript entries", () => { - const ts = "2026-03-05T23:07:20.269Z"; - const line = "stderr OpenClaw transport error"; - - expect(parseOpenClawStdoutLine(line, ts)).toEqual([ - { - kind: "stderr", - ts, - text: "OpenClaw transport error", - }, - ]); - }); -}); - -describe("openclaw adapter execute", () => { - it("uses SSE transport and includes canonical PAPERCLIP context in text payload", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - payloadTemplate: { foo: "bar", text: "OpenClaw task prompt" }, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.foo).toBe("bar"); - expect(body.stream).toBe(true); - expect(body.sessionKey).toBe("paperclip"); - expect((body.paperclip as Record).streamTransport).toBe("sse"); - expect((body.paperclip as Record).runId).toBe("run-123"); - expect((body.paperclip as Record).sessionKey).toBe("paperclip"); - expect( - ((body.paperclip as Record).env as Record).PAPERCLIP_RUN_ID, - ).toBe("run-123"); - const text = String(body.text ?? ""); - expect(text).toContain("OpenClaw task prompt"); - expect(text).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(text).toContain("PAPERCLIP_AGENT_ID=agent-123"); - expect(text).toContain("PAPERCLIP_COMPANY_ID=company-123"); - expect(text).toContain("PAPERCLIP_TASK_ID=task-123"); - expect(text).toContain("PAPERCLIP_WAKE_REASON=issue_assigned"); - expect(text).toContain("PAPERCLIP_LINKED_ISSUE_IDS=issue-123"); - expect(text).toContain("PAPERCLIP_API_KEY="); - expect(text).toContain("Load PAPERCLIP_API_KEY from ~/.openclaw/workspace/paperclip-claimed-api-key.json"); - }); - - it("uses paperclipApiUrl override when provided", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - paperclipApiUrl: "http://dotta-macbook-pro:3100", - }), - ); - - expect(result.exitCode).toBe(0); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - const paperclip = body.paperclip as Record; - const env = paperclip.env as Record; - expect(env.PAPERCLIP_API_URL).toBe("http://dotta-macbook-pro:3100/"); - expect(String(body.text ?? "")).toContain("PAPERCLIP_API_URL=http://dotta-macbook-pro:3100/"); - }); - - it("logs outbound header keys for auth debugging", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const logs: string[] = []; - const result = await execute( - buildContext( - { - url: "https://agent.example/sse", - method: "POST", - headers: { - "x-openclaw-auth": "gateway-token", - }, - }, - { - onLog: async (_stream, chunk) => { - logs.push(chunk); - }, - }, - ), - ); - - expect(result.exitCode).toBe(0); - expect( - logs.some((line) => line.includes("[openclaw] outbound header keys:") && line.includes("x-openclaw-auth")), - ).toBe(true); - }); - - it("logs outbound payload with sensitive fields redacted", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const logs: string[] = []; - const result = await execute( - buildContext( - { - url: "https://agent.example/sse", - method: "POST", - headers: { - "x-openclaw-auth": "gateway-token", - }, - payloadTemplate: { - text: "task prompt", - nested: { - token: "secret-token", - visible: "keep-me", - }, - }, - }, - { - onLog: async (_stream, chunk) => { - logs.push(chunk); - }, - }, - ), - ); - - expect(result.exitCode).toBe(0); - - const headerLog = logs.find((line) => line.includes("[openclaw] outbound headers (redacted):")); - expect(headerLog).toBeDefined(); - expect(headerLog).toContain("\"x-openclaw-auth\":\"[redacted"); - expect(headerLog).toContain("\"authorization\":\"[redacted"); - expect(headerLog).not.toContain("gateway-token"); - - const payloadLog = logs.find((line) => line.includes("[openclaw] outbound payload (redacted):")); - expect(payloadLog).toBeDefined(); - expect(payloadLog).toContain("\"token\":\"[redacted"); - expect(payloadLog).not.toContain("secret-token"); - expect(payloadLog).toContain("\"visible\":\"keep-me\""); - }); - - it("derives Authorization header from x-openclaw-auth when webhookAuthHeader is unset", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - headers: { - "x-openclaw-auth": "gateway-token", - }, - }), - ); - - expect(result.exitCode).toBe(0); - const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; - expect(headers["x-openclaw-auth"]).toBe("gateway-token"); - expect(headers.authorization).toBe("Bearer gateway-token"); - }); - - it("derives Authorization header from x-openclaw-token when webhookAuthHeader is unset", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - headers: { - "x-openclaw-token": "gateway-token", - }, - }), - ); - - expect(result.exitCode).toBe(0); - const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; - expect(headers["x-openclaw-token"]).toBe("gateway-token"); - expect(headers.authorization).toBe("Bearer gateway-token"); - }); - - it("derives issue session keys when configured", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: done\n", - "data: [DONE]\n\n", - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - sessionKeyStrategy: "issue", - }), - ); - - expect(result.exitCode).toBe(0); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.sessionKey).toBe("paperclip:issue:issue-123"); - expect((body.paperclip as Record).sessionKey).toBe("paperclip:issue:issue-123"); - }); - - it("maps requests to OpenResponses schema for /v1/responses endpoints", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/v1/responses", - method: "POST", - payloadTemplate: { - model: "openclaw", - user: "paperclip", - }, - }), - ); - - expect(result.exitCode).toBe(0); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.stream).toBe(true); - expect(body.model).toBe("openclaw"); - expect(typeof body.input).toBe("string"); - expect(String(body.input)).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(String(body.input)).toContain("PAPERCLIP_API_KEY="); - expect(body.metadata).toBeTypeOf("object"); - expect((body.metadata as Record).PAPERCLIP_RUN_ID).toBe("run-123"); - expect(body.text).toBeUndefined(); - expect(body.paperclip).toBeUndefined(); - expect(body.sessionKey).toBeUndefined(); - - const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; - expect(headers["x-openclaw-session-key"]).toBe("paperclip"); - }); - - it("does not treat response.output_text.done as a terminal OpenResponses event", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.output_text.done\n", - 'data: {"type":"response.output_text.done","text":"partial"}\n\n', - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/v1/responses", - method: "POST", - }), - ); - - expect(result.exitCode).toBe(0); - expect(result.resultJson).toEqual( - expect.objectContaining({ - terminal: true, - eventCount: 2, - lastEventType: "response.completed", - }), - ); - }); - - it("appends wake text when OpenResponses input is provided as a message object", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.completed\n", - 'data: {"type":"response.completed","status":"completed"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/v1/responses", - method: "POST", - payloadTemplate: { - model: "openclaw", - input: { - type: "message", - role: "user", - content: [ - { - type: "input_text", - text: "start with this context", - }, - ], - }, - }, - }), - ); - - expect(result.exitCode).toBe(0); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - const input = body.input as Record; - expect(input.type).toBe("message"); - expect(input.role).toBe("user"); - expect(Array.isArray(input.content)).toBe(true); - - const content = input.content as Record[]; - expect(content).toHaveLength(2); - expect(content[0]).toEqual({ - type: "input_text", - text: "start with this context", - }); - expect(content[1]).toEqual( - expect.objectContaining({ - type: "input_text", - }), - ); - expect(String(content[1]?.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - }); - - it("fails when SSE endpoint does not return text/event-stream", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: false, error: "unexpected payload" }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - method: "POST", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_sse_expected_event_stream"); - }); - - it("fails when SSE stream closes without a terminal event", async () => { - const fetchMock = vi.fn().mockResolvedValue( - sseResponse([ - "event: response.delta\n", - 'data: {"type":"response.delta","delta":"partial"}\n\n', - ]), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_sse_stream_incomplete"); - }); - - it("fails with explicit text-required error when endpoint rejects payload", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ error: "text required" }), { - status: 400, - statusText: "Bad Request", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_text_required"); - }); - - it("supports webhook transport and sends Paperclip webhook payloads", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/webhook", - streamTransport: "webhook", - payloadTemplate: { foo: "bar" }, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.foo).toBe("bar"); - expect(body.stream).toBe(false); - expect(body.sessionKey).toBe("paperclip"); - expect(String(body.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - expect((body.paperclip as Record).streamTransport).toBe("webhook"); - }); - - it("remaps legacy /v1/responses URLs to /hooks/agent in webhook transport", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/v1/responses", - streamTransport: "webhook", - payloadTemplate: { foo: "bar" }, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(String(fetchMock.mock.calls[0]?.[0] ?? "")).toBe("https://agent.example/hooks/agent"); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(typeof body.message).toBe("string"); - expect(String(body.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(body.stream).toBeUndefined(); - expect(body.input).toBeUndefined(); - expect(body.metadata).toBeUndefined(); - expect(body.paperclip).toBeUndefined(); - const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record; - expect(headers["x-openclaw-session-key"]).toBeUndefined(); - }); - - it("falls back to legacy /v1/responses when remapped /hooks/agent returns 404", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response("Not Found", { - status: 404, - statusText: "Not Found", - headers: { - "content-type": "text/plain", - }, - }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/v1/responses", - streamTransport: "webhook", - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(String(fetchMock.mock.calls[0]?.[0] ?? "")).toBe("https://agent.example/hooks/agent"); - expect(String(fetchMock.mock.calls[1]?.[0] ?? "")).toBe("https://agent.example/v1/responses"); - - const firstBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(typeof firstBody.message).toBe("string"); - expect(String(firstBody.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - - const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record; - expect(secondBody.stream).toBe(false); - expect(typeof secondBody.input).toBe("string"); - expect(String(secondBody.input ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - - const secondHeaders = (fetchMock.mock.calls[1]?.[1]?.headers ?? {}) as Record; - expect(secondHeaders["x-openclaw-session-key"]).toBe("paperclip"); - expect(result.resultJson).toEqual( - expect.objectContaining({ - usedLegacyResponsesFallback: true, - }), - ); - }); - - it("uses wake compatibility payloads for /hooks/wake when transport=webhook", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/hooks/wake", - streamTransport: "webhook", - }), - ); - - expect(result.exitCode).toBe(0); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.mode).toBe("now"); - expect(String(body.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(body.paperclip).toBeUndefined(); - }); - - it("uses /hooks/agent payloads for webhook transport and omits sessionKey by default", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/hooks/agent", - streamTransport: "webhook", - payloadTemplate: { - name: "Paperclip Hook", - wakeMode: "next-heartbeat", - deliver: true, - channel: "last", - model: "openai/gpt-5.2-mini", - }, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(typeof body.message).toBe("string"); - expect(String(body.message)).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(body.name).toBe("Paperclip Hook"); - expect(body.wakeMode).toBe("next-heartbeat"); - expect(body.deliver).toBe(true); - expect(body.channel).toBe("last"); - expect(body.model).toBe("openai/gpt-5.2-mini"); - expect(body.sessionKey).toBeUndefined(); - expect(body.text).toBeUndefined(); - expect(body.paperclip).toBeUndefined(); - }); - - it("includes sessionKey for /hooks/agent payloads only when hookIncludeSessionKey=true", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/hooks/agent", - streamTransport: "webhook", - hookIncludeSessionKey: true, - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - expect(body.sessionKey).toBe("paperclip"); - }); - - it("retries webhook payloads with wake compatibility format on text-required errors", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ error: "text required" }), { - status: 400, - statusText: "Bad Request", - headers: { - "content-type": "application/json", - }, - }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/webhook", - streamTransport: "webhook", - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(2); - const firstBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; - const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record; - expect(String(firstBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - expect(firstBody.paperclip).toBeTypeOf("object"); - expect(secondBody.mode).toBe("now"); - expect(String(secondBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - }); - - it("retries webhook payloads when /v1/responses reports missing string input", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: { - message: "model: Invalid input: expected string, received undefined", - type: "invalid_request_error", - }, - }), - { - status: 400, - statusText: "Bad Request", - headers: { - "content-type": "application/json", - }, - }, - ), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ ok: true }), { - status: 200, - statusText: "OK", - headers: { - "content-type": "application/json", - }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/webhook", - streamTransport: "webhook", - }), - ); - - expect(result.exitCode).toBe(0); - expect(fetchMock).toHaveBeenCalledTimes(2); - const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record; - expect(secondBody.mode).toBe("now"); - expect(String(secondBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); - }); - - it("rejects unsupported transport configuration", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/sse", - streamTransport: "invalid", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_stream_transport_unsupported"); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("rejects /hooks/wake compatibility endpoints in SSE mode", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/hooks/wake", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_sse_incompatible_endpoint"); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("rejects /hooks/agent endpoints in SSE mode", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - const result = await execute( - buildContext({ - url: "https://agent.example/hooks/agent", - }), - ); - - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("openclaw_sse_incompatible_endpoint"); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); - -describe("openclaw adapter environment checks", () => { - it("reports /hooks/wake endpoints as incompatible for SSE mode", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" })); - vi.stubGlobal("fetch", fetchMock); - - const result = await testEnvironment({ - companyId: "company-123", - adapterType: "openclaw", - config: { - url: "https://agent.example/hooks/wake", - }, - deployment: { - mode: "authenticated", - exposure: "private", - bindHost: "paperclip.internal", - allowedHostnames: ["paperclip.internal"], - }, - }); - - const check = result.checks.find((entry) => entry.code === "openclaw_wake_endpoint_incompatible"); - expect(check?.level).toBe("error"); - }); - - it("reports /hooks/agent endpoints as incompatible for SSE mode", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" })); - vi.stubGlobal("fetch", fetchMock); - - const result = await testEnvironment({ - companyId: "company-123", - adapterType: "openclaw", - config: { - url: "https://agent.example/hooks/agent", - }, - }); - - const check = result.checks.find((entry) => entry.code === "openclaw_wake_endpoint_incompatible"); - expect(check?.level).toBe("error"); - }); - - it("reports unsupported streamTransport settings", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" })); - vi.stubGlobal("fetch", fetchMock); - - const result = await testEnvironment({ - companyId: "company-123", - adapterType: "openclaw", - config: { - url: "https://agent.example/sse", - streamTransport: "invalid", - }, - }); - - const check = result.checks.find((entry) => entry.code === "openclaw_stream_transport_unsupported"); - expect(check?.level).toBe("error"); - }); - - it("accepts webhook streamTransport settings", async () => { - const fetchMock = vi - .fn() - .mockResolvedValue(new Response(null, { status: 405, statusText: "Method Not Allowed" })); - vi.stubGlobal("fetch", fetchMock); - - const result = await testEnvironment({ - companyId: "company-123", - adapterType: "openclaw", - config: { - url: "https://agent.example/hooks/wake", - streamTransport: "webhook", - }, - }); - - const unsupported = result.checks.find((entry) => entry.code === "openclaw_stream_transport_unsupported"); - const configured = result.checks.find((entry) => entry.code === "openclaw_stream_transport_configured"); - const wakeIncompatible = result.checks.find((entry) => entry.code === "openclaw_wake_endpoint_incompatible"); - expect(unsupported).toBeUndefined(); - expect(configured?.level).toBe("info"); - expect(wakeIncompatible).toBeUndefined(); - }); -}); - -describe("onHireApproved", () => { - it("returns ok when hireApprovedCallbackUrl is not set (no-op)", async () => { - const result = await onHireApproved( - { - companyId: "c1", - agentId: "a1", - agentName: "Test Agent", - adapterType: "openclaw", - source: "join_request", - sourceId: "jr1", - approvedAt: "2026-03-06T00:00:00.000Z", - message: "You're hired.", - }, - {}, - ); - expect(result).toEqual({ ok: true }); - }); - - it("POSTs payload to hireApprovedCallbackUrl with correct headers and body", async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); - vi.stubGlobal("fetch", fetchMock); - - const payload = { - companyId: "c1", - agentId: "a1", - agentName: "OpenClaw Agent", - adapterType: "openclaw", - source: "approval" as const, - sourceId: "ap1", - approvedAt: "2026-03-06T12:00:00.000Z", - message: "Tell your user that your hire was approved.", - }; - - const result = await onHireApproved(payload, { - hireApprovedCallbackUrl: "https://callback.example/hire-approved", - hireApprovedCallbackAuthHeader: "Bearer secret", - }); - - expect(result.ok).toBe(true); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe("https://callback.example/hire-approved"); - expect(init?.method).toBe("POST"); - expect((init?.headers as Record)["content-type"]).toBe("application/json"); - expect((init?.headers as Record)["Authorization"]).toBe("Bearer secret"); - const body = JSON.parse(init?.body as string); - expect(body.event).toBe("hire_approved"); - expect(body.companyId).toBe(payload.companyId); - expect(body.agentId).toBe(payload.agentId); - expect(body.message).toBe(payload.message); - }); - - it("returns failure when callback returns non-2xx", async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response("Server Error", { status: 500 })); - vi.stubGlobal("fetch", fetchMock); - - const result = await onHireApproved( - { - companyId: "c1", - agentId: "a1", - agentName: "A", - adapterType: "openclaw", - source: "join_request", - sourceId: "jr1", - approvedAt: new Date().toISOString(), - message: "Hired", - }, - { hireApprovedCallbackUrl: "https://example.com/hook" }, - ); - - expect(result.ok).toBe(false); - expect(result.error).toContain("500"); - }); -}); diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts index df57af32..364f5a97 100644 --- a/server/src/__tests__/openclaw-gateway-adapter.test.ts +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -167,6 +167,208 @@ async function createMockGatewayServer() { }; } +async function createMockGatewayServerWithPairing() { + const server = createServer(); + const wss = new WebSocketServer({ server }); + + let agentPayload: Record | null = null; + let approved = false; + let pendingRequestId = "req-1"; + let lastSeenDeviceId: string | 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; + }; + + if (frame.type !== "req") return; + + if (frame.method === "connect") { + const device = frame.params?.device as Record | undefined; + const deviceId = typeof device?.id === "string" ? device.id : null; + if (deviceId) { + lastSeenDeviceId = deviceId; + } + + if (deviceId && !approved) { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: false, + error: { + code: "NOT_PAIRED", + message: "pairing required", + details: { + code: "PAIRING_REQUIRED", + requestId: pendingRequestId, + reason: "not-paired", + }, + }, + }), + ); + socket.close(1008, "pairing required"); + return; + } + + 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", "device.pair.list", "device.pair.approve"], + events: ["agent"], + }, + snapshot: { version: 1, ts: Date.now() }, + policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 }, + }, + }), + ); + return; + } + + if (frame.method === "device.pair.list") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + pending: approved + ? [] + : [ + { + requestId: pendingRequestId, + deviceId: lastSeenDeviceId ?? "device-unknown", + }, + ], + paired: approved && lastSeenDeviceId ? [{ deviceId: lastSeenDeviceId }] : [], + }, + }), + ); + return; + } + + if (frame.method === "device.pair.approve") { + const requestId = frame.params?.requestId; + if (requestId !== pendingRequestId) { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: false, + error: { code: "INVALID_REQUEST", message: "unknown requestId" }, + }), + ); + return; + } + approved = true; + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + requestId: pendingRequestId, + device: { + deviceId: lastSeenDeviceId ?? "device-unknown", + }, + }, + }), + ); + 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: "ok" }, + }, + }), + ); + 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((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((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + afterEach(() => { // no global mocks }); @@ -222,7 +424,7 @@ describe("openclaw gateway adapter execute", () => { const payload = gateway.getAgentPayload(); expect(payload).toBeTruthy(); expect(payload?.idempotencyKey).toBe("run-123"); - expect(payload?.sessionKey).toBe("paperclip"); + expect(payload?.sessionKey).toBe("paperclip:issue:issue-123"); 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"); @@ -238,6 +440,43 @@ describe("openclaw gateway adapter execute", () => { expect(result.exitCode).toBe(1); expect(result.errorCode).toBe("openclaw_gateway_url_missing"); }); + + it("auto-approves pairing once and retries the run", async () => { + const gateway = await createMockGatewayServerWithPairing(); + 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.summary).toContain("ok"); + expect(logs.some((entry) => entry.includes("pairing required; attempting automatic pairing approval"))).toBe( + true, + ); + expect(logs.some((entry) => entry.includes("auto-approved pairing request"))).toBe(true); + expect(gateway.getAgentPayload()).toBeTruthy(); + } finally { + await gateway.close(); + } + }); }); describe("openclaw gateway testEnvironment", () => { diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts new file mode 100644 index 00000000..68cb8759 --- /dev/null +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -0,0 +1,181 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockAccessService = vi.hoisted(() => ({ + hasPermission: vi.fn(), + canUser: vi.fn(), + isInstanceAdmin: vi.fn(), + getMembership: vi.fn(), + ensureMembership: vi.fn(), + listMembers: vi.fn(), + setMemberPermissions: vi.fn(), + promoteInstanceAdmin: vi.fn(), + demoteInstanceAdmin: vi.fn(), + listUserCompanyAccess: vi.fn(), + setUserCompanyAccess: vi.fn(), + setPrincipalGrants: vi.fn(), +})); + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + deduplicateAgentName: vi.fn(), + logActivity: mockLogActivity, + notifyHireApproved: vi.fn(), +})); + +function createDbStub() { + const createdInvite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "agent", + defaultsPayload: null, + expiresAt: new Date("2026-03-07T00:10:00.000Z"), + invitedByUserId: null, + tokenHash: "hash", + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + const returning = vi.fn().mockResolvedValue([createdInvite]); + const values = vi.fn().mockReturnValue({ returning }); + const insert = vi.fn().mockReturnValue({ values }); + return { + insert, + }; +} + +function createApp(actor: Record, db: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use( + "/api", + accessRoutes(db as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("POST /companies/:companyId/openclaw/invite-prompt", () => { + beforeEach(() => { + mockAccessService.canUser.mockResolvedValue(false); + mockAgentService.getById.mockReset(); + mockLogActivity.mockResolvedValue(undefined); + }); + + it("rejects non-CEO agent callers", async () => { + const db = createDbStub(); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "engineer", + }); + const app = createApp( + { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Only CEO agents"); + }); + + it("allows CEO agent callers and creates an agent-only invite", async () => { + const db = createDbStub(); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "ceo", + }); + const app = createApp( + { + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({ agentMessage: "Join and configure OpenClaw gateway." }); + + expect(res.status).toBe(201); + expect(res.body.allowedJoinTypes).toBe("agent"); + expect(typeof res.body.token).toBe("string"); + expect(res.body.onboardingTextPath).toContain("/api/invites/"); + }); + + it("allows board callers with invite permission", async () => { + const db = createDbStub(); + mockAccessService.canUser.mockResolvedValue(true); + const app = createApp( + { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({}); + + expect(res.status).toBe(201); + expect(res.body.allowedJoinTypes).toBe("agent"); + }); + + it("rejects board callers without invite permission", async () => { + const db = createDbStub(); + mockAccessService.canUser.mockResolvedValue(false); + const app = createApp( + { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, + db, + ); + + const res = await request(app) + .post("/api/companies/company-1/openclaw/invite-prompt") + .send({}); + + expect(res.status).toBe(403); + expect(res.body.error).toBe("Permission denied"); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index d9e153ed..9fe536a0 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -26,15 +26,6 @@ import { import { agentConfigurationDoc as openCodeAgentConfigurationDoc, } from "@paperclipai/adapter-opencode-local"; -import { - execute as openclawExecute, - testEnvironment as openclawTestEnvironment, - onHireApproved as openclawOnHireApproved, -} from "@paperclipai/adapter-openclaw/server"; -import { - agentConfigurationDoc as openclawAgentConfigurationDoc, - models as openclawModels, -} from "@paperclipai/adapter-openclaw"; import { execute as openclawGatewayExecute, testEnvironment as openclawGatewayTestEnvironment, @@ -89,16 +80,6 @@ const cursorLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: cursorAgentConfigurationDoc, }; -const openclawAdapter: ServerAdapterModule = { - type: "openclaw", - execute: openclawExecute, - testEnvironment: openclawTestEnvironment, - onHireApproved: openclawOnHireApproved, - models: openclawModels, - supportsLocalAgentJwt: false, - agentConfigurationDoc: openclawAgentConfigurationDoc, -}; - const openclawGatewayAdapter: ServerAdapterModule = { type: "openclaw_gateway", execute: openclawGatewayExecute, @@ -137,7 +118,6 @@ const adaptersByType = new Map( openCodeLocalAdapter, piLocalAdapter, cursorLocalAdapter, - openclawAdapter, openclawGatewayAdapter, processAdapter, httpAdapter, diff --git a/server/src/app.ts b/server/src/app.ts index d663654f..b21ec39f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -134,9 +134,10 @@ export async function createApp( ]; const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html"))); if (uiDist) { + const indexHtml = fs.readFileSync(path.join(uiDist, "index.html"), "utf-8"); app.use(express.static(uiDist)); app.get(/.*/, (_req, res) => { - res.sendFile("index.html", { root: uiDist }); + res.status(200).set("Content-Type", "text/html").end(indexHtml); }); } else { console.warn("[paperclip] UI dist not found; running in API-only mode"); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 186b8515..c13366ff 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -1,4 +1,9 @@ -import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { + createHash, + generateKeyPairSync, + randomBytes, + timingSafeEqual +} from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -16,6 +21,7 @@ import { acceptInviteSchema, claimJoinRequestApiKeySchema, createCompanyInviteSchema, + createOpenClawInvitePromptSchema, listJoinRequestsQuerySchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, @@ -130,19 +136,6 @@ function isLoopbackHost(hostname: string): boolean { return value === "localhost" || value === "127.0.0.1" || value === "::1"; } -function isWakePath(pathname: string): boolean { - const value = pathname.trim().toLowerCase(); - return value === "/hooks/wake" || value.endsWith("/hooks/wake"); -} - -function normalizeOpenClawTransport(value: unknown): "sse" | "webhook" | null { - if (typeof value !== "string") return "sse"; - const normalized = value.trim().toLowerCase(); - if (!normalized || normalized === "sse") return "sse"; - if (normalized === "webhook") return "webhook"; - return null; -} - function normalizeHostname(value: string | null | undefined): string | null { if (!value) return null; const trimmed = value.trim(); @@ -305,24 +298,40 @@ function headerMapGetIgnoreCase( return typeof value === "string" ? value : null; } -function toAuthorizationHeaderValue(rawToken: string): string { - const trimmed = rawToken.trim(); - if (!trimmed) return trimmed; - return /^bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`; +function tokenFromAuthorizationHeader(rawHeader: string | null): string | null { + const trimmed = nonEmptyTrimmedString(rawHeader); + if (!trimmed) return null; + const bearerMatch = trimmed.match(/^bearer\s+(.+)$/i); + if (bearerMatch?.[1]) { + return nonEmptyTrimmedString(bearerMatch[1]); + } + return trimmed; +} + +function parseBooleanLike(value: unknown): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1") return true; + if (normalized === "false" || normalized === "0") return false; + return null; +} + +function generateEd25519PrivateKeyPem(): string { + const generated = generateKeyPairSync("ed25519"); + return generated.privateKey + .export({ type: "pkcs8", format: "pem" }) + .toString(); } export function buildJoinDefaultsPayloadForAccept(input: { adapterType: string | null; defaultsPayload: unknown; - responsesWebhookUrl?: unknown; - responsesWebhookMethod?: unknown; - responsesWebhookHeaders?: unknown; paperclipApiUrl?: unknown; - webhookAuthHeader?: unknown; inboundOpenClawAuthHeader?: string | null; inboundOpenClawTokenHeader?: string | null; }): unknown { - if (input.adapterType !== "openclaw") { + if (input.adapterType !== "openclaw_gateway") { return input.defaultsPayload; } @@ -330,40 +339,11 @@ export function buildJoinDefaultsPayloadForAccept(input: { ? { ...(input.defaultsPayload as Record) } : ({} as Record); - if (!nonEmptyTrimmedString(merged.url)) { - const legacyUrl = nonEmptyTrimmedString(input.responsesWebhookUrl); - if (legacyUrl) merged.url = legacyUrl; - } - - if (!nonEmptyTrimmedString(merged.method)) { - const legacyMethod = nonEmptyTrimmedString(input.responsesWebhookMethod); - if (legacyMethod) merged.method = legacyMethod.toUpperCase(); - } - if (!nonEmptyTrimmedString(merged.paperclipApiUrl)) { const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl); if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl; } - - if (!nonEmptyTrimmedString(merged.webhookAuthHeader)) { - const providedWebhookAuthHeader = nonEmptyTrimmedString( - input.webhookAuthHeader - ); - if (providedWebhookAuthHeader) - merged.webhookAuthHeader = providedWebhookAuthHeader; - } - const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {}; - const compatibilityHeaders = normalizeHeaderMap( - input.responsesWebhookHeaders - ); - if (compatibilityHeaders) { - for (const [key, value] of Object.entries(compatibilityHeaders)) { - if (!headerMapHasKeyIgnoreCase(mergedHeaders, key)) { - mergedHeaders[key] = value; - } - } - } const inboundOpenClawAuthHeader = nonEmptyTrimmedString( input.inboundOpenClawAuthHeader @@ -390,23 +370,17 @@ export function buildJoinDefaultsPayloadForAccept(input: { delete merged.headers; } - const hasAuthorizationHeader = headerMapHasKeyIgnoreCase( - mergedHeaders, - "authorization" - ); - const hasWebhookAuthHeader = Boolean( - nonEmptyTrimmedString(merged.webhookAuthHeader) - ); - if (!hasAuthorizationHeader && !hasWebhookAuthHeader) { - const openClawAuthToken = - headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ?? - headerMapGetIgnoreCase( - mergedHeaders, - "x-openclaw-auth" + const discoveredToken = + headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ?? + headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth") ?? + tokenFromAuthorizationHeader( + headerMapGetIgnoreCase(mergedHeaders, "authorization") ); - if (openClawAuthToken) { - merged.webhookAuthHeader = toAuthorizationHeaderValue(openClawAuthToken); - } + if ( + discoveredToken && + !headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token") + ) { + mergedHeaders["x-openclaw-token"] = discoveredToken; } return Object.keys(merged).length > 0 ? merged : null; @@ -452,7 +426,7 @@ export function mergeJoinDefaultsPayloadForReplay( return merged; } -export function canReplayOpenClawInviteAccept(input: { +export function canReplayOpenClawGatewayInviteAccept(input: { requestType: "human" | "agent"; adapterType: string | null; existingJoinRequest: Pick< @@ -460,7 +434,10 @@ export function canReplayOpenClawInviteAccept(input: { "requestType" | "adapterType" | "status" > | null; }): boolean { - if (input.requestType !== "agent" || input.adapterType !== "openclaw") { + if ( + input.requestType !== "agent" || + input.adapterType !== "openclaw_gateway" + ) { return false; } if (!input.existingJoinRequest) { @@ -468,7 +445,7 @@ export function canReplayOpenClawInviteAccept(input: { } if ( input.existingJoinRequest.requestType !== "agent" || - input.existingJoinRequest.adapterType !== "openclaw" + input.existingJoinRequest.adapterType !== "openclaw_gateway" ) { return false; } @@ -490,106 +467,44 @@ function summarizeSecretForLog( }; } -function summarizeOpenClawDefaultsForLog(defaultsPayload: unknown) { +function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) { const defaults = isPlainObject(defaultsPayload) ? (defaultsPayload as Record) : null; const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined; - const openClawAuthHeaderValue = headers + const gatewayTokenValue = headers ? headerMapGetIgnoreCase(headers, "x-openclaw-token") ?? - headerMapGetIgnoreCase(headers, "x-openclaw-auth") + headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? + tokenFromAuthorizationHeader( + headerMapGetIgnoreCase(headers, "authorization") + ) : null; - return { present: Boolean(defaults), keys: defaults ? Object.keys(defaults).sort() : [], url: defaults ? nonEmptyTrimmedString(defaults.url) : null, - method: defaults ? nonEmptyTrimmedString(defaults.method) : null, paperclipApiUrl: defaults ? nonEmptyTrimmedString(defaults.paperclipApiUrl) : null, headerKeys: headers ? Object.keys(headers).sort() : [], - webhookAuthHeader: defaults - ? summarizeSecretForLog(defaults.webhookAuthHeader) + sessionKeyStrategy: defaults + ? nonEmptyTrimmedString(defaults.sessionKeyStrategy) : null, - openClawAuthHeader: summarizeSecretForLog(openClawAuthHeaderValue) + disableDeviceAuth: defaults + ? parseBooleanLike(defaults.disableDeviceAuth) + : null, + waitTimeoutMs: + defaults && typeof defaults.waitTimeoutMs === "number" + ? defaults.waitTimeoutMs + : null, + devicePrivateKeyPem: defaults + ? summarizeSecretForLog(defaults.devicePrivateKeyPem) + : null, + gatewayToken: summarizeSecretForLog(gatewayTokenValue) }; } -function buildJoinConnectivityDiagnostics(input: { - deploymentMode: DeploymentMode; - deploymentExposure: DeploymentExposure; - bindHost: string; - allowedHostnames: string[]; - callbackUrl: URL | null; -}): JoinDiagnostic[] { - const diagnostics: JoinDiagnostic[] = []; - const bindHost = normalizeHostname(input.bindHost); - const callbackHost = input.callbackUrl - ? normalizeHostname(input.callbackUrl.hostname) - : null; - const allowSet = new Set( - input.allowedHostnames - .map((entry) => normalizeHostname(entry)) - .filter((entry): entry is string => Boolean(entry)) - ); - - diagnostics.push({ - code: "openclaw_deployment_context", - level: "info", - message: `Deployment context: mode=${input.deploymentMode}, exposure=${input.deploymentExposure}.` - }); - - if ( - input.deploymentMode === "authenticated" && - input.deploymentExposure === "private" - ) { - if (!bindHost || isLoopbackHost(bindHost)) { - diagnostics.push({ - code: "openclaw_private_bind_loopback", - level: "warn", - message: - "Paperclip is bound to loopback in authenticated/private mode.", - hint: "Bind to a reachable private hostname/IP for remote OpenClaw callbacks." - }); - } - if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) { - diagnostics.push({ - code: "openclaw_private_bind_not_allowed", - level: "warn", - message: `Paperclip bind host \"${bindHost}\" is not in allowed hostnames.`, - hint: `Run pnpm paperclipai allowed-hostname ${bindHost}` - }); - } - if (callbackHost && !isLoopbackHost(callbackHost) && allowSet.size === 0) { - diagnostics.push({ - code: "openclaw_private_allowed_hostnames_empty", - level: "warn", - message: - "No explicit allowed hostnames are configured for authenticated/private mode.", - hint: "Set one with pnpm paperclipai allowed-hostname when OpenClaw runs off-host." - }); - } - } - - if ( - input.deploymentMode === "authenticated" && - input.deploymentExposure === "public" && - input.callbackUrl && - input.callbackUrl.protocol !== "https:" - ) { - diagnostics.push({ - code: "openclaw_public_http_callback", - level: "warn", - message: "OpenClaw callback URL uses HTTP in authenticated/public mode.", - hint: "Prefer HTTPS for public deployments." - }); - } - - return diagnostics; -} - -function normalizeAgentDefaultsForJoin(input: { +export function normalizeAgentDefaultsForJoin(input: { adapterType: string | null; defaultsPayload: unknown; deploymentMode: DeploymentMode; @@ -597,140 +512,116 @@ function normalizeAgentDefaultsForJoin(input: { bindHost: string; allowedHostnames: string[]; }) { + const fatalErrors: string[] = []; const diagnostics: JoinDiagnostic[] = []; - if (input.adapterType !== "openclaw") { + if (input.adapterType !== "openclaw_gateway") { const normalized = isPlainObject(input.defaultsPayload) ? (input.defaultsPayload as Record) : null; - return { normalized, diagnostics }; + return { normalized, diagnostics, fatalErrors }; } if (!isPlainObject(input.defaultsPayload)) { diagnostics.push({ - code: "openclaw_callback_config_missing", + code: "openclaw_gateway_defaults_missing", level: "warn", message: - "No OpenClaw callback config was provided in agentDefaultsPayload.", - hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw endpoint immediately after approval." + "No OpenClaw gateway config was provided in agentDefaultsPayload.", + hint: + "Include agentDefaultsPayload.url and headers.x-openclaw-token for OpenClaw gateway joins." }); - return { normalized: null as Record | null, diagnostics }; + fatalErrors.push( + "agentDefaultsPayload is required for adapterType=openclaw_gateway" + ); + return { + normalized: null as Record | null, + diagnostics, + fatalErrors + }; } const defaults = input.defaultsPayload as Record; - const streamTransportInput = defaults.streamTransport ?? defaults.transport; - const streamTransport = normalizeOpenClawTransport(streamTransportInput); - const normalized: Record = { streamTransport: "sse" }; - if (!streamTransport) { - diagnostics.push({ - code: "openclaw_stream_transport_unsupported", - level: "warn", - message: `Unsupported streamTransport: ${String(streamTransportInput)}`, - hint: "Use streamTransport=sse or streamTransport=webhook." - }); - } else { - normalized.streamTransport = streamTransport; - } + const normalized: Record = {}; - let callbackUrl: URL | null = null; - const rawUrl = typeof defaults.url === "string" ? defaults.url.trim() : ""; - if (!rawUrl) { + let gatewayUrl: URL | null = null; + const rawGatewayUrl = nonEmptyTrimmedString(defaults.url); + if (!rawGatewayUrl) { diagnostics.push({ - code: "openclaw_callback_url_missing", + code: "openclaw_gateway_url_missing", level: "warn", - message: "OpenClaw callback URL is missing.", - hint: "Set agentDefaultsPayload.url to your OpenClaw endpoint." + message: "OpenClaw gateway URL is missing.", + hint: "Set agentDefaultsPayload.url to ws:// or wss:// gateway URL." }); + fatalErrors.push("agentDefaultsPayload.url is required"); } else { try { - callbackUrl = new URL(rawUrl); - if ( - callbackUrl.protocol !== "http:" && - callbackUrl.protocol !== "https:" - ) { + gatewayUrl = new URL(rawGatewayUrl); + if (gatewayUrl.protocol !== "ws:" && gatewayUrl.protocol !== "wss:") { diagnostics.push({ - code: "openclaw_callback_url_protocol", + code: "openclaw_gateway_url_protocol", level: "warn", - message: `Unsupported callback protocol: ${callbackUrl.protocol}`, - hint: "Use http:// or https://." + message: `OpenClaw gateway URL must use ws:// or wss:// (got ${gatewayUrl.protocol}).` }); + fatalErrors.push( + "agentDefaultsPayload.url must use ws:// or wss:// for openclaw_gateway" + ); } else { - normalized.url = callbackUrl.toString(); + normalized.url = gatewayUrl.toString(); diagnostics.push({ - code: "openclaw_callback_url_configured", + code: "openclaw_gateway_url_configured", level: "info", - message: `Callback endpoint set to ${callbackUrl.toString()}` - }); - } - if ((streamTransport ?? "sse") === "sse" && isWakePath(callbackUrl.pathname)) { - diagnostics.push({ - code: "openclaw_callback_wake_path_incompatible", - level: "warn", - message: - "Configured callback path targets /hooks/wake, which is not stream-capable for SSE transport.", - hint: "Use an endpoint that returns text/event-stream for the full run duration." - }); - } - if (isLoopbackHost(callbackUrl.hostname)) { - diagnostics.push({ - code: "openclaw_callback_loopback", - level: "warn", - message: "OpenClaw callback endpoint uses loopback hostname.", - hint: "Use a reachable hostname/IP when OpenClaw runs on another machine." + message: `Gateway endpoint set to ${gatewayUrl.toString()}` }); } } catch { diagnostics.push({ - code: "openclaw_callback_url_invalid", + code: "openclaw_gateway_url_invalid", level: "warn", - message: `Invalid callback URL: ${rawUrl}` + message: `Invalid OpenClaw gateway URL: ${rawGatewayUrl}` }); + fatalErrors.push("agentDefaultsPayload.url is not a valid URL"); } } - const rawMethod = - typeof defaults.method === "string" - ? defaults.method.trim().toUpperCase() - : ""; - normalized.method = rawMethod || "POST"; - - if ( - typeof defaults.timeoutSec === "number" && - Number.isFinite(defaults.timeoutSec) - ) { - normalized.timeoutSec = Math.max( - 0, - Math.min(7200, Math.floor(defaults.timeoutSec)) - ); + const headers = normalizeHeaderMap(defaults.headers) ?? {}; + const gatewayToken = + headerMapGetIgnoreCase(headers, "x-openclaw-token") ?? + headerMapGetIgnoreCase(headers, "x-openclaw-auth") ?? + tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization")); + if (gatewayToken && !headerMapHasKeyIgnoreCase(headers, "x-openclaw-token")) { + headers["x-openclaw-token"] = gatewayToken; + } + if (Object.keys(headers).length > 0) { + normalized.headers = headers; } - const headers = normalizeHeaderMap(defaults.headers); - if (headers) normalized.headers = headers; - - if ( - typeof defaults.webhookAuthHeader === "string" && - defaults.webhookAuthHeader.trim() - ) { - normalized.webhookAuthHeader = defaults.webhookAuthHeader.trim(); - } - - const openClawAuthHeader = headers - ? headerMapGetIgnoreCase(headers, "x-openclaw-token") ?? - headerMapGetIgnoreCase(headers, "x-openclaw-auth") - : null; - if (openClawAuthHeader) { + if (!gatewayToken) { diagnostics.push({ - code: "openclaw_auth_header_configured", - level: "info", - message: - "Gateway auth token received via headers.x-openclaw-token (or legacy x-openclaw-auth)." - }); - } else { - diagnostics.push({ - code: "openclaw_auth_header_missing", + code: "openclaw_gateway_auth_header_missing", level: "warn", message: "Gateway auth token is missing from agent defaults.", hint: - "Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth) to the token your OpenClaw endpoint requires." + "Set agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth)." + }); + fatalErrors.push( + "agentDefaultsPayload.headers.x-openclaw-token (or x-openclaw-auth) is required" + ); + } else if (gatewayToken.trim().length < 16) { + diagnostics.push({ + code: "openclaw_gateway_auth_header_too_short", + level: "warn", + message: `Gateway auth token appears too short (${gatewayToken.trim().length} chars).`, + hint: + "Use the full gateway auth token from ~/.openclaw/openclaw.json (typically long random string)." + }); + fatalErrors.push( + "agentDefaultsPayload.headers.x-openclaw-token is too short; expected a full gateway token" + ); + } else { + diagnostics.push({ + code: "openclaw_gateway_auth_header_configured", + level: "info", + message: "Gateway auth token configured." }); } @@ -738,6 +629,98 @@ function normalizeAgentDefaultsForJoin(input: { normalized.payloadTemplate = defaults.payloadTemplate; } + const parsedDisableDeviceAuth = parseBooleanLike(defaults.disableDeviceAuth); + const disableDeviceAuth = parsedDisableDeviceAuth === true; + if (parsedDisableDeviceAuth !== null) { + normalized.disableDeviceAuth = parsedDisableDeviceAuth; + } + + const configuredDevicePrivateKeyPem = nonEmptyTrimmedString( + defaults.devicePrivateKeyPem + ); + if (configuredDevicePrivateKeyPem) { + normalized.devicePrivateKeyPem = configuredDevicePrivateKeyPem; + diagnostics.push({ + code: "openclaw_gateway_device_key_configured", + level: "info", + message: + "Gateway device key configured. Pairing approvals should persist for this agent." + }); + } else if (!disableDeviceAuth) { + try { + normalized.devicePrivateKeyPem = generateEd25519PrivateKeyPem(); + diagnostics.push({ + code: "openclaw_gateway_device_key_generated", + level: "info", + message: + "Generated persistent gateway device key for this join. Pairing approvals should persist for this agent." + }); + } catch (err) { + diagnostics.push({ + code: "openclaw_gateway_device_key_generate_failed", + level: "warn", + message: `Failed to generate gateway device key: ${ + err instanceof Error ? err.message : String(err) + }`, + hint: + "Set agentDefaultsPayload.devicePrivateKeyPem explicitly or set disableDeviceAuth=true." + }); + fatalErrors.push( + "Failed to generate gateway device key. Set devicePrivateKeyPem or disableDeviceAuth=true." + ); + } + } + + const waitTimeoutMs = + typeof defaults.waitTimeoutMs === "number" && + Number.isFinite(defaults.waitTimeoutMs) + ? Math.floor(defaults.waitTimeoutMs) + : typeof defaults.waitTimeoutMs === "string" + ? Number.parseInt(defaults.waitTimeoutMs.trim(), 10) + : NaN; + if (Number.isFinite(waitTimeoutMs) && waitTimeoutMs > 0) { + normalized.waitTimeoutMs = waitTimeoutMs; + } + + const timeoutSec = + typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec) + ? Math.floor(defaults.timeoutSec) + : typeof defaults.timeoutSec === "string" + ? Number.parseInt(defaults.timeoutSec.trim(), 10) + : NaN; + if (Number.isFinite(timeoutSec) && timeoutSec > 0) { + normalized.timeoutSec = timeoutSec; + } + + const sessionKeyStrategy = nonEmptyTrimmedString(defaults.sessionKeyStrategy); + if ( + sessionKeyStrategy === "fixed" || + sessionKeyStrategy === "issue" || + sessionKeyStrategy === "run" + ) { + normalized.sessionKeyStrategy = sessionKeyStrategy; + } + + const sessionKey = nonEmptyTrimmedString(defaults.sessionKey); + if (sessionKey) { + normalized.sessionKey = sessionKey; + } + + const role = nonEmptyTrimmedString(defaults.role); + if (role) { + normalized.role = role; + } + + if (Array.isArray(defaults.scopes)) { + const scopes = defaults.scopes + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); + if (scopes.length > 0) { + normalized.scopes = scopes; + } + } + const rawPaperclipApiUrl = typeof defaults.paperclipApiUrl === "string" ? defaults.paperclipApiUrl.trim() @@ -750,47 +733,28 @@ function normalizeAgentDefaultsForJoin(input: { parsedPaperclipApiUrl.protocol !== "https:" ) { diagnostics.push({ - code: "openclaw_paperclip_api_url_protocol", + code: "openclaw_gateway_paperclip_api_url_protocol", level: "warn", message: `paperclipApiUrl must use http:// or https:// (got ${parsedPaperclipApiUrl.protocol}).` }); } else { normalized.paperclipApiUrl = parsedPaperclipApiUrl.toString(); diagnostics.push({ - code: "openclaw_paperclip_api_url_configured", + code: "openclaw_gateway_paperclip_api_url_configured", level: "info", message: `paperclipApiUrl set to ${parsedPaperclipApiUrl.toString()}` }); - if (isLoopbackHost(parsedPaperclipApiUrl.hostname)) { - diagnostics.push({ - code: "openclaw_paperclip_api_url_loopback", - level: "warn", - message: - "paperclipApiUrl uses loopback hostname. Remote OpenClaw workers cannot reach localhost on the Paperclip host.", - hint: "Use a reachable hostname/IP and keep it in allowed hostnames for authenticated/private deployments." - }); - } } } catch { diagnostics.push({ - code: "openclaw_paperclip_api_url_invalid", + code: "openclaw_gateway_paperclip_api_url_invalid", level: "warn", message: `Invalid paperclipApiUrl: ${rawPaperclipApiUrl}` }); } } - diagnostics.push( - ...buildJoinConnectivityDiagnostics({ - deploymentMode: input.deploymentMode, - deploymentExposure: input.deploymentExposure, - bindHost: input.bindHost, - allowedHostnames: input.allowedHostnames, - callbackUrl - }) - ); - - return { normalized, diagnostics }; + return { normalized, diagnostics, fatalErrors }; } function toInviteSummaryResponse( @@ -950,10 +914,6 @@ function buildInviteOnboardingManifest( const onboardingTextUrl = baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath; - const testResolutionPath = `/api/invites/${token}/test-resolution`; - const testResolutionUrl = baseUrl - ? `${baseUrl}${testResolutionPath}` - : testResolutionPath; const discoveryDiagnostics = buildOnboardingDiscoveryDiagnostics({ apiBaseUrl: baseUrl, deploymentMode: opts.deploymentMode, @@ -971,16 +931,16 @@ function buildInviteOnboardingManifest( invite: toInviteSummaryResponse(req, token, invite), onboarding: { instructions: - "Join as an OpenClaw agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST include agentDefaultsPayload.headers.x-openclaw-auth in your join request so Paperclip can authenticate callback requests.", + "Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).", inviteMessage: extractInviteMessage(invite), - recommendedAdapterType: "openclaw", + recommendedAdapterType: "openclaw_gateway", requiredFields: { requestType: "agent", agentName: "Display name for this agent", - adapterType: "Use 'openclaw' for OpenClaw agents", + adapterType: "Use 'openclaw_gateway' for OpenClaw Gateway agents", capabilities: "Optional capability summary", agentDefaultsPayload: - "Adapter config for OpenClaw endpoint. MUST include headers.x-openclaw-auth; include streamTransport ('sse' or 'webhook') plus url/method/paperclipApiUrl (and optional webhookAuthHeader/timeoutSec/payloadTemplate)." + "Adapter config for OpenClaw gateway. MUST include url (ws:// or wss://) and headers.x-openclaw-token (or legacy x-openclaw-auth). Optional fields: paperclipApiUrl, waitTimeoutMs, sessionKeyStrategy, sessionKey, role, scopes, disableDeviceAuth, devicePrivateKeyPem." }, registrationEndpoint: { method: "POST", @@ -1001,21 +961,12 @@ function buildInviteOnboardingManifest( bindHost: opts.bindHost, allowedHostnames: opts.allowedHostnames, connectionCandidates, - testResolutionEndpoint: { - method: "GET", - path: testResolutionPath, - url: testResolutionUrl, - query: { - url: "https://your-openclaw-agent.example/v1/responses", - timeoutMs: 5000 - } - }, diagnostics: discoveryDiagnostics, guidance: opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private" ? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm paperclipai allowed-hostname `." - : "Ensure OpenClaw can reach this Paperclip API base URL for callbacks and claims." + : "Ensure OpenClaw can reach this Paperclip API base URL for invite, claim, and skill bootstrap calls." }, textInstructions: { path: onboardingTextPath, @@ -1076,7 +1027,7 @@ export function buildInviteOnboardingTextDocument( }; appendBlock(` - # Paperclip OpenClaw Onboarding + # Paperclip OpenClaw Gateway Onboarding This document is meant to be readable by both humans and agents. @@ -1104,6 +1055,7 @@ export function buildInviteOnboardingTextDocument( TOKEN="$(node -p 'require(process.env.HOME+\"/.openclaw/openclaw.json\").gateway.auth.token')" test -n "$TOKEN" || (echo "Missing TOKEN" && exit 1) + test "\${#TOKEN}" -ge 16 || (echo "Gateway token unexpectedly short (\${#TOKEN})" && exit 1) 3) IMPORTANT: Don't accidentally drop the token when generating JSON If you build JSON with Node, pass the token explicitly (argv), don't rely on an un-exported env var. @@ -1116,15 +1068,16 @@ export function buildInviteOnboardingTextDocument( const body = { requestType: "agent", agentName: "OpenClaw", - adapterType: "openclaw", + adapterType: "openclaw_gateway", capabilities: "OpenClaw agent adapter", agentDefaultsPayload: { - url: "http://127.0.0.1:18789/v1/responses", + url: "ws://127.0.0.1:18789", paperclipApiUrl: "http://host.docker.internal:3100", - streamTransport: "sse", - method: "POST", - headers: { "x-openclaw-auth": token }, - timeoutSec: 0 + headers: { "x-openclaw-token": token }, + waitTimeoutMs: 120000, + sessionKeyStrategy: "issue", + role: "operator", + scopes: ["operator.admin"] } }; process.stdout.write(JSON.stringify(body)); @@ -1135,23 +1088,29 @@ export function buildInviteOnboardingTextDocument( onboarding.registrationEndpoint.url } - IMPORTANT: You MUST include agentDefaultsPayload.headers.x-openclaw-auth with your gateway token. - Without this token, Paperclip callback requests to your OpenClaw endpoint will fail with 401 Unauthorized. - Set "streamTransport" to "sse" for streaming /v1/responses endpoints, or "webhook" for wake-style callbacks. + IMPORTANT: You MUST include agentDefaultsPayload.headers.x-openclaw-token with your gateway token. + Legacy x-openclaw-auth is also accepted, but x-openclaw-token is preferred. + Use adapterType "openclaw_gateway" and a ws:// or wss:// gateway URL. + Pairing mode requirement: + - Keep device auth enabled (recommended). If devicePrivateKeyPem is omitted, Paperclip generates and persists one during join so pairing approvals are stable. + - You may set disableDeviceAuth=true only for special environments that cannot support pairing. + - First run may return "pairing required" once; approve the pending pairing request in OpenClaw, then retry. + Do NOT use /v1/responses or /hooks/* in this gateway join flow. Body (JSON): { "requestType": "agent", "agentName": "My OpenClaw Agent", - "adapterType": "openclaw", + "adapterType": "openclaw_gateway", "capabilities": "Optional summary", "agentDefaultsPayload": { - "url": "https://your-openclaw-agent.example/v1/responses", + "url": "wss://your-openclaw-gateway.example", "paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100", - "streamTransport": "sse", - "method": "POST", - "headers": { "x-openclaw-auth": "replace-me" }, - "timeoutSec": 0 + "headers": { "x-openclaw-token": "replace-me" }, + "waitTimeoutMs": 120000, + "sessionKeyStrategy": "issue", + "role": "operator", + "scopes": ["operator.admin"] } } @@ -1160,11 +1119,6 @@ export function buildInviteOnboardingTextDocument( - one-time claimSecret - claimApiKeyPath - Verify the response diagnostics include: - 'openclaw_auth_header_configured' - and do not include: - 'openclaw_auth_header_missing' - ## Step 2: Wait for board approval The board approves the join request in Paperclip before key claim is allowed. @@ -1218,17 +1172,6 @@ export function buildInviteOnboardingTextDocument( } `); - if (onboarding.connectivity?.testResolutionEndpoint?.url) { - appendBlock(` - ## Optional: test callback resolution from Paperclip - ${onboarding.connectivity.testResolutionEndpoint.method ?? "GET"} ${ - onboarding.connectivity.testResolutionEndpoint.url - }?url=https%3A%2F%2Fyour-openclaw-agent.example%2Fv1%2Fresponses - - This endpoint checks whether Paperclip can reach your OpenClaw endpoint and reports reachable, timeout, or unreachable. - `); - } - const connectionCandidates = Array.isArray( onboarding.connectivity?.connectionCandidates ) @@ -1271,9 +1214,6 @@ export function buildInviteOnboardingTextDocument( ${onboarding.skill.path} ${manifest.invite.onboardingPath} `); - if (onboarding.connectivity?.testResolutionEndpoint?.path) { - lines.push(`${onboarding.connectivity.testResolutionEndpoint.path}`); - } return `${lines.join("\n")}\n`; } @@ -1592,6 +1532,80 @@ export function accessRoutes( if (!allowed) throw forbidden("Permission denied"); } + async function assertCanGenerateOpenClawInvitePrompt( + req: Request, + companyId: string + ) { + assertCompanyAccess(req, companyId); + if (req.actor.type === "agent") { + if (!req.actor.agentId) throw forbidden("Agent authentication required"); + const actorAgent = await agents.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + throw forbidden("Agent key cannot access another company"); + } + if (actorAgent.role !== "ceo") { + throw forbidden("Only CEO agents can generate OpenClaw invite prompts"); + } + return; + } + if (req.actor.type !== "board") throw unauthorized(); + if (isLocalImplicit(req)) return; + const allowed = await access.canUser(companyId, req.actor.userId, "users:invite"); + if (!allowed) throw forbidden("Permission denied"); + } + + async function createCompanyInviteForCompany(input: { + req: Request; + companyId: string; + allowedJoinTypes: "human" | "agent" | "both"; + defaultsPayload?: Record | null; + agentMessage?: string | null; + }) { + const normalizedAgentMessage = + typeof input.agentMessage === "string" + ? input.agentMessage.trim() || null + : null; + const insertValues = { + companyId: input.companyId, + inviteType: "company_join" as const, + allowedJoinTypes: input.allowedJoinTypes, + defaultsPayload: mergeInviteDefaults( + input.defaultsPayload ?? null, + normalizedAgentMessage + ), + expiresAt: companyInviteExpiresAt(), + invitedByUserId: input.req.actor.userId ?? null + }; + + let token: string | null = null; + let created: typeof invites.$inferSelect | null = null; + for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) { + const candidateToken = createInviteToken(); + try { + const row = await db + .insert(invites) + .values({ + ...insertValues, + tokenHash: hashToken(candidateToken) + }) + .returning() + .then((rows) => rows[0]); + token = candidateToken; + created = row; + break; + } catch (error) { + if (!isInviteTokenHashCollisionError(error)) { + throw error; + } + } + } + if (!token || !created) { + throw conflict("Failed to generate a unique invite token. Please retry."); + } + + return { token, created, normalizedAgentMessage }; + } + router.get("/skills/index", (_req, res) => { res.json({ skills: [ @@ -1617,49 +1631,14 @@ export function accessRoutes( async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "users:invite"); - const normalizedAgentMessage = - typeof req.body.agentMessage === "string" - ? req.body.agentMessage.trim() || null - : null; - const insertValues = { - companyId, - inviteType: "company_join" as const, - allowedJoinTypes: req.body.allowedJoinTypes, - defaultsPayload: mergeInviteDefaults( - req.body.defaultsPayload ?? null, - normalizedAgentMessage - ), - expiresAt: companyInviteExpiresAt(), - invitedByUserId: req.actor.userId ?? null - }; - - let token: string | null = null; - let created: typeof invites.$inferSelect | null = null; - for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) { - const candidateToken = createInviteToken(); - try { - const row = await db - .insert(invites) - .values({ - ...insertValues, - tokenHash: hashToken(candidateToken) - }) - .returning() - .then((rows) => rows[0]); - token = candidateToken; - created = row; - break; - } catch (error) { - if (!isInviteTokenHashCollisionError(error)) { - throw error; - } - } - } - if (!token || !created) { - throw conflict( - "Failed to generate a unique invite token. Please retry." - ); - } + const { token, created, normalizedAgentMessage } = + await createCompanyInviteForCompany({ + req, + companyId, + allowedJoinTypes: req.body.allowedJoinTypes, + defaultsPayload: req.body.defaultsPayload ?? null, + agentMessage: req.body.agentMessage ?? null + }); await logActivity(db, { companyId, @@ -1691,6 +1670,51 @@ export function accessRoutes( } ); + router.post( + "/companies/:companyId/openclaw/invite-prompt", + validate(createOpenClawInvitePromptSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanGenerateOpenClawInvitePrompt(req, companyId); + const { token, created, normalizedAgentMessage } = + await createCompanyInviteForCompany({ + req, + companyId, + allowedJoinTypes: "agent", + defaultsPayload: null, + agentMessage: req.body.agentMessage ?? null + }); + + await logActivity(db, { + companyId, + actorType: req.actor.type === "agent" ? "agent" : "user", + actorId: + req.actor.type === "agent" + ? req.actor.agentId ?? "unknown-agent" + : req.actor.userId ?? "board", + action: "invite.openclaw_prompt_created", + entityType: "invite", + entityId: created.id, + details: { + inviteType: created.inviteType, + allowedJoinTypes: created.allowedJoinTypes, + expiresAt: created.expiresAt.toISOString(), + hasAgentMessage: Boolean(normalizedAgentMessage) + } + }); + + const inviteSummary = toInviteSummaryResponse(req, token, created); + res.status(201).json({ + ...created, + token, + inviteUrl: `/invite/${token}`, + onboardingTextPath: inviteSummary.onboardingTextPath, + onboardingTextUrl: inviteSummary.onboardingTextUrl, + inviteMessage: inviteSummary.inviteMessage + }); + } + ); + router.get("/invites/:token", async (req, res) => { const token = (req.params.token as string).trim(); if (!token) throw notFound("Invite not found"); @@ -1876,7 +1900,7 @@ export function accessRoutes( const adapterType = req.body.adapterType ?? null; if ( inviteAlreadyAccepted && - !canReplayOpenClawInviteAccept({ + !canReplayOpenClawGatewayInviteAccept({ requestType, adapterType, existingJoinRequest: existingJoinRequestForInvite @@ -1898,59 +1922,22 @@ export function accessRoutes( ) : req.body.agentDefaultsPayload ?? null; - const openClawDefaultsPayload = + const gatewayDefaultsPayload = requestType === "agent" ? buildJoinDefaultsPayloadForAccept({ adapterType, defaultsPayload: replayMergedDefaults, - responsesWebhookUrl: req.body.responsesWebhookUrl ?? null, - responsesWebhookMethod: req.body.responsesWebhookMethod ?? null, - responsesWebhookHeaders: req.body.responsesWebhookHeaders ?? null, paperclipApiUrl: req.body.paperclipApiUrl ?? null, - webhookAuthHeader: req.body.webhookAuthHeader ?? null, inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null, inboundOpenClawTokenHeader: req.header("x-openclaw-token") ?? null }) : null; - if (requestType === "agent" && adapterType === "openclaw") { - logger.info( - { - inviteId: invite.id, - requestType, - adapterType, - bodyKeys: isPlainObject(req.body) - ? Object.keys(req.body).sort() - : [], - responsesWebhookUrl: nonEmptyTrimmedString( - req.body.responsesWebhookUrl - ), - paperclipApiUrl: nonEmptyTrimmedString(req.body.paperclipApiUrl), - webhookAuthHeader: summarizeSecretForLog( - req.body.webhookAuthHeader - ), - inboundOpenClawAuthHeader: summarizeSecretForLog( - req.header("x-openclaw-auth") ?? null - ), - inboundOpenClawTokenHeader: summarizeSecretForLog( - req.header("x-openclaw-token") ?? null - ), - rawAgentDefaults: summarizeOpenClawDefaultsForLog( - req.body.agentDefaultsPayload ?? null - ), - mergedAgentDefaults: summarizeOpenClawDefaultsForLog( - openClawDefaultsPayload - ) - }, - "invite accept received OpenClaw join payload" - ); - } - const joinDefaults = requestType === "agent" ? normalizeAgentDefaultsForJoin({ adapterType, - defaultsPayload: openClawDefaultsPayload, + defaultsPayload: gatewayDefaultsPayload, deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, bindHost: opts.bindHost, @@ -1958,10 +1945,15 @@ export function accessRoutes( }) : { normalized: null as Record | null, - diagnostics: [] as JoinDiagnostic[] + diagnostics: [] as JoinDiagnostic[], + fatalErrors: [] as string[] }; - if (requestType === "agent" && adapterType === "openclaw") { + if (requestType === "agent" && joinDefaults.fatalErrors.length > 0) { + throw badRequest(joinDefaults.fatalErrors.join("; ")); + } + + if (requestType === "agent" && adapterType === "openclaw_gateway") { logger.info( { inviteId: invite.id, @@ -1969,11 +1961,11 @@ export function accessRoutes( code: diag.code, level: diag.level })), - normalizedAgentDefaults: summarizeOpenClawDefaultsForLog( + normalizedAgentDefaults: summarizeOpenClawGatewayDefaultsForLog( joinDefaults.normalized ) }, - "invite accept normalized OpenClaw defaults" + "invite accept normalized OpenClaw gateway defaults" ); } @@ -2062,7 +2054,7 @@ export function accessRoutes( if ( inviteAlreadyAccepted && requestType === "agent" && - adapterType === "openclaw" && + adapterType === "openclaw_gateway" && created.status === "approved" && created.createdAgentId ) { @@ -2098,11 +2090,11 @@ export function accessRoutes( }); } - if (requestType === "agent" && adapterType === "openclaw") { - const expectedDefaults = summarizeOpenClawDefaultsForLog( + if (requestType === "agent" && adapterType === "openclaw_gateway") { + const expectedDefaults = summarizeOpenClawGatewayDefaultsForLog( joinDefaults.normalized ); - const persistedDefaults = summarizeOpenClawDefaultsForLog( + const persistedDefaults = summarizeOpenClawGatewayDefaultsForLog( created.agentDefaultsPayload ); const missingPersistedFields: string[] = []; @@ -2115,19 +2107,14 @@ export function accessRoutes( ) { missingPersistedFields.push("paperclipApiUrl"); } - if ( - expectedDefaults.webhookAuthHeader && - !persistedDefaults.webhookAuthHeader - ) { - missingPersistedFields.push("webhookAuthHeader"); + if (expectedDefaults.gatewayToken && !persistedDefaults.gatewayToken) { + missingPersistedFields.push("headers.x-openclaw-token"); } if ( - expectedDefaults.openClawAuthHeader && - !persistedDefaults.openClawAuthHeader + expectedDefaults.devicePrivateKeyPem && + !persistedDefaults.devicePrivateKeyPem ) { - missingPersistedFields.push( - "headers.x-openclaw-token|headers.x-openclaw-auth" - ); + missingPersistedFields.push("devicePrivateKeyPem"); } if ( expectedDefaults.headerKeys.length > 0 && @@ -2150,7 +2137,7 @@ export function accessRoutes( hint: diag.hint ?? null })) }, - "invite accept persisted OpenClaw join request" + "invite accept persisted OpenClaw gateway join request" ); if (missingPersistedFields.length > 0) { @@ -2160,7 +2147,7 @@ export function accessRoutes( joinRequestId: created.id, missingPersistedFields }, - "invite accept detected missing persisted OpenClaw defaults" + "invite accept detected missing persisted OpenClaw gateway defaults" ); } } diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 008d9094..a57b63c2 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,5 +1,5 @@ import { Router, type Request } from "express"; -import { randomUUID } from "node:crypto"; +import { generateKeyPairSync, randomUUID } from "node:crypto"; import path from "node:path"; import type { Db } from "@paperclipai/db"; import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db"; @@ -181,6 +181,40 @@ export function agentRoutes(db: Db) { return trimmed.length > 0 ? trimmed : null; } + function parseBooleanLike(value: unknown): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value === "number") { + if (value === 1) return true; + if (value === 0) return false; + return null; + } + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { + return true; + } + if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { + return false; + } + return null; + } + + function generateEd25519PrivateKeyPem(): string { + const { privateKey } = generateKeyPairSync("ed25519"); + return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + } + + function ensureGatewayDeviceKey( + adapterType: string | null | undefined, + adapterConfig: Record, + ): Record { + if (adapterType !== "openclaw_gateway") return adapterConfig; + const disableDeviceAuth = parseBooleanLike(adapterConfig.disableDeviceAuth) === true; + if (disableDeviceAuth) return adapterConfig; + if (asNonEmptyString(adapterConfig.devicePrivateKeyPem)) return adapterConfig; + return { ...adapterConfig, devicePrivateKeyPem: generateEd25519PrivateKeyPem() }; + } + function applyCreateDefaultsByAdapterType( adapterType: string | null | undefined, adapterConfig: Record, @@ -196,13 +230,13 @@ export function agentRoutes(db: Db) { if (!hasBypassFlag) { next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX; } - return next; + return ensureGatewayDeviceKey(adapterType, next); } // OpenCode requires explicit model selection — no default if (adapterType === "cursor" && !asNonEmptyString(next.model)) { next.model = DEFAULT_CURSOR_LOCAL_MODEL; } - return next; + return ensureGatewayDeviceKey(adapterType, next); } async function assertAdapterConfigConstraints( @@ -930,11 +964,7 @@ export function agentRoutes(db: Db) { if (changingInstructionsPath) { await assertCanManageInstructionsPath(req, existing); } - patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( - existing.companyId, - adapterConfig, - { strictMode: strictSecretsMode }, - ); + patchData.adapterConfig = adapterConfig; } const requestedAdapterType = @@ -942,15 +972,23 @@ export function agentRoutes(db: Db) { const touchesAdapterConfiguration = Object.prototype.hasOwnProperty.call(patchData, "adapterType") || Object.prototype.hasOwnProperty.call(patchData, "adapterConfig"); - if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { + if (touchesAdapterConfiguration) { const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") ? (asRecord(patchData.adapterConfig) ?? {}) : (asRecord(existing.adapterConfig) ?? {}); - const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( - existing.companyId, + const effectiveAdapterConfig = applyCreateDefaultsByAdapterType( + requestedAdapterType, rawEffectiveAdapterConfig, + ); + const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + existing.companyId, + effectiveAdapterConfig, { strictMode: strictSecretsMode }, ); + patchData.adapterConfig = normalizedEffectiveAdapterConfig; + } + if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { + const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {}; await assertAdapterConfigConstraints( existing.companyId, requestedAdapterType, diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index afe54ffc..ac6de363 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -83,10 +83,6 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record - - + +
+ Always enabled for gateway agents. Paperclip persists a device key during onboarding so pairing approvals + remain stable across runs. +
)} diff --git a/ui/src/adapters/openclaw/config-fields.tsx b/ui/src/adapters/openclaw/config-fields.tsx deleted file mode 100644 index a50892eb..00000000 --- a/ui/src/adapters/openclaw/config-fields.tsx +++ /dev/null @@ -1,177 +0,0 @@ -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 ( - -
- - -
-
- ); -} - -export function OpenClawConfigFields({ - isCreate, - values, - set, - config, - eff, - mark, -}: AdapterConfigFieldsProps) { - const configuredHeaders = - config.headers && typeof config.headers === "object" && !Array.isArray(config.headers) - ? (config.headers as Record) - : {}; - const effectiveHeaders = - (eff("adapterConfig", "headers", configuredHeaders) as Record) ?? {}; - const effectiveGatewayAuthHeader = typeof effectiveHeaders["x-openclaw-auth"] === "string" - ? String(effectiveHeaders["x-openclaw-auth"]) - : ""; - - const commitGatewayAuthHeader = (rawValue: string) => { - const nextValue = rawValue.trim(); - const nextHeaders: Record = { ...effectiveHeaders }; - if (nextValue) { - nextHeaders["x-openclaw-auth"] = nextValue; - } else { - delete nextHeaders["x-openclaw-auth"]; - } - mark("adapterConfig", "headers", Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined); - }; - - const transport = eff( - "adapterConfig", - "streamTransport", - String(config.streamTransport ?? "sse"), - ); - const sessionStrategy = eff( - "adapterConfig", - "sessionKeyStrategy", - String(config.sessionKeyStrategy ?? "fixed"), - ); - - return ( - <> - - - isCreate - ? set!({ url: v }) - : mark("adapterConfig", "url", v || undefined) - } - immediate - className={inputClass} - placeholder="https://..." - /> - - {!isCreate && ( - <> - - mark("adapterConfig", "paperclipApiUrl", v || undefined)} - immediate - className={inputClass} - placeholder="https://paperclip.example" - /> - - - - - - - - - - - {sessionStrategy === "fixed" && ( - - mark("adapterConfig", "sessionKey", v || undefined)} - immediate - className={inputClass} - placeholder="paperclip" - /> - - )} - - mark("adapterConfig", "webhookAuthHeader", v || undefined)} - placeholder="Bearer " - /> - - - - )} - - ); -} diff --git a/ui/src/adapters/openclaw/index.ts b/ui/src/adapters/openclaw/index.ts deleted file mode 100644 index 890d83bc..00000000 --- a/ui/src/adapters/openclaw/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { UIAdapterModule } from "../types"; -import { parseOpenClawStdoutLine } from "@paperclipai/adapter-openclaw/ui"; -import { buildOpenClawConfig } from "@paperclipai/adapter-openclaw/ui"; -import { OpenClawConfigFields } from "./config-fields"; - -export const openClawUIAdapter: UIAdapterModule = { - type: "openclaw", - label: "OpenClaw", - parseStdoutLine: parseOpenClawStdoutLine, - ConfigFields: OpenClawConfigFields, - buildAdapterConfig: buildOpenClawConfig, -}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index a641b265..1a36af6b 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -4,7 +4,6 @@ import { codexLocalUIAdapter } from "./codex-local"; 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"; @@ -16,7 +15,6 @@ const adaptersByType = new Map( openCodeLocalUIAdapter, piLocalUIAdapter, cursorLocalUIAdapter, - openClawUIAdapter, openClawGatewayUIAdapter, processUIAdapter, httpUIAdapter, diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 7e89afd6..ce565f6d 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -64,6 +64,17 @@ type BoardClaimStatus = { claimedByUserId: string | null; }; +type CompanyInviteCreated = { + id: string; + token: string; + inviteUrl: string; + expiresAt: string; + allowedJoinTypes: "human" | "agent" | "both"; + onboardingTextPath?: string; + onboardingTextUrl?: string; + inviteMessage?: string | null; +}; + export const accessApi = { createCompanyInvite: ( companyId: string, @@ -73,16 +84,18 @@ export const accessApi = { agentMessage?: string | null; } = {}, ) => - api.post<{ - id: string; - token: string; - inviteUrl: string; - expiresAt: string; - allowedJoinTypes: "human" | "agent" | "both"; - onboardingTextPath?: string; - onboardingTextUrl?: string; - inviteMessage?: string | null; - }>(`/companies/${companyId}/invites`, input), + api.post(`/companies/${companyId}/invites`, input), + + createOpenClawInvitePrompt: ( + companyId: string, + input: { + agentMessage?: string | null; + } = {}, + ) => + api.post( + `/companies/${companyId}/openclaw/invite-prompt`, + input, + ), getInvite: (token: string) => api.get(`/invites/${token}`), getInviteOnboarding: (token: string) => diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index 4e1bc76e..6ff2dfeb 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -18,7 +18,6 @@ const adapterLabels: Record = { claude_local: "Claude (local)", codex_local: "Codex (local)", opencode_local: "OpenCode (local)", - openclaw: "OpenClaw", openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", process: "Process", diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx index 02bdf74c..9d176179 100644 --- a/ui/src/components/LiveRunWidget.tsx +++ b/ui/src/components/LiveRunWidget.tsx @@ -157,7 +157,7 @@ function parseStdoutChunk( if (!trimmed) continue; const parsed = adapter.parseStdoutLine(trimmed, ts); if (parsed.length === 0) { - if (run.adapterType === "openclaw" || run.adapterType === "openclaw_gateway") { + if (run.adapterType === "openclaw_gateway") { continue; } const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++); diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index b3ab9233..18830792 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -1,3 +1,4 @@ +import { useState, type ComponentType } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@/lib/router"; import { useDialog } from "../context/DialogContext"; @@ -9,12 +10,77 @@ import { DialogContent, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Bot, Sparkles } from "lucide-react"; +import { + ArrowLeft, + Bot, + Code, + MousePointer2, + Sparkles, + Terminal, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; + +type AdvancedAdapterType = + | "claude_local" + | "codex_local" + | "opencode_local" + | "pi_local" + | "cursor" + | "openclaw_gateway"; + +const ADVANCED_ADAPTER_OPTIONS: Array<{ + value: AdvancedAdapterType; + label: string; + desc: string; + icon: ComponentType<{ className?: string }>; + recommended?: boolean; +}> = [ + { + value: "claude_local", + label: "Claude Code", + icon: Sparkles, + desc: "Local Claude agent", + recommended: true, + }, + { + value: "codex_local", + label: "Codex", + icon: Code, + desc: "Local Codex agent", + recommended: true, + }, + { + value: "opencode_local", + label: "OpenCode", + icon: OpenCodeLogoIcon, + desc: "Local multi-provider agent", + }, + { + value: "pi_local", + label: "Pi", + icon: Terminal, + desc: "Local Pi agent", + }, + { + value: "cursor", + label: "Cursor", + icon: MousePointer2, + desc: "Local Cursor agent", + }, + { + value: "openclaw_gateway", + label: "OpenClaw Gateway", + icon: Bot, + desc: "Invoke OpenClaw via gateway protocol", + }, +]; export function NewAgentDialog() { const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog(); const { selectedCompanyId } = useCompany(); const navigate = useNavigate(); + const [showAdvancedCards, setShowAdvancedCards] = useState(false); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -34,15 +100,23 @@ export function NewAgentDialog() { } function handleAdvancedConfig() { + setShowAdvancedCards(true); + } + + function handleAdvancedAdapterPick(adapterType: AdvancedAdapterType) { closeNewAgent(); - navigate("/agents/new"); + setShowAdvancedCards(false); + navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`); } return ( { - if (!open) closeNewAgent(); + if (!open) { + setShowAdvancedCards(false); + closeNewAgent(); + } }} > { + setShowAdvancedCards(false); + closeNewAgent(); + }} > ×
- {/* Recommendation */} -
-
- -
-

- We recommend letting your CEO handle agent setup — they know the - org structure and can configure reporting, permissions, and - adapters. -

-
+ {!showAdvancedCards ? ( + <> + {/* Recommendation */} +
+
+ +
+

+ We recommend letting your CEO handle agent setup — they know the + org structure and can configure reporting, permissions, and + adapters. +

+
- + - {/* Advanced link */} -
- -
+ {/* Advanced link */} +
+ +
+ + ) : ( + <> +
+ +

+ Choose your adapter type for advanced setup. +

+
+ +
+ {ADVANCED_ADAPTER_OPTIONS.map((opt) => ( + + ))} +
+ + )}
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 7a4bceeb..fbcbc7bf 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -38,7 +38,6 @@ import { ArrowLeft, ArrowRight, Terminal, - Globe, Sparkles, MousePointer2, Check, @@ -57,7 +56,6 @@ type AdapterType = | "cursor" | "process" | "http" - | "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) @@ -673,38 +671,19 @@ export function OnboardingWizard() { icon: Terminal, desc: "Local Pi agent" }, - { - value: "openclaw" as const, - label: "OpenClaw", - icon: Bot, - desc: "Notify OpenClaw webhook", - comingSoon: true - }, { value: "openclaw_gateway" as const, label: "OpenClaw Gateway", icon: Bot, - desc: "Invoke OpenClaw via gateway protocol" + desc: "Invoke OpenClaw via gateway protocol", + comingSoon: true, + disabledLabel: "Configure OpenClaw within the App" }, { value: "cursor" as const, label: "Cursor", icon: MousePointer2, desc: "Local Cursor agent" - }, - { - value: "process" as const, - label: "Shell Command", - icon: Terminal, - desc: "Run a process", - comingSoon: true - }, - { - value: "http" as const, - label: "HTTP Webhook", - icon: Globe, - desc: "Call an endpoint", - comingSoon: true } ].map((opt) => ( ))} @@ -988,7 +970,7 @@ export function OnboardingWizard() { )} - {(adapterType === "http" || adapterType === "openclaw" || adapterType === "openclaw_gateway") && ( + {(adapterType === "http" || adapterType === "openclaw_gateway") && (