Merge pull request #270 from paperclipai/openclawgateway
Openclaw gateway
This commit is contained in:
@@ -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/claude-local/package.json packages/adapters/claude-local/
|
||||||
COPY packages/adapters/codex-local/package.json packages/adapters/codex-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/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/
|
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const workspacePaths = [
|
|||||||
"packages/adapter-utils",
|
"packages/adapter-utils",
|
||||||
"packages/adapters/claude-local",
|
"packages/adapters/claude-local",
|
||||||
"packages/adapters/codex-local",
|
"packages/adapters/codex-local",
|
||||||
"packages/adapters/openclaw",
|
|
||||||
"packages/adapters/openclaw-gateway",
|
"packages/adapters/openclaw-gateway",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
|
||||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/db": "workspace:*",
|
"@paperclipai/db": "workspace:*",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
|||||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||||
import { printPiStreamEvent } from "@paperclipai/adapter-pi-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 { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
||||||
import { processCLIAdapter } from "./process/index.js";
|
import { processCLIAdapter } from "./process/index.js";
|
||||||
import { httpCLIAdapter } from "./http/index.js";
|
import { httpCLIAdapter } from "./http/index.js";
|
||||||
@@ -34,11 +33,6 @@ const cursorLocalCLIAdapter: CLIAdapterModule = {
|
|||||||
formatStdoutEvent: printCursorStreamEvent,
|
formatStdoutEvent: printCursorStreamEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
const openclawCLIAdapter: CLIAdapterModule = {
|
|
||||||
type: "openclaw",
|
|
||||||
formatStdoutEvent: printOpenClawStreamEvent,
|
|
||||||
};
|
|
||||||
|
|
||||||
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||||
type: "openclaw_gateway",
|
type: "openclaw_gateway",
|
||||||
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
||||||
@@ -51,7 +45,6 @@ const adaptersByType = new Map<string, CLIAdapterModule>(
|
|||||||
openCodeLocalCLIAdapter,
|
openCodeLocalCLIAdapter,
|
||||||
piLocalCLIAdapter,
|
piLocalCLIAdapter,
|
||||||
cursorLocalCLIAdapter,
|
cursorLocalCLIAdapter,
|
||||||
openclawCLIAdapter,
|
|
||||||
openclawGatewayCLIAdapter,
|
openclawGatewayCLIAdapter,
|
||||||
processCLIAdapter,
|
processCLIAdapter,
|
||||||
httpCLIAdapter,
|
httpCLIAdapter,
|
||||||
|
|||||||
@@ -197,10 +197,16 @@ export function registerAgentCommands(program: Command): void {
|
|||||||
const agentRow = await ctx.api.get<Agent>(
|
const agentRow = await ctx.api.get<Agent>(
|
||||||
`/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`,
|
`/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`,
|
||||||
);
|
);
|
||||||
|
if (!agentRow) {
|
||||||
|
throw new Error(`Agent not found: ${agentRef}`);
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString().replaceAll(":", "-");
|
const now = new Date().toISOString().replaceAll(":", "-");
|
||||||
const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`;
|
const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`;
|
||||||
const key = await ctx.api.post<CreatedAgentKey>(`/api/agents/${agentRow.id}/keys`, { name: keyName });
|
const key = await ctx.api.post<CreatedAgentKey>(`/api/agents/${agentRow.id}/keys`, { name: keyName });
|
||||||
|
if (!key) {
|
||||||
|
throw new Error("Failed to create API key");
|
||||||
|
}
|
||||||
|
|
||||||
const installSummaries: SkillsInstallSummary[] = [];
|
const installSummaries: SkillsInstallSummary[] = [];
|
||||||
if (opts.installSkills !== false) {
|
if (opts.installSkills !== false) {
|
||||||
|
|||||||
@@ -18,20 +18,28 @@ 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`.
|
3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`.
|
||||||
|
|
||||||
4. Use the agent snippet flow.
|
4. Use the OpenClaw invite prompt flow.
|
||||||
- Copy the snippet from company settings.
|
- 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.
|
- Paste it into OpenClaw main chat as one message.
|
||||||
- If it stalls, send one follow-up: `How is onboarding going? Continue setup now.`
|
- 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.
|
5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents.
|
||||||
|
|
||||||
6. Gateway preflight (required before task tests).
|
6. Gateway preflight (required before task tests).
|
||||||
- Confirm the created agent uses `openclaw_gateway` (not `openclaw`).
|
- Confirm the created agent uses `openclaw_gateway` (not `openclaw`).
|
||||||
- Confirm gateway URL is `ws://...` or `wss://...`.
|
- Confirm gateway URL is `ws://...` or `wss://...`.
|
||||||
- Confirm gateway token is non-trivial (not empty / not 1-char placeholder).
|
- 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:
|
- Confirm pairing mode is explicit:
|
||||||
- recommended default: `adapterConfig.disableDeviceAuth` is false/absent and `adapterConfig.devicePrivateKeyPem` is present
|
- required default: device auth enabled (`adapterConfig.disableDeviceAuth` false/absent) with persisted `adapterConfig.devicePrivateKeyPem`
|
||||||
- fallback only: `adapterConfig.disableDeviceAuth=true` when pairing cannot be supported in that environment
|
- do not rely on `disableDeviceAuth` for normal onboarding
|
||||||
- If you can run API checks with board auth:
|
- If you can run API checks with board auth:
|
||||||
```bash
|
```bash
|
||||||
AGENT_ID="<newly-created-agent-id>"
|
AGENT_ID="<newly-created-agent-id>"
|
||||||
@@ -40,12 +48,19 @@ curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT
|
|||||||
- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`.
|
- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`.
|
||||||
|
|
||||||
Pairing handshake note:
|
Pairing handshake note:
|
||||||
- The first gateway run may return `pairing required` once for a new device key.
|
- 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.
|
- Approve it in OpenClaw, then retry the task.
|
||||||
- For local docker smoke, you can approve from host:
|
- For local docker smoke, you can approve from host:
|
||||||
```bash
|
```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\")"'
|
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).
|
7. Case A (manual issue test).
|
||||||
- Create an issue assigned to the OpenClaw agent.
|
- Create an issue assigned to the OpenClaw agent.
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.6.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ Use when:
|
|||||||
- You want structured stream output in run logs via --output-format stream-json
|
- You want structured stream output in run logs via --output-format stream-json
|
||||||
|
|
||||||
Don't use when:
|
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)
|
- You only need one-shot shell commands (use process)
|
||||||
- Cursor Agent CLI is not installed on the machine
|
- Cursor Agent CLI is not installed on the machine
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"extends": "../../../tsconfig.json",
|
"extends": "../../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "src",
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,12 +32,13 @@ By default the adapter sends a signed `device` payload in `connect` params.
|
|||||||
- set `disableDeviceAuth=true` to omit device signing
|
- set `disableDeviceAuth=true` to omit device signing
|
||||||
- set `devicePrivateKeyPem` to pin a stable signing key
|
- set `devicePrivateKeyPem` to pin a stable signing key
|
||||||
- without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run
|
- 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
|
## Session Strategy
|
||||||
|
|
||||||
The adapter supports the same session routing model as HTTP OpenClaw mode:
|
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`
|
- `sessionKey` is used when strategy is `fixed`
|
||||||
|
|
||||||
Resolved session key is sent as `agent.sessionKey`.
|
Resolved session key is sent as `agent.sessionKey`.
|
||||||
|
|||||||
@@ -1,367 +1,109 @@
|
|||||||
# OpenClaw Gateway Onboarding and Test Plan
|
# 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 manifest/text gateway path (resolved)
|
|
||||||
Resolved in `server/src/routes/access.ts`:
|
|
||||||
- `recommendedAdapterType` now points to `openclaw_gateway`.
|
|
||||||
- Onboarding examples now require `adapterType: "openclaw_gateway"` + `ws://`/`wss://` URL + gateway token header.
|
|
||||||
- Added fail-fast guidance for short/placeholder tokens.
|
|
||||||
|
|
||||||
### 2) Company settings snippet gateway path (resolved)
|
|
||||||
Resolved in `ui/src/pages/CompanySettings.tsx`:
|
|
||||||
- Snippet now instructs OpenClaw Gateway onboarding.
|
|
||||||
- Snippet explicitly says not to use `/v1/responses` or `/hooks/*` for this flow.
|
|
||||||
|
|
||||||
### 3) Invite landing “agent join” UX is not wired for OpenClaw adapters (open)
|
|
||||||
`ui/src/pages/InviteLanding.tsx` shows `openclaw` and `openclaw_gateway` as disabled (“Coming soon”) in join UI.
|
|
||||||
|
|
||||||
### 4) Join normalization/replay logic parity (partially resolved)
|
|
||||||
Resolved:
|
|
||||||
- `buildJoinDefaultsPayloadForAccept` now normalizes wrapped gateway token headers for `openclaw_gateway`.
|
|
||||||
- `normalizeAgentDefaultsForJoin` now validates `openclaw_gateway` URL/token and rejects short placeholder tokens at invite-accept time.
|
|
||||||
|
|
||||||
Still open:
|
|
||||||
- Invite replay path is still special-cased to legacy `openclaw` joins.
|
|
||||||
|
|
||||||
### 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
|
## Scope
|
||||||
Run this full flow per test cycle against company `CLA`:
|
This plan is now **gateway-only**. Paperclip supports OpenClaw through `openclaw_gateway` only.
|
||||||
1. Assign task to OpenClaw agent -> agent executes -> task closes.
|
|
||||||
2. Task asks OpenClaw to send message to user main chat via message tool -> message appears in main chat.
|
|
||||||
3. OpenClaw in a fresh/new session can still create a Paperclip task.
|
|
||||||
4. Use one primary OpenClaw bootstrap prompt (plus optional single follow-up ping) to perform setup.
|
|
||||||
|
|
||||||
## 0) Cleanup Before Each Run
|
- Removed path: legacy `openclaw` adapter (`/v1/responses`, `/hooks/*`, SSE/webhook transport switching)
|
||||||
Use deterministic reset to avoid stale agents/runs/state.
|
- 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": "<gateway-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
|
```bash
|
||||||
# stop/remove OpenClaw compose services
|
|
||||||
OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker
|
OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker
|
||||||
if [ -d "$OPENCLAW_DOCKER_DIR" ]; then
|
if [ -d "$OPENCLAW_DOCKER_DIR" ]; then
|
||||||
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true
|
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# remove old image (as requested)
|
|
||||||
docker image rm openclaw:local || true
|
docker image rm openclaw:local || true
|
||||||
```
|
|
||||||
|
|
||||||
2. Recreate OpenClaw cleanly:
|
|
||||||
```bash
|
|
||||||
OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
|
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:
|
### 1) Start Paperclip
|
||||||
- List `CLA` agents via API.
|
|
||||||
- Terminate/delete agents with `adapterType in ("openclaw", "openclaw_gateway")` before new onboarding.
|
|
||||||
|
|
||||||
4. Reject/clear stale pending join requests for CLA (optional but recommended).
|
|
||||||
|
|
||||||
## 1) Start Paperclip in Required Mode
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev --tailscale-auth
|
pnpm dev --tailscale-auth
|
||||||
```
|
|
||||||
Verify:
|
|
||||||
```bash
|
|
||||||
curl -fsS http://127.0.0.1:3100/api/health
|
curl -fsS http://127.0.0.1:3100/api/health
|
||||||
# expect deploymentMode=authenticated, deploymentExposure=private
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2) Acquire Board Session for Automation
|
### 2) Invite + join + approval
|
||||||
Board operations (create invite, approve join, terminate agents) require board session cookie.
|
- 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:
|
### 3) Pairing stabilization
|
||||||
1. Preferred immediate path: reuse an existing signed-in board browser cookie and export as `PAPERCLIP_COOKIE`.
|
- if first run returns `pairing required`, approve pending device in OpenClaw
|
||||||
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).
|
- rerun task and confirm success
|
||||||
|
- assert later runs do not require re-pairing for same agent
|
||||||
|
|
||||||
Note:
|
### 4) Functional E2E assertions
|
||||||
- CLI `--api-key` is for agent auth and is not enough for board-only routes in this flow.
|
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
|
## Manual Smoke Checklist
|
||||||
With board cookie:
|
Use [doc/OPENCLAW_ONBOARDING.md](../../../../doc/OPENCLAW_ONBOARDING.md) as the operator runbook.
|
||||||
|
|
||||||
|
## Regression Gates
|
||||||
|
Required before merge:
|
||||||
```bash
|
```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
|
If full suite is too heavy locally, run at least:
|
||||||
From host (using current OpenClaw token):
|
|
||||||
- For HTTP SSE mode: confirm `/v1/responses` behavior.
|
|
||||||
- For HTTP webhook mode: confirm `/hooks/agent` exists; if 404, hooks are disabled.
|
|
||||||
- For gateway mode: confirm WS challenge appears from `ws://127.0.0.1:18789`.
|
|
||||||
|
|
||||||
Expected in current docker smoke config:
|
|
||||||
- `/hooks/agent` likely `404` unless hooks explicitly enabled.
|
|
||||||
- WS gateway protocol works.
|
|
||||||
|
|
||||||
## 5) Gateway Join Flow (Primary Path)
|
|
||||||
|
|
||||||
1. Create agent-only invite in CLA:
|
|
||||||
```bash
|
```bash
|
||||||
POST /api/companies/$CLA_COMPANY_ID/invites
|
pnpm --filter @paperclipai/server test:run -- openclaw-gateway
|
||||||
{ "allowedJoinTypes": "agent" }
|
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": "<gateway-token>" },
|
|
||||||
"role": "operator",
|
|
||||||
"scopes": ["operator.admin"],
|
|
||||||
"sessionKeyStrategy": "fixed",
|
|
||||||
"sessionKey": "paperclip",
|
|
||||||
"waitTimeoutMs": 120000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Approve join request.
|
|
||||||
4. **Hard gate before any task run:** fetch created agent config and validate:
|
|
||||||
- `adapterType == "openclaw_gateway"`
|
|
||||||
- `adapterConfig.url` uses `ws://` or `wss://`
|
|
||||||
- `adapterConfig.headers.x-openclaw-token` exists and is not placeholder/too-short (`len >= 16`)
|
|
||||||
- token hash matches the OpenClaw `gateway.auth.token` used for join
|
|
||||||
- pairing mode is explicit:
|
|
||||||
- default path: `adapterConfig.disableDeviceAuth` is false/absent and stable `adapterConfig.devicePrivateKeyPem` is set so approvals persist across runs
|
|
||||||
- fallback path: `disableDeviceAuth=true` only for environments that cannot support pairing
|
|
||||||
5. Trigger one connectivity run. If it returns `pairing required`, approve the pending device request in OpenClaw and retry once.
|
|
||||||
- Local docker automation path:
|
|
||||||
- `openclaw devices approve --latest --json --url ws://127.0.0.1:18789 --token <gateway-token>`
|
|
||||||
- After approval, retries should succeed using the persisted `devicePrivateKeyPem`.
|
|
||||||
6. Claim API key with `claimSecret`.
|
|
||||||
7. 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.
|
|
||||||
8. Ensure Paperclip skill is installed for OpenClaw runtime.
|
|
||||||
9. 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.
|
|
||||||
- Gateway agent config/token preflight validation before connectivity or case execution.
|
|
||||||
- Pairing-mode preflight (`disableDeviceAuth=false` + stable `devicePrivateKeyPem` by default).
|
|
||||||
- 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.
|
|
||||||
- Gateway join fails fast if token is missing/placeholder, and smoke preflight verifies adapter/token parity before task runs.
|
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Use when:
|
|||||||
- You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*.
|
- You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*.
|
||||||
|
|
||||||
Don't use when:
|
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.
|
- Your deployment does not permit outbound WebSocket access from the Paperclip server.
|
||||||
|
|
||||||
Core fields:
|
Core fields:
|
||||||
@@ -33,9 +33,10 @@ Request behavior fields:
|
|||||||
- payloadTemplate (object, optional): additional fields merged into gateway agent params
|
- payloadTemplate (object, optional): additional fields merged into gateway agent params
|
||||||
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
|
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
|
||||||
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
|
- 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
|
- paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text
|
||||||
|
|
||||||
Session routing fields:
|
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)
|
- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip)
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type GatewayDeviceIdentity = {
|
|||||||
deviceId: string;
|
deviceId: string;
|
||||||
publicKeyRawBase64Url: string;
|
publicKeyRawBase64Url: string;
|
||||||
privateKeyPem: string;
|
privateKeyPem: string;
|
||||||
|
source: "configured" | "ephemeral";
|
||||||
};
|
};
|
||||||
|
|
||||||
type GatewayRequestFrame = {
|
type GatewayRequestFrame = {
|
||||||
@@ -56,6 +57,11 @@ type PendingRequest = {
|
|||||||
timer: ReturnType<typeof setTimeout> | null;
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GatewayResponseError = Error & {
|
||||||
|
gatewayCode?: string;
|
||||||
|
gatewayDetails?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
type GatewayClientOptions = {
|
type GatewayClientOptions = {
|
||||||
url: string;
|
url: string;
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
@@ -111,9 +117,9 @@ function parseBoolean(value: unknown, fallback = false): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy {
|
function normalizeSessionKeyStrategy(value: unknown): SessionKeyStrategy {
|
||||||
const normalized = asString(value, "fixed").trim().toLowerCase();
|
const normalized = asString(value, "issue").trim().toLowerCase();
|
||||||
if (normalized === "issue" || normalized === "run") return normalized;
|
if (normalized === "fixed" || normalized === "run") return normalized;
|
||||||
return "fixed";
|
return "issue";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSessionKey(input: {
|
function resolveSessionKey(input: {
|
||||||
@@ -163,6 +169,10 @@ function normalizeScopes(value: unknown): string[] {
|
|||||||
return parsed.length > 0 ? parsed : [...DEFAULT_SCOPES];
|
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<string, string>, key: string): string | null {
|
function headerMapGetIgnoreCase(headers: Record<string, string>, key: string): string | null {
|
||||||
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
|
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
|
||||||
return match ? match[1] : null;
|
return match ? match[1] : null;
|
||||||
@@ -172,6 +182,21 @@ function headerMapHasIgnoreCase(headers: Record<string, string>, key: string): b
|
|||||||
return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase());
|
return Object.keys(headers).some((entryKey) => entryKey.toLowerCase() === key.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGatewayErrorDetails(err: unknown): Record<string, unknown> | 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 {
|
function toAuthorizationHeaderValue(rawToken: string): string {
|
||||||
const trimmed = rawToken.trim();
|
const trimmed = rawToken.trim();
|
||||||
if (!trimmed) return trimmed;
|
if (!trimmed) return trimmed;
|
||||||
@@ -486,6 +511,7 @@ function resolveDeviceIdentity(config: Record<string, unknown>): GatewayDeviceId
|
|||||||
deviceId: crypto.createHash("sha256").update(raw).digest("hex"),
|
deviceId: crypto.createHash("sha256").update(raw).digest("hex"),
|
||||||
publicKeyRawBase64Url: base64UrlEncode(raw),
|
publicKeyRawBase64Url: base64UrlEncode(raw),
|
||||||
privateKeyPem: configuredPrivateKey,
|
privateKeyPem: configuredPrivateKey,
|
||||||
|
source: "configured",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +523,7 @@ function resolveDeviceIdentity(config: Record<string, unknown>): GatewayDeviceId
|
|||||||
deviceId: crypto.createHash("sha256").update(raw).digest("hex"),
|
deviceId: crypto.createHash("sha256").update(raw).digest("hex"),
|
||||||
publicKeyRawBase64Url: base64UrlEncode(raw),
|
publicKeyRawBase64Url: base64UrlEncode(raw),
|
||||||
privateKeyPem,
|
privateKeyPem,
|
||||||
|
source: "ephemeral",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,7 +715,101 @@ class GatewayWsClient {
|
|||||||
nonEmpty(errorRecord?.message) ??
|
nonEmpty(errorRecord?.message) ??
|
||||||
nonEmpty(errorRecord?.code) ??
|
nonEmpty(errorRecord?.code) ??
|
||||||
"gateway request failed";
|
"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<string, string>;
|
||||||
|
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<Record<string, unknown>>("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<string, unknown> => 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,15 +942,55 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
agentParams.timeout = waitTimeoutMs;
|
agentParams.timeout = waitTimeoutMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ctx.onMeta) {
|
||||||
|
await ctx.onMeta({
|
||||||
|
adapterType: "openclaw_gateway",
|
||||||
|
command: "gateway",
|
||||||
|
commandArgs: ["ws", parsedUrl.toString(), "agent"],
|
||||||
|
context: ctx.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const outboundHeaderKeys = Object.keys(headers).sort();
|
||||||
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[openclaw-gateway] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`,
|
||||||
|
);
|
||||||
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[openclaw-gateway] outbound payload (redacted): ${stringifyForLog(redactForLog(agentParams), 12_000)}\n`,
|
||||||
|
);
|
||||||
|
await ctx.onLog("stdout", `[openclaw-gateway] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`);
|
||||||
|
if (transportHint) {
|
||||||
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[openclaw-gateway] ignoring streamTransport=${transportHint}; gateway adapter always uses websocket protocol\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parsedUrl.protocol === "ws:" && !isLoopbackHost(parsedUrl.hostname)) {
|
||||||
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
"[openclaw-gateway] warning: using plaintext ws:// to a non-loopback host; prefer wss:// for remote endpoints\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoPairOnFirstConnect = parseBoolean(ctx.config.autoPairOnFirstConnect, true);
|
||||||
|
let autoPairAttempted = false;
|
||||||
|
let latestResultPayload: unknown = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
const trackedRunIds = new Set<string>([ctx.runId]);
|
const trackedRunIds = new Set<string>([ctx.runId]);
|
||||||
const assistantChunks: string[] = [];
|
const assistantChunks: string[] = [];
|
||||||
let lifecycleError: string | null = null;
|
let lifecycleError: string | null = null;
|
||||||
let latestResultPayload: unknown = null;
|
let deviceIdentity: GatewayDeviceIdentity | null = null;
|
||||||
|
|
||||||
const onEvent = async (frame: GatewayEventFrame) => {
|
const onEvent = async (frame: GatewayEventFrame) => {
|
||||||
if (frame.event !== "agent") {
|
if (frame.event !== "agent") {
|
||||||
if (frame.event === "shutdown") {
|
if (frame.event === "shutdown") {
|
||||||
await ctx.onLog("stdout", `[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`);
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[openclaw-gateway] gateway shutdown notice: ${stringifyForLog(frame.payload ?? {}, 2_000)}\n`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -878,40 +1039,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
onLog: ctx.onLog,
|
onLog: ctx.onLog,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ctx.onMeta) {
|
|
||||||
await ctx.onMeta({
|
|
||||||
adapterType: "openclaw_gateway",
|
|
||||||
command: "gateway",
|
|
||||||
commandArgs: ["ws", parsedUrl.toString(), "agent"],
|
|
||||||
context: ctx.context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const outboundHeaderKeys = Object.keys(headers).sort();
|
|
||||||
await ctx.onLog(
|
|
||||||
"stdout",
|
|
||||||
`[openclaw-gateway] outbound headers (redacted): ${stringifyForLog(redactForLog(headers), 4_000)}\n`,
|
|
||||||
);
|
|
||||||
await ctx.onLog(
|
|
||||||
"stdout",
|
|
||||||
`[openclaw-gateway] outbound payload (redacted): ${stringifyForLog(redactForLog(agentParams), 12_000)}\n`,
|
|
||||||
);
|
|
||||||
await ctx.onLog("stdout", `[openclaw-gateway] outbound header keys: ${outboundHeaderKeys.join(", ")}\n`);
|
|
||||||
if (transportHint) {
|
|
||||||
await ctx.onLog(
|
|
||||||
"stdout",
|
|
||||||
`[openclaw-gateway] ignoring streamTransport=${transportHint}; gateway adapter always uses websocket protocol\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (parsedUrl.protocol === "ws:" && !isLoopbackHost(parsedUrl.hostname)) {
|
|
||||||
await ctx.onLog(
|
|
||||||
"stdout",
|
|
||||||
"[openclaw-gateway] warning: using plaintext ws:// to a non-loopback host; prefer wss:// for remote endpoints\n",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deviceIdentity = disableDeviceAuth ? null : resolveDeviceIdentity(parseObject(ctx.config));
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`);
|
await ctx.onLog("stdout", `[openclaw-gateway] connecting to ${parsedUrl.toString()}\n`);
|
||||||
|
|
||||||
@@ -984,7 +1121,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (acceptedStatus === "error") {
|
if (acceptedStatus === "error") {
|
||||||
const errorMessage = nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed";
|
const errorMessage =
|
||||||
|
nonEmpty(acceptedPayload?.summary) ?? lifecycleError ?? "OpenClaw gateway agent request failed";
|
||||||
return {
|
return {
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
signal: null,
|
signal: null,
|
||||||
@@ -1057,7 +1195,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null;
|
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null;
|
||||||
const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0);
|
const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0);
|
||||||
|
|
||||||
await ctx.onLog("stdout", `[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`);
|
await ctx.onLog(
|
||||||
|
"stdout",
|
||||||
|
`[openclaw-gateway] run completed runId=${Array.from(trackedRunIds).join(",")} status=ok\n`,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
@@ -1075,8 +1216,45 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
const lower = message.toLowerCase();
|
const lower = message.toLowerCase();
|
||||||
const timedOut = lower.includes("timeout");
|
const timedOut = lower.includes("timeout");
|
||||||
const pairingRequired = lower.includes("pairing required");
|
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
|
const detailedMessage = pairingRequired
|
||||||
? `${message}. Configure adapterConfig.disableDeviceAuth=true for smoke/dev, or set adapterConfig.devicePrivateKeyPem so pairing persists across runs.`
|
? `${message}. Approve the pending device in OpenClaw (for example: openclaw devices approve --latest --url <gateway-ws-url> --token <gateway-token>) and retry. Ensure this agent has a persisted adapterConfig.devicePrivateKeyPem so approvals are reused.`
|
||||||
: message;
|
: message;
|
||||||
|
|
||||||
await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`);
|
await ctx.onLog("stderr", `[openclaw-gateway] request failed: ${detailedMessage}\n`);
|
||||||
@@ -1096,4 +1274,5 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
} finally {
|
} finally {
|
||||||
client.close();
|
client.close();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string
|
|||||||
if (v.url) ac.url = v.url;
|
if (v.url) ac.url = v.url;
|
||||||
ac.timeoutSec = 120;
|
ac.timeoutSec = 120;
|
||||||
ac.waitTimeoutMs = 120000;
|
ac.waitTimeoutMs = 120000;
|
||||||
ac.sessionKeyStrategy = "fixed";
|
ac.sessionKeyStrategy = "issue";
|
||||||
ac.sessionKey = "paperclip";
|
|
||||||
ac.role = "operator";
|
ac.role = "operator";
|
||||||
ac.scopes = ["operator.admin"];
|
ac.scopes = ["operator.admin"];
|
||||||
return ac;
|
return ac;
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
# @paperclipai/adapter-openclaw
|
|
||||||
|
|
||||||
## 0.2.7
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Version bump (patch)
|
|
||||||
- Updated dependencies
|
|
||||||
- @paperclipai/adapter-utils@0.2.7
|
|
||||||
|
|
||||||
## 0.2.6
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Version bump (patch)
|
|
||||||
- Updated dependencies
|
|
||||||
- @paperclipai/adapter-utils@0.2.6
|
|
||||||
|
|
||||||
## 0.2.5
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Version bump (patch)
|
|
||||||
- Updated dependencies
|
|
||||||
- @paperclipai/adapter-utils@0.2.5
|
|
||||||
|
|
||||||
## 0.2.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Version bump (patch)
|
|
||||||
- Updated dependencies
|
|
||||||
- @paperclipai/adapter-utils@0.2.4
|
|
||||||
|
|
||||||
## 0.2.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Version bump (patch)
|
|
||||||
- Updated dependencies
|
|
||||||
- @paperclipai/adapter-utils@0.2.3
|
|
||||||
|
|
||||||
## 0.2.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Version bump (patch)
|
|
||||||
- Updated dependencies
|
|
||||||
- @paperclipai/adapter-utils@0.2.2
|
|
||||||
|
|
||||||
## 0.2.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Version bump (patch)
|
|
||||||
- Updated dependencies
|
|
||||||
- @paperclipai/adapter-utils@0.2.1
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# OpenClaw Adapter Modes
|
|
||||||
|
|
||||||
This document describes how `@paperclipai/adapter-openclaw` selects request shape and endpoint behavior.
|
|
||||||
|
|
||||||
## Transport Modes
|
|
||||||
|
|
||||||
The adapter has two transport modes:
|
|
||||||
|
|
||||||
- `sse` (default)
|
|
||||||
- `webhook`
|
|
||||||
|
|
||||||
Configured via `adapterConfig.streamTransport` (or legacy `adapterConfig.transport`).
|
|
||||||
|
|
||||||
## Mode Matrix
|
|
||||||
|
|
||||||
| streamTransport | configured URL path | behavior |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `sse` | `/v1/responses` | Sends OpenResponses request with `stream: true`, expects `text/event-stream` response until terminal event. |
|
|
||||||
| `sse` | `/hooks/*` | Rejected (`openclaw_sse_incompatible_endpoint`). Hooks are not stream-capable. |
|
|
||||||
| `sse` | other endpoint | Sends generic streaming payload (`stream: true`, `text`, `paperclip`) and expects SSE response. |
|
|
||||||
| `webhook` | `/hooks/wake` | Sends wake payload `{ text, mode }`. |
|
|
||||||
| `webhook` | `/hooks/agent` | Sends agent payload `{ message, ...hook fields }`. |
|
|
||||||
| `webhook` | `/v1/responses` | Compatibility flow: tries `/hooks/agent` first, then falls back to original `/v1/responses` if hook endpoint returns `404`. |
|
|
||||||
| `webhook` | other endpoint | Sends legacy generic webhook payload (`stream: false`, `text`, `paperclip`). |
|
|
||||||
|
|
||||||
## Webhook Payload Shapes
|
|
||||||
|
|
||||||
### 1) Hook Wake (`/hooks/wake`)
|
|
||||||
|
|
||||||
Payload:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"text": "Paperclip wake event ...",
|
|
||||||
"mode": "now"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2) Hook Agent (`/hooks/agent`)
|
|
||||||
|
|
||||||
Payload:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Paperclip wake event ...",
|
|
||||||
"name": "Optional hook name",
|
|
||||||
"agentId": "Optional OpenClaw agent id",
|
|
||||||
"wakeMode": "now",
|
|
||||||
"deliver": true,
|
|
||||||
"channel": "last",
|
|
||||||
"to": "Optional channel recipient",
|
|
||||||
"model": "Optional model override",
|
|
||||||
"thinking": "Optional thinking override",
|
|
||||||
"timeoutSeconds": 120
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- `message` is always used (not `text`) for `/hooks/agent`.
|
|
||||||
- `sessionKey` is **not** sent by default for `/hooks/agent`.
|
|
||||||
- To include derived session keys in `/hooks/agent`, set:
|
|
||||||
- `hookIncludeSessionKey: true`
|
|
||||||
|
|
||||||
### 3) OpenResponses (`/v1/responses`)
|
|
||||||
|
|
||||||
When used directly (SSE mode or webhook fallback), payload uses OpenResponses shape:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"stream": false,
|
|
||||||
"model": "openclaw",
|
|
||||||
"input": "...",
|
|
||||||
"metadata": {
|
|
||||||
"paperclip_session_key": "paperclip"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auth Header Behavior
|
|
||||||
|
|
||||||
You can provide auth either explicitly or via token headers:
|
|
||||||
|
|
||||||
- Explicit auth header:
|
|
||||||
- `webhookAuthHeader: "Bearer ..."`
|
|
||||||
- Token headers (adapter derives `Authorization` automatically when missing):
|
|
||||||
- `headers["x-openclaw-token"]` (preferred)
|
|
||||||
- `headers["x-openclaw-auth"]` (legacy compatibility)
|
|
||||||
|
|
||||||
## Session Key Behavior
|
|
||||||
|
|
||||||
Session keys are resolved from:
|
|
||||||
|
|
||||||
- `sessionKeyStrategy`: `fixed` (default), `issue`, `run`
|
|
||||||
- `sessionKey`: used when strategy is `fixed` (default value `paperclip`)
|
|
||||||
|
|
||||||
Where session keys are applied:
|
|
||||||
|
|
||||||
- `/v1/responses`: sent via `x-openclaw-session-key` header + metadata.
|
|
||||||
- `/hooks/wake`: not sent as a dedicated field.
|
|
||||||
- `/hooks/agent`: only sent if `hookIncludeSessionKey=true`.
|
|
||||||
- Generic webhook fallback: sent as `sessionKey` field.
|
|
||||||
|
|
||||||
## Recommended Config Examples
|
|
||||||
|
|
||||||
### SSE (streaming endpoint)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"url": "http://127.0.0.1:18789/v1/responses",
|
|
||||||
"streamTransport": "sse",
|
|
||||||
"method": "POST",
|
|
||||||
"headers": {
|
|
||||||
"x-openclaw-token": "replace-me"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Webhook (hooks endpoint)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"url": "http://127.0.0.1:18789/hooks/agent",
|
|
||||||
"streamTransport": "webhook",
|
|
||||||
"method": "POST",
|
|
||||||
"headers": {
|
|
||||||
"x-openclaw-token": "replace-me"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Webhook with legacy URL retained
|
|
||||||
|
|
||||||
If URL is still `/v1/responses` and `streamTransport=webhook`, the adapter will:
|
|
||||||
|
|
||||||
1. try `.../hooks/agent`
|
|
||||||
2. fallback to original `.../v1/responses` when hook endpoint returns `404`
|
|
||||||
|
|
||||||
This lets older OpenClaw setups continue working while migrating to hooks.
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@paperclipai/adapter-openclaw",
|
|
||||||
"version": "0.2.7",
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts",
|
|
||||||
"./server": "./src/server/index.ts",
|
|
||||||
"./ui": "./src/ui/index.ts",
|
|
||||||
"./cli": "./src/cli/index.ts"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"./server": {
|
|
||||||
"types": "./dist/server/index.d.ts",
|
|
||||||
"import": "./dist/server/index.js"
|
|
||||||
},
|
|
||||||
"./ui": {
|
|
||||||
"types": "./dist/ui/index.d.ts",
|
|
||||||
"import": "./dist/ui/index.js"
|
|
||||||
},
|
|
||||||
"./cli": {
|
|
||||||
"types": "./dist/cli/index.d.ts",
|
|
||||||
"import": "./dist/cli/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"clean": "rm -rf dist",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
|
||||||
"picocolors": "^1.1.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^24.6.0",
|
|
||||||
"typescript": "^5.7.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import pc from "picocolors";
|
|
||||||
|
|
||||||
export function printOpenClawStreamEvent(raw: string, debug: boolean): void {
|
|
||||||
const line = raw.trim();
|
|
||||||
if (!line) return;
|
|
||||||
|
|
||||||
if (!debug) {
|
|
||||||
console.log(line);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.startsWith("[openclaw]")) {
|
|
||||||
console.log(pc.cyan(line));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(pc.gray(line));
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { printOpenClawStreamEvent } from "./format-event.js";
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
export const type = "openclaw";
|
|
||||||
export const label = "OpenClaw";
|
|
||||||
|
|
||||||
export const models: { id: string; label: string }[] = [];
|
|
||||||
|
|
||||||
export const agentConfigurationDoc = `# openclaw agent configuration
|
|
||||||
|
|
||||||
Adapter: openclaw
|
|
||||||
|
|
||||||
Use when:
|
|
||||||
- You run an OpenClaw agent remotely and wake it over HTTP.
|
|
||||||
- You want selectable transport:
|
|
||||||
- \`sse\` for streaming execution in one Paperclip run.
|
|
||||||
- \`webhook\` for wake-style callbacks (\`/hooks/wake\`, \`/hooks/agent\`, or compatibility webhooks).
|
|
||||||
|
|
||||||
Don't use when:
|
|
||||||
- You need local CLI execution inside Paperclip (use claude_local/codex_local/opencode_local/process).
|
|
||||||
- The OpenClaw endpoint is not reachable from the Paperclip server.
|
|
||||||
|
|
||||||
Core fields:
|
|
||||||
- url (string, required): OpenClaw endpoint URL
|
|
||||||
- streamTransport (string, optional): \`sse\` (default) or \`webhook\`
|
|
||||||
- method (string, optional): HTTP method, default POST
|
|
||||||
- headers (object, optional): extra HTTP headers for requests
|
|
||||||
- webhookAuthHeader (string, optional): Authorization header value if your endpoint requires auth
|
|
||||||
- payloadTemplate (object, optional): additional JSON payload fields merged into each wake payload
|
|
||||||
- paperclipApiUrl (string, optional): absolute http(s) Paperclip base URL to advertise to OpenClaw as \`PAPERCLIP_API_URL\`
|
|
||||||
- hookIncludeSessionKey (boolean, optional): when true, include derived \`sessionKey\` in \`/hooks/agent\` webhook payloads (default false)
|
|
||||||
|
|
||||||
Session routing fields:
|
|
||||||
- sessionKeyStrategy (string, optional): \`fixed\` (default), \`issue\`, or \`run\`
|
|
||||||
- sessionKey (string, optional): fixed session key value when strategy is \`fixed\` (default \`paperclip\`)
|
|
||||||
|
|
||||||
Operational fields:
|
|
||||||
- timeoutSec (number, optional): SSE request timeout in seconds (default 0 = no adapter timeout)
|
|
||||||
|
|
||||||
Hire-approved callback fields (optional):
|
|
||||||
- hireApprovedCallbackUrl (string): callback endpoint invoked when this agent is approved/hired
|
|
||||||
- hireApprovedCallbackMethod (string): HTTP method for the callback (default POST)
|
|
||||||
- hireApprovedCallbackAuthHeader (string): Authorization header value for callback requests
|
|
||||||
- hireApprovedCallbackHeaders (object): extra headers merged into callback requests
|
|
||||||
`;
|
|
||||||
@@ -1,534 +0,0 @@
|
|||||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
|
||||||
import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import { parseOpenClawResponse } from "./parse.js";
|
|
||||||
|
|
||||||
export type OpenClawTransport = "sse" | "webhook";
|
|
||||||
export type SessionKeyStrategy = "fixed" | "issue" | "run";
|
|
||||||
export type OpenClawEndpointKind = "open_responses" | "hook_wake" | "hook_agent" | "generic";
|
|
||||||
|
|
||||||
export type WakePayload = {
|
|
||||||
runId: string;
|
|
||||||
agentId: string;
|
|
||||||
companyId: string;
|
|
||||||
taskId: string | null;
|
|
||||||
issueId: string | null;
|
|
||||||
wakeReason: string | null;
|
|
||||||
wakeCommentId: string | null;
|
|
||||||
approvalId: string | null;
|
|
||||||
approvalStatus: string | null;
|
|
||||||
issueIds: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OpenClawExecutionState = {
|
|
||||||
method: string;
|
|
||||||
timeoutSec: number;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
payloadTemplate: Record<string, unknown>;
|
|
||||||
wakePayload: WakePayload;
|
|
||||||
sessionKey: string;
|
|
||||||
paperclipEnv: Record<string, string>;
|
|
||||||
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<string, string> {
|
|
||||||
const parsed = parseObject(value);
|
|
||||||
const out: Record<string, string> = {};
|
|
||||||
for (const [key, entry] of Object.entries(parsed)) {
|
|
||||||
if (typeof entry === "string") {
|
|
||||||
out[key] = entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function 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<string, unknown>);
|
|
||||||
const out: Record<string, unknown> = {};
|
|
||||||
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<string, string> {
|
|
||||||
const paperclipApiUrlOverride = resolvePaperclipApiUrlOverride(ctx.config.paperclipApiUrl);
|
|
||||||
const paperclipEnv: Record<string, string> = {
|
|
||||||
...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, string>): 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 ?? "<set PAPERCLIP_API_URL>";
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
"Paperclip wake event for a cloud adapter.",
|
|
||||||
"",
|
|
||||||
"Run this procedure now. Do not guess undocumented endpoints and do not ask for additional heartbeat docs.",
|
|
||||||
"",
|
|
||||||
"Set these values in your run context:",
|
|
||||||
...envLines,
|
|
||||||
`PAPERCLIP_API_KEY=<token from ${claimedApiKeyPath}>`,
|
|
||||||
"",
|
|
||||||
`Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`,
|
|
||||||
"",
|
|
||||||
`api_base=${apiBaseHint}`,
|
|
||||||
`task_id=${payload.taskId ?? ""}`,
|
|
||||||
`issue_id=${payload.issueId ?? ""}`,
|
|
||||||
`wake_reason=${payload.wakeReason ?? ""}`,
|
|
||||||
`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<string, unknown> {
|
|
||||||
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<string, unknown>).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<string, string>;
|
|
||||||
payload: Record<string, unknown>;
|
|
||||||
signal: AbortSignal;
|
|
||||||
}): Promise<Response> {
|
|
||||||
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<string> {
|
|
||||||
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}) <empty>\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<string, unknown>;
|
|
||||||
const payloadTemplate = parseObject(ctx.config.payloadTemplate);
|
|
||||||
const webhookAuthHeader = nonEmpty(ctx.config.webhookAuthHeader);
|
|
||||||
const sessionKeyStrategy = normalizeSessionKeyStrategy(ctx.config.sessionKeyStrategy);
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"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<string, unknown> {
|
|
||||||
return {
|
|
||||||
text: wakeText,
|
|
||||||
mode: "now",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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<string, unknown> | 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<string, unknown> | 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<ConsumedSse> {
|
|
||||||
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<string, unknown> | null = null;
|
|
||||||
let terminal = false;
|
|
||||||
let failed = false;
|
|
||||||
let errorMessage: string | null = null;
|
|
||||||
|
|
||||||
const dispatchEvent = async (): Promise<boolean> => {
|
|
||||||
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<string, string>; body: Record<string, unknown> } {
|
|
||||||
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<string, unknown> = 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<string, string> = {
|
|
||||||
...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<AdapterExecutionResult> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<string, unknown> {
|
|
||||||
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<string, unknown> {
|
|
||||||
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<string, unknown> {
|
|
||||||
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<string, unknown> = {
|
|
||||||
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<string, unknown> {
|
|
||||||
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<string, unknown> {
|
|
||||||
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<string, string>;
|
|
||||||
payload: Record<string, unknown>;
|
|
||||||
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<AdapterExecutionResult> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<AdapterExecutionResult> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -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<string, unknown>,
|
|
||||||
): Promise<HireApprovedHookResult> {
|
|
||||||
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<string, string> = {
|
|
||||||
"content-type": "application/json",
|
|
||||||
};
|
|
||||||
if (authHeader && !headers.authorization && !headers.Authorization) {
|
|
||||||
headers.Authorization = authHeader;
|
|
||||||
}
|
|
||||||
const extraHeaders = parseObject(config.hireApprovedCallbackHeaders) as Record<string, unknown>;
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export function parseOpenClawResponse(text: string): Record<string, unknown> | null {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(text);
|
|
||||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return parsed as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isOpenClawUnknownSessionError(_text: string): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -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 <host> 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<AdapterEnvironmentTestResult> {
|
|
||||||
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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
|
||||||
|
|
||||||
export function buildOpenClawConfig(v: CreateConfigValues): Record<string, unknown> {
|
|
||||||
const ac: Record<string, unknown> = {};
|
|
||||||
if (v.url) ac.url = v.url;
|
|
||||||
ac.method = "POST";
|
|
||||||
ac.timeoutSec = 0;
|
|
||||||
ac.streamTransport = "sse";
|
|
||||||
ac.sessionKeyStrategy = "fixed";
|
|
||||||
ac.sessionKey = "paperclip";
|
|
||||||
return ac;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { parseOpenClawStdoutLine } from "./parse-stdout.js";
|
|
||||||
export { buildOpenClawConfig } from "./build-config.js";
|
|
||||||
@@ -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<string, unknown> | null {
|
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
||||||
return value as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, unknown> | 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<string, unknown> | 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 }];
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ Use when:
|
|||||||
- You want OpenCode session resume across heartbeats via --session
|
- You want OpenCode session resume across heartbeats via --session
|
||||||
|
|
||||||
Don't use when:
|
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)
|
- You only need one-shot shell commands (use process)
|
||||||
- OpenCode CLI is not installed on the machine
|
- OpenCode CLI is not installed on the machine
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Use when:
|
|||||||
- You need Pi's tool set (read, bash, edit, write, grep, find, ls)
|
- You need Pi's tool set (read, bash, edit, write, grep, find, ls)
|
||||||
|
|
||||||
Don't use when:
|
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)
|
- You only need one-shot shell commands (use process)
|
||||||
- Pi CLI is not installed on the machine
|
- Pi CLI is not installed on the machine
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export const AGENT_ADAPTER_TYPES = [
|
|||||||
"opencode_local",
|
"opencode_local",
|
||||||
"pi_local",
|
"pi_local",
|
||||||
"cursor",
|
"cursor",
|
||||||
"openclaw",
|
|
||||||
"openclaw_gateway",
|
"openclaw_gateway",
|
||||||
] as const;
|
] as const;
|
||||||
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export {
|
|||||||
updateBudgetSchema,
|
updateBudgetSchema,
|
||||||
createAssetImageMetadataSchema,
|
createAssetImageMetadataSchema,
|
||||||
createCompanyInviteSchema,
|
createCompanyInviteSchema,
|
||||||
|
createOpenClawInvitePromptSchema,
|
||||||
acceptInviteSchema,
|
acceptInviteSchema,
|
||||||
listJoinRequestsQuerySchema,
|
listJoinRequestsQuerySchema,
|
||||||
claimJoinRequestApiKeySchema,
|
claimJoinRequestApiKeySchema,
|
||||||
@@ -206,6 +207,7 @@ export {
|
|||||||
type UpdateBudget,
|
type UpdateBudget,
|
||||||
type CreateAssetImageMetadata,
|
type CreateAssetImageMetadata,
|
||||||
type CreateCompanyInvite,
|
type CreateCompanyInvite,
|
||||||
|
type CreateOpenClawInvitePrompt,
|
||||||
type AcceptInvite,
|
type AcceptInvite,
|
||||||
type ListJoinRequestsQuery,
|
type ListJoinRequestsQuery,
|
||||||
type ClaimJoinRequestApiKey,
|
type ClaimJoinRequestApiKey,
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ export const createCompanyInviteSchema = z.object({
|
|||||||
|
|
||||||
export type CreateCompanyInvite = z.infer<typeof createCompanyInviteSchema>;
|
export type CreateCompanyInvite = z.infer<typeof createCompanyInviteSchema>;
|
||||||
|
|
||||||
|
export const createOpenClawInvitePromptSchema = z.object({
|
||||||
|
agentMessage: z.string().max(4000).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateOpenClawInvitePrompt = z.infer<
|
||||||
|
typeof createOpenClawInvitePromptSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export const acceptInviteSchema = z.object({
|
export const acceptInviteSchema = z.object({
|
||||||
requestType: z.enum(JOIN_REQUEST_TYPES),
|
requestType: z.enum(JOIN_REQUEST_TYPES),
|
||||||
agentName: z.string().min(1).max(120).optional(),
|
agentName: z.string().min(1).max(120).optional(),
|
||||||
|
|||||||
@@ -119,12 +119,14 @@ export {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
createCompanyInviteSchema,
|
createCompanyInviteSchema,
|
||||||
|
createOpenClawInvitePromptSchema,
|
||||||
acceptInviteSchema,
|
acceptInviteSchema,
|
||||||
listJoinRequestsQuerySchema,
|
listJoinRequestsQuerySchema,
|
||||||
claimJoinRequestApiKeySchema,
|
claimJoinRequestApiKeySchema,
|
||||||
updateMemberPermissionsSchema,
|
updateMemberPermissionsSchema,
|
||||||
updateUserCompanyAccessSchema,
|
updateUserCompanyAccessSchema,
|
||||||
type CreateCompanyInvite,
|
type CreateCompanyInvite,
|
||||||
|
type CreateOpenClawInvitePrompt,
|
||||||
type AcceptInvite,
|
type AcceptInvite,
|
||||||
type ListJoinRequestsQuery,
|
type ListJoinRequestsQuery,
|
||||||
type ClaimJoinRequestApiKey,
|
type ClaimJoinRequestApiKey,
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -132,6 +132,9 @@ importers:
|
|||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^24.6.0
|
||||||
|
version: 24.12.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const workspacePaths = [
|
|||||||
"packages/adapters/claude-local",
|
"packages/adapters/claude-local",
|
||||||
"packages/adapters/codex-local",
|
"packages/adapters/codex-local",
|
||||||
"packages/adapters/opencode-local",
|
"packages/adapters/opencode-local",
|
||||||
"packages/adapters/openclaw",
|
"packages/adapters/openclaw-gateway",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Workspace packages that are NOT bundled and must stay as npm dependencies.
|
// Workspace packages that are NOT bundled and must stay as npm dependencies.
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ const { readFileSync } = require('fs');
|
|||||||
const { resolve } = require('path');
|
const { resolve } = require('path');
|
||||||
const root = '$REPO_ROOT';
|
const root = '$REPO_ROOT';
|
||||||
const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db',
|
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'];
|
'server', 'cli'];
|
||||||
const names = [];
|
const names = [];
|
||||||
for (const d of dirs) {
|
for (const d of dirs) {
|
||||||
@@ -221,7 +221,7 @@ const { resolve } = require('path');
|
|||||||
const root = '$REPO_ROOT';
|
const root = '$REPO_ROOT';
|
||||||
const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8');
|
const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8');
|
||||||
const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db',
|
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'];
|
'server', 'cli'];
|
||||||
const names = [];
|
const names = [];
|
||||||
for (const d of dirs) {
|
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-claude-local build
|
||||||
pnpm --filter @paperclipai/adapter-codex-local build
|
pnpm --filter @paperclipai/adapter-codex-local build
|
||||||
pnpm --filter @paperclipai/adapter-opencode-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
|
pnpm --filter @paperclipai/server build
|
||||||
|
|
||||||
# Build UI and bundle into server package for static serving
|
# Build UI and bundle into server package for static serving
|
||||||
@@ -314,7 +314,7 @@ if [ "$dry_run" = true ]; then
|
|||||||
echo ""
|
echo ""
|
||||||
echo " Preview what would be published:"
|
echo " Preview what would be published:"
|
||||||
for dir in packages/shared packages/adapter-utils packages/db \
|
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
|
server cli; do
|
||||||
echo " --- $dir ---"
|
echo " --- $dir ---"
|
||||||
cd "$REPO_ROOT/$dir"
|
cd "$REPO_ROOT/$dir"
|
||||||
|
|||||||
@@ -179,24 +179,32 @@ if [[ -z "$ONBOARDING_TEXT_PATH" ]]; then
|
|||||||
fi
|
fi
|
||||||
api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt"
|
api_request "GET" "/invites/${INVITE_TOKEN}/onboarding.txt"
|
||||||
assert_status "200"
|
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"
|
fail "onboarding.txt response missing expected header"
|
||||||
fi
|
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 \
|
JOIN_PAYLOAD="$(jq -nc \
|
||||||
--arg name "$OPENCLAW_AGENT_NAME" \
|
--arg name "$OPENCLAW_AGENT_NAME" \
|
||||||
--arg url "$OPENCLAW_WEBHOOK_URL" \
|
--arg url "$OPENCLAW_GATEWAY_URL" \
|
||||||
--arg auth "$OPENCLAW_WEBHOOK_AUTH" \
|
--arg token "$OPENCLAW_GATEWAY_TOKEN" \
|
||||||
'{
|
'{
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
agentName: $name,
|
agentName: $name,
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
capabilities: "Automated OpenClaw smoke harness",
|
capabilities: "Automated OpenClaw gateway smoke harness",
|
||||||
agentDefaultsPayload: (
|
agentDefaultsPayload: {
|
||||||
{ url: $url, method: "POST", timeoutSec: 30 }
|
url: $url,
|
||||||
+ (if ($auth | length) > 0 then { webhookAuthHeader: $auth } else {} end)
|
headers: { "x-openclaw-token": $token },
|
||||||
)
|
sessionKeyStrategy: "issue",
|
||||||
|
waitTimeoutMs: 120000
|
||||||
|
}
|
||||||
}')"
|
}')"
|
||||||
api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD"
|
api_request "POST" "/invites/${INVITE_TOKEN}/accept" "$JOIN_PAYLOAD"
|
||||||
assert_status "202"
|
assert_status "202"
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
|
||||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/db": "workspace:*",
|
"@paperclipai/db": "workspace:*",
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ afterEach(() => {
|
|||||||
describe("notifyHireApproved", () => {
|
describe("notifyHireApproved", () => {
|
||||||
it("writes success activity when adapter hook returns ok", async () => {
|
it("writes success activity when adapter hook returns ok", async () => {
|
||||||
vi.mocked(findServerAdapter).mockReturnValue({
|
vi.mocked(findServerAdapter).mockReturnValue({
|
||||||
type: "openclaw",
|
type: "openclaw_gateway",
|
||||||
onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
|
onHireApproved: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ describe("notifyHireApproved", () => {
|
|||||||
id: "a1",
|
id: "a1",
|
||||||
companyId: "c1",
|
companyId: "c1",
|
||||||
name: "OpenClaw Agent",
|
name: "OpenClaw Agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -65,7 +65,7 @@ describe("notifyHireApproved", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
action: "hire_hook.succeeded",
|
action: "hire_hook.succeeded",
|
||||||
entityId: "a1",
|
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 () => {
|
it("logs failed result when adapter onHireApproved returns ok=false", async () => {
|
||||||
vi.mocked(findServerAdapter).mockReturnValue({
|
vi.mocked(findServerAdapter).mockReturnValue({
|
||||||
type: "openclaw",
|
type: "openclaw_gateway",
|
||||||
onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }),
|
onHireApproved: vi.fn().mockResolvedValue({ ok: false, error: "HTTP 500", detail: { status: 500 } }),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ describe("notifyHireApproved", () => {
|
|||||||
id: "a1",
|
id: "a1",
|
||||||
companyId: "c1",
|
companyId: "c1",
|
||||||
name: "OpenClaw Agent",
|
name: "OpenClaw Agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -148,7 +148,7 @@ describe("notifyHireApproved", () => {
|
|||||||
|
|
||||||
it("does not throw when adapter onHireApproved throws (non-fatal)", async () => {
|
it("does not throw when adapter onHireApproved throws (non-fatal)", async () => {
|
||||||
vi.mocked(findServerAdapter).mockReturnValue({
|
vi.mocked(findServerAdapter).mockReturnValue({
|
||||||
type: "openclaw",
|
type: "openclaw_gateway",
|
||||||
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
|
onHireApproved: vi.fn().mockRejectedValue(new Error("Network error")),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ describe("notifyHireApproved", () => {
|
|||||||
id: "a1",
|
id: "a1",
|
||||||
companyId: "c1",
|
companyId: "c1",
|
||||||
name: "OpenClaw Agent",
|
name: "OpenClaw Agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
119
server/src/__tests__/invite-accept-gateway-defaults.test.ts
Normal file
119
server/src/__tests__/invite-accept-gateway-defaults.test.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
buildJoinDefaultsPayloadForAccept,
|
|
||||||
normalizeAgentDefaultsForJoin,
|
|
||||||
} 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<string, unknown>;
|
|
||||||
|
|
||||||
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<string, unknown>;
|
|
||||||
|
|
||||||
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<string, unknown>;
|
|
||||||
|
|
||||||
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<string, unknown>;
|
|
||||||
|
|
||||||
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<string, unknown>;
|
|
||||||
|
|
||||||
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<string, unknown>;
|
|
||||||
|
|
||||||
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<string, unknown>;
|
|
||||||
|
|
||||||
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<string, unknown>;
|
|
||||||
|
|
||||||
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<string, unknown>;
|
|
||||||
|
|
||||||
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<string, unknown>;
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes wrapped gateway token headers for openclaw_gateway", () => {
|
|
||||||
const result = buildJoinDefaultsPayloadForAccept({
|
|
||||||
adapterType: "openclaw_gateway",
|
|
||||||
defaultsPayload: {
|
|
||||||
url: "ws://127.0.0.1:18789",
|
|
||||||
headers: {
|
|
||||||
"x-openclaw-token": {
|
|
||||||
value: "gateway-token-1234567890",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}) as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
url: "ws://127.0.0.1:18789",
|
|
||||||
headers: {
|
|
||||||
"x-openclaw-token": "gateway-token-1234567890",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts inbound x-openclaw-token for openclaw_gateway", () => {
|
|
||||||
const result = buildJoinDefaultsPayloadForAccept({
|
|
||||||
adapterType: "openclaw_gateway",
|
|
||||||
defaultsPayload: {
|
|
||||||
url: "ws://127.0.0.1:18789",
|
|
||||||
},
|
|
||||||
inboundOpenClawTokenHeader: "gateway-token-1234567890",
|
|
||||||
}) as Record<string, unknown>;
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
headers: {
|
|
||||||
"x-openclaw-token": "gateway-token-1234567890",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generates persistent device key for openclaw_gateway 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 openclaw_gateway has 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,63 +1,55 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildJoinDefaultsPayloadForAccept,
|
buildJoinDefaultsPayloadForAccept,
|
||||||
canReplayOpenClawInviteAccept,
|
canReplayOpenClawGatewayInviteAccept,
|
||||||
mergeJoinDefaultsPayloadForReplay,
|
mergeJoinDefaultsPayloadForReplay,
|
||||||
} from "../routes/access.js";
|
} from "../routes/access.js";
|
||||||
|
|
||||||
describe("canReplayOpenClawInviteAccept", () => {
|
describe("canReplayOpenClawGatewayInviteAccept", () => {
|
||||||
it("allows replay only for openclaw agent joins in pending or approved state", () => {
|
it("allows replay only for openclaw_gateway agent joins in pending or approved state", () => {
|
||||||
expect(
|
expect(
|
||||||
canReplayOpenClawInviteAccept({
|
canReplayOpenClawGatewayInviteAccept({
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
existingJoinRequest: {
|
existingJoinRequest: {
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
status: "pending_approval",
|
status: "pending_approval",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
canReplayOpenClawInviteAccept({
|
canReplayOpenClawGatewayInviteAccept({
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
existingJoinRequest: {
|
existingJoinRequest: {
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
status: "approved",
|
status: "approved",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
canReplayOpenClawInviteAccept({
|
canReplayOpenClawGatewayInviteAccept({
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
existingJoinRequest: {
|
existingJoinRequest: {
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
status: "rejected",
|
status: "rejected",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
canReplayOpenClawInviteAccept({
|
canReplayOpenClawGatewayInviteAccept({
|
||||||
requestType: "human",
|
requestType: "human",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
existingJoinRequest: {
|
existingJoinRequest: {
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
status: "pending_approval",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
canReplayOpenClawInviteAccept({
|
|
||||||
requestType: "agent",
|
|
||||||
adapterType: "process",
|
|
||||||
existingJoinRequest: {
|
|
||||||
requestType: "agent",
|
|
||||||
adapterType: "openclaw",
|
|
||||||
status: "pending_approval",
|
status: "pending_approval",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -66,36 +58,34 @@ describe("canReplayOpenClawInviteAccept", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("mergeJoinDefaultsPayloadForReplay", () => {
|
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(
|
const merged = mergeJoinDefaultsPayloadForReplay(
|
||||||
{
|
{
|
||||||
url: "https://old.example/v1/responses",
|
url: "ws://old.example:18789",
|
||||||
method: "POST",
|
|
||||||
paperclipApiUrl: "http://host.docker.internal:3100",
|
paperclipApiUrl: "http://host.docker.internal:3100",
|
||||||
headers: {
|
headers: {
|
||||||
"x-openclaw-auth": "old-token",
|
"x-openclaw-token": "old-token-1234567890",
|
||||||
"x-custom": "keep-me",
|
"x-custom": "keep-me",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
paperclipApiUrl: "https://paperclip.example.com",
|
paperclipApiUrl: "https://paperclip.example.com",
|
||||||
headers: {
|
headers: {
|
||||||
"x-openclaw-auth": "new-token",
|
"x-openclaw-token": "new-token-1234567890",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const normalized = buildJoinDefaultsPayloadForAccept({
|
const normalized = buildJoinDefaultsPayloadForAccept({
|
||||||
adapterType: "openclaw",
|
adapterType: "openclaw_gateway",
|
||||||
defaultsPayload: merged,
|
defaultsPayload: merged,
|
||||||
inboundOpenClawAuthHeader: null,
|
inboundOpenClawAuthHeader: null,
|
||||||
}) as Record<string, unknown>;
|
}) as Record<string, unknown>;
|
||||||
|
|
||||||
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.paperclipApiUrl).toBe("https://paperclip.example.com");
|
||||||
expect(normalized.webhookAuthHeader).toBe("Bearer new-token");
|
|
||||||
expect(normalized.headers).toMatchObject({
|
expect(normalized.headers).toMatchObject({
|
||||||
"x-openclaw-auth": "new-token",
|
"x-openclaw-token": "new-token-1234567890",
|
||||||
"x-custom": "keep-me",
|
"x-custom": "keep-me",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -167,6 +167,208 @@ async function createMockGatewayServer() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createMockGatewayServerWithPairing() {
|
||||||
|
const server = createServer();
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
let agentPayload: Record<string, unknown> | 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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (frame.type !== "req") return;
|
||||||
|
|
||||||
|
if (frame.method === "connect") {
|
||||||
|
const device = frame.params?.device as Record<string, unknown> | 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<void>((resolve) => {
|
||||||
|
server.listen(0, "127.0.0.1", () => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("Failed to resolve test server address");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `ws://127.0.0.1:${address.port}`,
|
||||||
|
getAgentPayload: () => agentPayload,
|
||||||
|
close: async () => {
|
||||||
|
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// no global mocks
|
// no global mocks
|
||||||
});
|
});
|
||||||
@@ -222,7 +424,7 @@ describe("openclaw gateway adapter execute", () => {
|
|||||||
const payload = gateway.getAgentPayload();
|
const payload = gateway.getAgentPayload();
|
||||||
expect(payload).toBeTruthy();
|
expect(payload).toBeTruthy();
|
||||||
expect(payload?.idempotencyKey).toBe("run-123");
|
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("wake now");
|
||||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
||||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-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.exitCode).toBe(1);
|
||||||
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
|
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", () => {
|
describe("openclaw gateway testEnvironment", () => {
|
||||||
|
|||||||
181
server/src/__tests__/openclaw-invite-prompt-route.test.ts
Normal file
181
server/src/__tests__/openclaw-invite-prompt-route.test.ts
Normal file
@@ -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<string, unknown>, db: Record<string, unknown>) {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,15 +26,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
agentConfigurationDoc as openCodeAgentConfigurationDoc,
|
agentConfigurationDoc as openCodeAgentConfigurationDoc,
|
||||||
} from "@paperclipai/adapter-opencode-local";
|
} 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 {
|
import {
|
||||||
execute as openclawGatewayExecute,
|
execute as openclawGatewayExecute,
|
||||||
testEnvironment as openclawGatewayTestEnvironment,
|
testEnvironment as openclawGatewayTestEnvironment,
|
||||||
@@ -89,16 +80,6 @@ const cursorLocalAdapter: ServerAdapterModule = {
|
|||||||
agentConfigurationDoc: cursorAgentConfigurationDoc,
|
agentConfigurationDoc: cursorAgentConfigurationDoc,
|
||||||
};
|
};
|
||||||
|
|
||||||
const openclawAdapter: ServerAdapterModule = {
|
|
||||||
type: "openclaw",
|
|
||||||
execute: openclawExecute,
|
|
||||||
testEnvironment: openclawTestEnvironment,
|
|
||||||
onHireApproved: openclawOnHireApproved,
|
|
||||||
models: openclawModels,
|
|
||||||
supportsLocalAgentJwt: false,
|
|
||||||
agentConfigurationDoc: openclawAgentConfigurationDoc,
|
|
||||||
};
|
|
||||||
|
|
||||||
const openclawGatewayAdapter: ServerAdapterModule = {
|
const openclawGatewayAdapter: ServerAdapterModule = {
|
||||||
type: "openclaw_gateway",
|
type: "openclaw_gateway",
|
||||||
execute: openclawGatewayExecute,
|
execute: openclawGatewayExecute,
|
||||||
@@ -137,7 +118,6 @@ const adaptersByType = new Map<string, ServerAdapterModule>(
|
|||||||
openCodeLocalAdapter,
|
openCodeLocalAdapter,
|
||||||
piLocalAdapter,
|
piLocalAdapter,
|
||||||
cursorLocalAdapter,
|
cursorLocalAdapter,
|
||||||
openclawAdapter,
|
|
||||||
openclawGatewayAdapter,
|
openclawGatewayAdapter,
|
||||||
processAdapter,
|
processAdapter,
|
||||||
httpAdapter,
|
httpAdapter,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
acceptInviteSchema,
|
acceptInviteSchema,
|
||||||
claimJoinRequestApiKeySchema,
|
claimJoinRequestApiKeySchema,
|
||||||
createCompanyInviteSchema,
|
createCompanyInviteSchema,
|
||||||
|
createOpenClawInvitePromptSchema,
|
||||||
listJoinRequestsQuerySchema,
|
listJoinRequestsQuerySchema,
|
||||||
updateMemberPermissionsSchema,
|
updateMemberPermissionsSchema,
|
||||||
updateUserCompanyAccessSchema,
|
updateUserCompanyAccessSchema,
|
||||||
@@ -135,19 +136,6 @@ function isLoopbackHost(hostname: string): boolean {
|
|||||||
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
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 {
|
function normalizeHostname(value: string | null | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -310,12 +298,6 @@ function headerMapGetIgnoreCase(
|
|||||||
return typeof value === "string" ? value : null;
|
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 {
|
function tokenFromAuthorizationHeader(rawHeader: string | null): string | null {
|
||||||
const trimmed = nonEmptyTrimmedString(rawHeader);
|
const trimmed = nonEmptyTrimmedString(rawHeader);
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
@@ -345,15 +327,14 @@ function generateEd25519PrivateKeyPem(): string {
|
|||||||
export function buildJoinDefaultsPayloadForAccept(input: {
|
export function buildJoinDefaultsPayloadForAccept(input: {
|
||||||
adapterType: string | null;
|
adapterType: string | null;
|
||||||
defaultsPayload: unknown;
|
defaultsPayload: unknown;
|
||||||
responsesWebhookUrl?: unknown;
|
|
||||||
responsesWebhookMethod?: unknown;
|
|
||||||
responsesWebhookHeaders?: unknown;
|
|
||||||
paperclipApiUrl?: unknown;
|
paperclipApiUrl?: unknown;
|
||||||
webhookAuthHeader?: unknown;
|
|
||||||
inboundOpenClawAuthHeader?: string | null;
|
inboundOpenClawAuthHeader?: string | null;
|
||||||
inboundOpenClawTokenHeader?: string | null;
|
inboundOpenClawTokenHeader?: string | null;
|
||||||
}): unknown {
|
}): unknown {
|
||||||
if (input.adapterType === "openclaw_gateway") {
|
if (input.adapterType !== "openclaw_gateway") {
|
||||||
|
return input.defaultsPayload;
|
||||||
|
}
|
||||||
|
|
||||||
const merged = isPlainObject(input.defaultsPayload)
|
const merged = isPlainObject(input.defaultsPayload)
|
||||||
? { ...(input.defaultsPayload as Record<string, unknown>) }
|
? { ...(input.defaultsPayload as Record<string, unknown>) }
|
||||||
: ({} as Record<string, unknown>);
|
: ({} as Record<string, unknown>);
|
||||||
@@ -362,7 +343,6 @@ export function buildJoinDefaultsPayloadForAccept(input: {
|
|||||||
const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl);
|
const legacyPaperclipApiUrl = nonEmptyTrimmedString(input.paperclipApiUrl);
|
||||||
if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl;
|
if (legacyPaperclipApiUrl) merged.paperclipApiUrl = legacyPaperclipApiUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {};
|
const mergedHeaders = normalizeHeaderMap(merged.headers) ?? {};
|
||||||
|
|
||||||
const inboundOpenClawAuthHeader = nonEmptyTrimmedString(
|
const inboundOpenClawAuthHeader = nonEmptyTrimmedString(
|
||||||
@@ -384,6 +364,12 @@ export function buildJoinDefaultsPayloadForAccept(input: {
|
|||||||
mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader;
|
mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.keys(mergedHeaders).length > 0) {
|
||||||
|
merged.headers = mergedHeaders;
|
||||||
|
} else {
|
||||||
|
delete merged.headers;
|
||||||
|
}
|
||||||
|
|
||||||
const discoveredToken =
|
const discoveredToken =
|
||||||
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ??
|
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-token") ??
|
||||||
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth") ??
|
headerMapGetIgnoreCase(mergedHeaders, "x-openclaw-auth") ??
|
||||||
@@ -397,102 +383,6 @@ export function buildJoinDefaultsPayloadForAccept(input: {
|
|||||||
mergedHeaders["x-openclaw-token"] = discoveredToken;
|
mergedHeaders["x-openclaw-token"] = discoveredToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(mergedHeaders).length > 0) {
|
|
||||||
merged.headers = mergedHeaders;
|
|
||||||
} else {
|
|
||||||
delete merged.headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(merged).length > 0 ? merged : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.adapterType !== "openclaw") {
|
|
||||||
return input.defaultsPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = isPlainObject(input.defaultsPayload)
|
|
||||||
? { ...(input.defaultsPayload as Record<string, unknown>) }
|
|
||||||
: ({} as Record<string, unknown>);
|
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
const inboundOpenClawTokenHeader = nonEmptyTrimmedString(
|
|
||||||
input.inboundOpenClawTokenHeader
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
inboundOpenClawTokenHeader &&
|
|
||||||
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-token")
|
|
||||||
) {
|
|
||||||
mergedHeaders["x-openclaw-token"] = inboundOpenClawTokenHeader;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
inboundOpenClawAuthHeader &&
|
|
||||||
!headerMapHasKeyIgnoreCase(mergedHeaders, "x-openclaw-auth")
|
|
||||||
) {
|
|
||||||
mergedHeaders["x-openclaw-auth"] = inboundOpenClawAuthHeader;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(mergedHeaders).length > 0) {
|
|
||||||
merged.headers = mergedHeaders;
|
|
||||||
} else {
|
|
||||||
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"
|
|
||||||
);
|
|
||||||
if (openClawAuthToken) {
|
|
||||||
merged.webhookAuthHeader = toAuthorizationHeaderValue(openClawAuthToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(merged).length > 0 ? merged : null;
|
return Object.keys(merged).length > 0 ? merged : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,7 +426,7 @@ export function mergeJoinDefaultsPayloadForReplay(
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canReplayOpenClawInviteAccept(input: {
|
export function canReplayOpenClawGatewayInviteAccept(input: {
|
||||||
requestType: "human" | "agent";
|
requestType: "human" | "agent";
|
||||||
adapterType: string | null;
|
adapterType: string | null;
|
||||||
existingJoinRequest: Pick<
|
existingJoinRequest: Pick<
|
||||||
@@ -544,7 +434,10 @@ export function canReplayOpenClawInviteAccept(input: {
|
|||||||
"requestType" | "adapterType" | "status"
|
"requestType" | "adapterType" | "status"
|
||||||
> | null;
|
> | null;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (input.requestType !== "agent" || input.adapterType !== "openclaw") {
|
if (
|
||||||
|
input.requestType !== "agent" ||
|
||||||
|
input.adapterType !== "openclaw_gateway"
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!input.existingJoinRequest) {
|
if (!input.existingJoinRequest) {
|
||||||
@@ -552,7 +445,7 @@ export function canReplayOpenClawInviteAccept(input: {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
input.existingJoinRequest.requestType !== "agent" ||
|
input.existingJoinRequest.requestType !== "agent" ||
|
||||||
input.existingJoinRequest.adapterType !== "openclaw"
|
input.existingJoinRequest.adapterType !== "openclaw_gateway"
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -574,32 +467,6 @@ function summarizeSecretForLog(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeOpenClawDefaultsForLog(defaultsPayload: unknown) {
|
|
||||||
const defaults = isPlainObject(defaultsPayload)
|
|
||||||
? (defaultsPayload as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const headers = defaults ? normalizeHeaderMap(defaults.headers) : undefined;
|
|
||||||
const openClawAuthHeaderValue = headers
|
|
||||||
? headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
|
|
||||||
headerMapGetIgnoreCase(headers, "x-openclaw-auth")
|
|
||||||
: 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)
|
|
||||||
: null,
|
|
||||||
openClawAuthHeader: summarizeSecretForLog(openClawAuthHeaderValue)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) {
|
function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) {
|
||||||
const defaults = isPlainObject(defaultsPayload)
|
const defaults = isPlainObject(defaultsPayload)
|
||||||
? (defaultsPayload as Record<string, unknown>)
|
? (defaultsPayload as Record<string, unknown>)
|
||||||
@@ -637,79 +504,6 @@ function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 <host> 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeAgentDefaultsForJoin(input: {
|
export function normalizeAgentDefaultsForJoin(input: {
|
||||||
adapterType: string | null;
|
adapterType: string | null;
|
||||||
defaultsPayload: unknown;
|
defaultsPayload: unknown;
|
||||||
@@ -720,17 +514,13 @@ export function normalizeAgentDefaultsForJoin(input: {
|
|||||||
}) {
|
}) {
|
||||||
const fatalErrors: string[] = [];
|
const fatalErrors: string[] = [];
|
||||||
const diagnostics: JoinDiagnostic[] = [];
|
const diagnostics: JoinDiagnostic[] = [];
|
||||||
if (
|
if (input.adapterType !== "openclaw_gateway") {
|
||||||
input.adapterType !== "openclaw" &&
|
|
||||||
input.adapterType !== "openclaw_gateway"
|
|
||||||
) {
|
|
||||||
const normalized = isPlainObject(input.defaultsPayload)
|
const normalized = isPlainObject(input.defaultsPayload)
|
||||||
? (input.defaultsPayload as Record<string, unknown>)
|
? (input.defaultsPayload as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
return { normalized, diagnostics, fatalErrors };
|
return { normalized, diagnostics, fatalErrors };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.adapterType === "openclaw_gateway") {
|
|
||||||
if (!isPlainObject(input.defaultsPayload)) {
|
if (!isPlainObject(input.defaultsPayload)) {
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
code: "openclaw_gateway_defaults_missing",
|
code: "openclaw_gateway_defaults_missing",
|
||||||
@@ -766,10 +556,7 @@ export function normalizeAgentDefaultsForJoin(input: {
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
gatewayUrl = new URL(rawGatewayUrl);
|
gatewayUrl = new URL(rawGatewayUrl);
|
||||||
if (
|
if (gatewayUrl.protocol !== "ws:" && gatewayUrl.protocol !== "wss:") {
|
||||||
gatewayUrl.protocol !== "ws:" &&
|
|
||||||
gatewayUrl.protocol !== "wss:"
|
|
||||||
) {
|
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
code: "openclaw_gateway_url_protocol",
|
code: "openclaw_gateway_url_protocol",
|
||||||
level: "warn",
|
level: "warn",
|
||||||
@@ -801,10 +588,7 @@ export function normalizeAgentDefaultsForJoin(input: {
|
|||||||
headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
|
headerMapGetIgnoreCase(headers, "x-openclaw-token") ??
|
||||||
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
||||||
tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization"));
|
tokenFromAuthorizationHeader(headerMapGetIgnoreCase(headers, "authorization"));
|
||||||
if (
|
if (gatewayToken && !headerMapHasKeyIgnoreCase(headers, "x-openclaw-token")) {
|
||||||
gatewayToken &&
|
|
||||||
!headerMapHasKeyIgnoreCase(headers, "x-openclaw-token")
|
|
||||||
) {
|
|
||||||
headers["x-openclaw-token"] = gatewayToken;
|
headers["x-openclaw-token"] = gatewayToken;
|
||||||
}
|
}
|
||||||
if (Object.keys(headers).length > 0) {
|
if (Object.keys(headers).length > 0) {
|
||||||
@@ -970,198 +754,6 @@ export function normalizeAgentDefaultsForJoin(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { normalized, diagnostics, fatalErrors };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPlainObject(input.defaultsPayload)) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_callback_config_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."
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
normalized: null as Record<string, unknown> | null,
|
|
||||||
diagnostics,
|
|
||||||
fatalErrors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaults = input.defaultsPayload as Record<string, unknown>;
|
|
||||||
const streamTransportInput = defaults.streamTransport ?? defaults.transport;
|
|
||||||
const streamTransport = normalizeOpenClawTransport(streamTransportInput);
|
|
||||||
const normalized: Record<string, unknown> = { 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let callbackUrl: URL | null = null;
|
|
||||||
const rawUrl = typeof defaults.url === "string" ? defaults.url.trim() : "";
|
|
||||||
if (!rawUrl) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_callback_url_missing",
|
|
||||||
level: "warn",
|
|
||||||
message: "OpenClaw callback URL is missing.",
|
|
||||||
hint: "Set agentDefaultsPayload.url to your OpenClaw endpoint."
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
callbackUrl = new URL(rawUrl);
|
|
||||||
if (
|
|
||||||
callbackUrl.protocol !== "http:" &&
|
|
||||||
callbackUrl.protocol !== "https:"
|
|
||||||
) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_callback_url_protocol",
|
|
||||||
level: "warn",
|
|
||||||
message: `Unsupported callback protocol: ${callbackUrl.protocol}`,
|
|
||||||
hint: "Use http:// or https://."
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
normalized.url = callbackUrl.toString();
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_callback_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."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_callback_url_invalid",
|
|
||||||
level: "warn",
|
|
||||||
message: `Invalid callback URL: ${rawUrl}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
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) {
|
|
||||||
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",
|
|
||||||
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."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlainObject(defaults.payloadTemplate)) {
|
|
||||||
normalized.payloadTemplate = defaults.payloadTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawPaperclipApiUrl =
|
|
||||||
typeof defaults.paperclipApiUrl === "string"
|
|
||||||
? defaults.paperclipApiUrl.trim()
|
|
||||||
: "";
|
|
||||||
if (rawPaperclipApiUrl) {
|
|
||||||
try {
|
|
||||||
const parsedPaperclipApiUrl = new URL(rawPaperclipApiUrl);
|
|
||||||
if (
|
|
||||||
parsedPaperclipApiUrl.protocol !== "http:" &&
|
|
||||||
parsedPaperclipApiUrl.protocol !== "https:"
|
|
||||||
) {
|
|
||||||
diagnostics.push({
|
|
||||||
code: "openclaw_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",
|
|
||||||
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",
|
|
||||||
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, fatalErrors };
|
return { normalized, diagnostics, fatalErrors };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1483,8 +1075,7 @@ export function buildInviteOnboardingTextDocument(
|
|||||||
paperclipApiUrl: "http://host.docker.internal:3100",
|
paperclipApiUrl: "http://host.docker.internal:3100",
|
||||||
headers: { "x-openclaw-token": token },
|
headers: { "x-openclaw-token": token },
|
||||||
waitTimeoutMs: 120000,
|
waitTimeoutMs: 120000,
|
||||||
sessionKeyStrategy: "fixed",
|
sessionKeyStrategy: "issue",
|
||||||
sessionKey: "paperclip",
|
|
||||||
role: "operator",
|
role: "operator",
|
||||||
scopes: ["operator.admin"]
|
scopes: ["operator.admin"]
|
||||||
}
|
}
|
||||||
@@ -1517,8 +1108,7 @@ export function buildInviteOnboardingTextDocument(
|
|||||||
"paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100",
|
"paperclipApiUrl": "https://paperclip-hostname-your-agent-can-reach:3100",
|
||||||
"headers": { "x-openclaw-token": "replace-me" },
|
"headers": { "x-openclaw-token": "replace-me" },
|
||||||
"waitTimeoutMs": 120000,
|
"waitTimeoutMs": 120000,
|
||||||
"sessionKeyStrategy": "fixed",
|
"sessionKeyStrategy": "issue",
|
||||||
"sessionKey": "paperclip",
|
|
||||||
"role": "operator",
|
"role": "operator",
|
||||||
"scopes": ["operator.admin"]
|
"scopes": ["operator.admin"]
|
||||||
}
|
}
|
||||||
@@ -1942,45 +1532,49 @@ export function accessRoutes(
|
|||||||
if (!allowed) throw forbidden("Permission denied");
|
if (!allowed) throw forbidden("Permission denied");
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get("/skills/index", (_req, res) => {
|
async function assertCanGenerateOpenClawInvitePrompt(
|
||||||
res.json({
|
req: Request,
|
||||||
skills: [
|
companyId: string
|
||||||
{ name: "paperclip", path: "/api/skills/paperclip" },
|
) {
|
||||||
{
|
assertCompanyAccess(req, companyId);
|
||||||
name: "paperclip-create-agent",
|
if (req.actor.type === "agent") {
|
||||||
path: "/api/skills/paperclip-create-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");
|
||||||
}
|
}
|
||||||
]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/skills/:skillName", (req, res) => {
|
async function createCompanyInviteForCompany(input: {
|
||||||
const skillName = (req.params.skillName as string).trim().toLowerCase();
|
req: Request;
|
||||||
const markdown = readSkillMarkdown(skillName);
|
companyId: string;
|
||||||
if (!markdown) throw notFound("Skill not found");
|
allowedJoinTypes: "human" | "agent" | "both";
|
||||||
res.type("text/markdown").send(markdown);
|
defaultsPayload?: Record<string, unknown> | null;
|
||||||
});
|
agentMessage?: string | null;
|
||||||
|
}) {
|
||||||
router.post(
|
|
||||||
"/companies/:companyId/invites",
|
|
||||||
validate(createCompanyInviteSchema),
|
|
||||||
async (req, res) => {
|
|
||||||
const companyId = req.params.companyId as string;
|
|
||||||
await assertCompanyPermission(req, companyId, "users:invite");
|
|
||||||
const normalizedAgentMessage =
|
const normalizedAgentMessage =
|
||||||
typeof req.body.agentMessage === "string"
|
typeof input.agentMessage === "string"
|
||||||
? req.body.agentMessage.trim() || null
|
? input.agentMessage.trim() || null
|
||||||
: null;
|
: null;
|
||||||
const insertValues = {
|
const insertValues = {
|
||||||
companyId,
|
companyId: input.companyId,
|
||||||
inviteType: "company_join" as const,
|
inviteType: "company_join" as const,
|
||||||
allowedJoinTypes: req.body.allowedJoinTypes,
|
allowedJoinTypes: input.allowedJoinTypes,
|
||||||
defaultsPayload: mergeInviteDefaults(
|
defaultsPayload: mergeInviteDefaults(
|
||||||
req.body.defaultsPayload ?? null,
|
input.defaultsPayload ?? null,
|
||||||
normalizedAgentMessage
|
normalizedAgentMessage
|
||||||
),
|
),
|
||||||
expiresAt: companyInviteExpiresAt(),
|
expiresAt: companyInviteExpiresAt(),
|
||||||
invitedByUserId: req.actor.userId ?? null
|
invitedByUserId: input.req.actor.userId ?? null
|
||||||
};
|
};
|
||||||
|
|
||||||
let token: string | null = null;
|
let token: string | null = null;
|
||||||
@@ -2006,11 +1600,46 @@ export function accessRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!token || !created) {
|
if (!token || !created) {
|
||||||
throw conflict(
|
throw conflict("Failed to generate a unique invite token. Please retry.");
|
||||||
"Failed to generate a unique invite token. Please retry."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { token, created, normalizedAgentMessage };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/skills/index", (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
skills: [
|
||||||
|
{ name: "paperclip", path: "/api/skills/paperclip" },
|
||||||
|
{
|
||||||
|
name: "paperclip-create-agent",
|
||||||
|
path: "/api/skills/paperclip-create-agent"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/skills/:skillName", (req, res) => {
|
||||||
|
const skillName = (req.params.skillName as string).trim().toLowerCase();
|
||||||
|
const markdown = readSkillMarkdown(skillName);
|
||||||
|
if (!markdown) throw notFound("Skill not found");
|
||||||
|
res.type("text/markdown").send(markdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/companies/:companyId/invites",
|
||||||
|
validate(createCompanyInviteSchema),
|
||||||
|
async (req, res) => {
|
||||||
|
const companyId = req.params.companyId as string;
|
||||||
|
await assertCompanyPermission(req, companyId, "users:invite");
|
||||||
|
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, {
|
await logActivity(db, {
|
||||||
companyId,
|
companyId,
|
||||||
actorType: req.actor.type === "agent" ? "agent" : "user",
|
actorType: req.actor.type === "agent" ? "agent" : "user",
|
||||||
@@ -2041,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) => {
|
router.get("/invites/:token", async (req, res) => {
|
||||||
const token = (req.params.token as string).trim();
|
const token = (req.params.token as string).trim();
|
||||||
if (!token) throw notFound("Invite not found");
|
if (!token) throw notFound("Invite not found");
|
||||||
@@ -2226,7 +1900,7 @@ export function accessRoutes(
|
|||||||
const adapterType = req.body.adapterType ?? null;
|
const adapterType = req.body.adapterType ?? null;
|
||||||
if (
|
if (
|
||||||
inviteAlreadyAccepted &&
|
inviteAlreadyAccepted &&
|
||||||
!canReplayOpenClawInviteAccept({
|
!canReplayOpenClawGatewayInviteAccept({
|
||||||
requestType,
|
requestType,
|
||||||
adapterType,
|
adapterType,
|
||||||
existingJoinRequest: existingJoinRequestForInvite
|
existingJoinRequest: existingJoinRequestForInvite
|
||||||
@@ -2248,59 +1922,22 @@ export function accessRoutes(
|
|||||||
)
|
)
|
||||||
: req.body.agentDefaultsPayload ?? null;
|
: req.body.agentDefaultsPayload ?? null;
|
||||||
|
|
||||||
const openClawDefaultsPayload =
|
const gatewayDefaultsPayload =
|
||||||
requestType === "agent"
|
requestType === "agent"
|
||||||
? buildJoinDefaultsPayloadForAccept({
|
? buildJoinDefaultsPayloadForAccept({
|
||||||
adapterType,
|
adapterType,
|
||||||
defaultsPayload: replayMergedDefaults,
|
defaultsPayload: replayMergedDefaults,
|
||||||
responsesWebhookUrl: req.body.responsesWebhookUrl ?? null,
|
|
||||||
responsesWebhookMethod: req.body.responsesWebhookMethod ?? null,
|
|
||||||
responsesWebhookHeaders: req.body.responsesWebhookHeaders ?? null,
|
|
||||||
paperclipApiUrl: req.body.paperclipApiUrl ?? null,
|
paperclipApiUrl: req.body.paperclipApiUrl ?? null,
|
||||||
webhookAuthHeader: req.body.webhookAuthHeader ?? null,
|
|
||||||
inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null,
|
inboundOpenClawAuthHeader: req.header("x-openclaw-auth") ?? null,
|
||||||
inboundOpenClawTokenHeader: req.header("x-openclaw-token") ?? null
|
inboundOpenClawTokenHeader: req.header("x-openclaw-token") ?? null
|
||||||
})
|
})
|
||||||
: 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 =
|
const joinDefaults =
|
||||||
requestType === "agent"
|
requestType === "agent"
|
||||||
? normalizeAgentDefaultsForJoin({
|
? normalizeAgentDefaultsForJoin({
|
||||||
adapterType,
|
adapterType,
|
||||||
defaultsPayload: openClawDefaultsPayload,
|
defaultsPayload: gatewayDefaultsPayload,
|
||||||
deploymentMode: opts.deploymentMode,
|
deploymentMode: opts.deploymentMode,
|
||||||
deploymentExposure: opts.deploymentExposure,
|
deploymentExposure: opts.deploymentExposure,
|
||||||
bindHost: opts.bindHost,
|
bindHost: opts.bindHost,
|
||||||
@@ -2316,22 +1953,6 @@ export function accessRoutes(
|
|||||||
throw badRequest(joinDefaults.fatalErrors.join("; "));
|
throw badRequest(joinDefaults.fatalErrors.join("; "));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestType === "agent" && adapterType === "openclaw") {
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
inviteId: invite.id,
|
|
||||||
joinRequestDiagnostics: joinDefaults.diagnostics.map((diag) => ({
|
|
||||||
code: diag.code,
|
|
||||||
level: diag.level
|
|
||||||
})),
|
|
||||||
normalizedAgentDefaults: summarizeOpenClawDefaultsForLog(
|
|
||||||
joinDefaults.normalized
|
|
||||||
)
|
|
||||||
},
|
|
||||||
"invite accept normalized OpenClaw defaults"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestType === "agent" && adapterType === "openclaw_gateway") {
|
if (requestType === "agent" && adapterType === "openclaw_gateway") {
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
@@ -2433,7 +2054,7 @@ export function accessRoutes(
|
|||||||
if (
|
if (
|
||||||
inviteAlreadyAccepted &&
|
inviteAlreadyAccepted &&
|
||||||
requestType === "agent" &&
|
requestType === "agent" &&
|
||||||
adapterType === "openclaw" &&
|
adapterType === "openclaw_gateway" &&
|
||||||
created.status === "approved" &&
|
created.status === "approved" &&
|
||||||
created.createdAgentId
|
created.createdAgentId
|
||||||
) {
|
) {
|
||||||
@@ -2469,11 +2090,11 @@ export function accessRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestType === "agent" && adapterType === "openclaw") {
|
if (requestType === "agent" && adapterType === "openclaw_gateway") {
|
||||||
const expectedDefaults = summarizeOpenClawDefaultsForLog(
|
const expectedDefaults = summarizeOpenClawGatewayDefaultsForLog(
|
||||||
joinDefaults.normalized
|
joinDefaults.normalized
|
||||||
);
|
);
|
||||||
const persistedDefaults = summarizeOpenClawDefaultsForLog(
|
const persistedDefaults = summarizeOpenClawGatewayDefaultsForLog(
|
||||||
created.agentDefaultsPayload
|
created.agentDefaultsPayload
|
||||||
);
|
);
|
||||||
const missingPersistedFields: string[] = [];
|
const missingPersistedFields: string[] = [];
|
||||||
@@ -2486,19 +2107,14 @@ export function accessRoutes(
|
|||||||
) {
|
) {
|
||||||
missingPersistedFields.push("paperclipApiUrl");
|
missingPersistedFields.push("paperclipApiUrl");
|
||||||
}
|
}
|
||||||
if (
|
if (expectedDefaults.gatewayToken && !persistedDefaults.gatewayToken) {
|
||||||
expectedDefaults.webhookAuthHeader &&
|
missingPersistedFields.push("headers.x-openclaw-token");
|
||||||
!persistedDefaults.webhookAuthHeader
|
|
||||||
) {
|
|
||||||
missingPersistedFields.push("webhookAuthHeader");
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
expectedDefaults.openClawAuthHeader &&
|
expectedDefaults.devicePrivateKeyPem &&
|
||||||
!persistedDefaults.openClawAuthHeader
|
!persistedDefaults.devicePrivateKeyPem
|
||||||
) {
|
) {
|
||||||
missingPersistedFields.push(
|
missingPersistedFields.push("devicePrivateKeyPem");
|
||||||
"headers.x-openclaw-token|headers.x-openclaw-auth"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
expectedDefaults.headerKeys.length > 0 &&
|
expectedDefaults.headerKeys.length > 0 &&
|
||||||
@@ -2521,7 +2137,7 @@ export function accessRoutes(
|
|||||||
hint: diag.hint ?? null
|
hint: diag.hint ?? null
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
"invite accept persisted OpenClaw join request"
|
"invite accept persisted OpenClaw gateway join request"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (missingPersistedFields.length > 0) {
|
if (missingPersistedFields.length > 0) {
|
||||||
@@ -2531,7 +2147,7 @@ export function accessRoutes(
|
|||||||
joinRequestId: created.id,
|
joinRequestId: created.id,
|
||||||
missingPersistedFields
|
missingPersistedFields
|
||||||
},
|
},
|
||||||
"invite accept detected missing persisted OpenClaw defaults"
|
"invite accept detected missing persisted OpenClaw gateway defaults"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, type Request } from "express";
|
import { Router, type Request } from "express";
|
||||||
import { randomUUID } from "node:crypto";
|
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { agents as agentsTable, companies, heartbeatRuns } 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;
|
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<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
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(
|
function applyCreateDefaultsByAdapterType(
|
||||||
adapterType: string | null | undefined,
|
adapterType: string | null | undefined,
|
||||||
adapterConfig: Record<string, unknown>,
|
adapterConfig: Record<string, unknown>,
|
||||||
@@ -196,13 +230,13 @@ export function agentRoutes(db: Db) {
|
|||||||
if (!hasBypassFlag) {
|
if (!hasBypassFlag) {
|
||||||
next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||||
}
|
}
|
||||||
return next;
|
return ensureGatewayDeviceKey(adapterType, next);
|
||||||
}
|
}
|
||||||
// OpenCode requires explicit model selection — no default
|
// OpenCode requires explicit model selection — no default
|
||||||
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
||||||
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||||
}
|
}
|
||||||
return next;
|
return ensureGatewayDeviceKey(adapterType, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertAdapterConfigConstraints(
|
async function assertAdapterConfigConstraints(
|
||||||
@@ -930,11 +964,7 @@ export function agentRoutes(db: Db) {
|
|||||||
if (changingInstructionsPath) {
|
if (changingInstructionsPath) {
|
||||||
await assertCanManageInstructionsPath(req, existing);
|
await assertCanManageInstructionsPath(req, existing);
|
||||||
}
|
}
|
||||||
patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
patchData.adapterConfig = adapterConfig;
|
||||||
existing.companyId,
|
|
||||||
adapterConfig,
|
|
||||||
{ strictMode: strictSecretsMode },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedAdapterType =
|
const requestedAdapterType =
|
||||||
@@ -942,15 +972,23 @@ export function agentRoutes(db: Db) {
|
|||||||
const touchesAdapterConfiguration =
|
const touchesAdapterConfiguration =
|
||||||
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
|
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
|
||||||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
|
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
|
||||||
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
|
if (touchesAdapterConfiguration) {
|
||||||
const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
|
const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
|
||||||
? (asRecord(patchData.adapterConfig) ?? {})
|
? (asRecord(patchData.adapterConfig) ?? {})
|
||||||
: (asRecord(existing.adapterConfig) ?? {});
|
: (asRecord(existing.adapterConfig) ?? {});
|
||||||
const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
const effectiveAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||||
existing.companyId,
|
requestedAdapterType,
|
||||||
rawEffectiveAdapterConfig,
|
rawEffectiveAdapterConfig,
|
||||||
|
);
|
||||||
|
const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||||
|
existing.companyId,
|
||||||
|
effectiveAdapterConfig,
|
||||||
{ strictMode: strictSecretsMode },
|
{ strictMode: strictSecretsMode },
|
||||||
);
|
);
|
||||||
|
patchData.adapterConfig = normalizedEffectiveAdapterConfig;
|
||||||
|
}
|
||||||
|
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
|
||||||
|
const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {};
|
||||||
await assertAdapterConfigConstraints(
|
await assertAdapterConfigConstraints(
|
||||||
existing.companyId,
|
existing.companyId,
|
||||||
requestedAdapterType,
|
requestedAdapterType,
|
||||||
|
|||||||
@@ -83,10 +83,6 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
|
|||||||
{ path: ["graceSec"], value: 15 },
|
{ path: ["graceSec"], value: 15 },
|
||||||
{ path: ["maxTurnsPerRun"], value: 80 },
|
{ path: ["maxTurnsPerRun"], value: 80 },
|
||||||
],
|
],
|
||||||
openclaw: [
|
|
||||||
{ path: ["method"], value: "POST" },
|
|
||||||
{ path: ["timeoutSec"], value: 30 },
|
|
||||||
],
|
|
||||||
openclaw_gateway: [
|
openclaw_gateway: [
|
||||||
{ path: ["timeoutSec"], value: 120 },
|
{ path: ["timeoutSec"], value: 120 },
|
||||||
{ path: ["waitTimeoutMs"], value: 120000 },
|
{ path: ["waitTimeoutMs"], value: 120000 },
|
||||||
|
|||||||
@@ -91,6 +91,30 @@ Workspace rules:
|
|||||||
- For repo-only setup, omit `cwd` and provide `repoUrl`.
|
- For repo-only setup, omit `cwd` and provide `repoUrl`.
|
||||||
- Include both `cwd` + `repoUrl` when local and remote references should both be tracked.
|
- Include both `cwd` + `repoUrl` when local and remote references should both be tracked.
|
||||||
|
|
||||||
|
## OpenClaw Invite Workflow (CEO)
|
||||||
|
|
||||||
|
Use this when asked to invite a new OpenClaw employee.
|
||||||
|
|
||||||
|
1. Generate a fresh OpenClaw invite prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/companies/{companyId}/openclaw/invite-prompt
|
||||||
|
{ "agentMessage": "optional onboarding note for OpenClaw" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Access control:
|
||||||
|
- Board users with invite permission can call it.
|
||||||
|
- Agent callers: only the company CEO agent can call it.
|
||||||
|
|
||||||
|
2. Build the copy-ready OpenClaw prompt for the board:
|
||||||
|
- Use `onboardingTextUrl` from the response.
|
||||||
|
- Ask the board to paste that prompt into OpenClaw.
|
||||||
|
- If the issue includes an OpenClaw URL (for example `ws://127.0.0.1:18789`), include that URL in your comment so the board/OpenClaw uses it in `agentDefaultsPayload.url`.
|
||||||
|
|
||||||
|
3. Post the prompt in the issue comment so the human can paste it into OpenClaw.
|
||||||
|
|
||||||
|
4. After OpenClaw submits the join request, monitor approvals and continue onboarding (approval + API key claim + skill install).
|
||||||
|
|
||||||
## Critical Rules
|
## Critical Rules
|
||||||
|
|
||||||
- **Always checkout** before working. Never PATCH to `in_progress` manually.
|
- **Always checkout** before working. Never PATCH to `in_progress` manually.
|
||||||
@@ -206,6 +230,7 @@ PATCH /api/agents/{agentId}/instructions-path
|
|||||||
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
|
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
|
||||||
| Add comment | `POST /api/issues/:issueId/comments` |
|
| Add comment | `POST /api/issues/:issueId/comments` |
|
||||||
| Create subtask | `POST /api/companies/:companyId/issues` |
|
| Create subtask | `POST /api/companies/:companyId/issues` |
|
||||||
|
| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` |
|
||||||
| Create project | `POST /api/companies/:companyId/projects` |
|
| Create project | `POST /api/companies/:companyId/projects` |
|
||||||
| Create project workspace | `POST /api/projects/:projectId/workspaces` |
|
| Create project workspace | `POST /api/projects/:projectId/workspaces` |
|
||||||
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
|
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
|
||||||
|
|||||||
@@ -280,6 +280,23 @@ GET /api/companies/{companyId}/dashboard — health summary: agent/task counts,
|
|||||||
|
|
||||||
Use the dashboard for situational awareness, especially if you're a manager or CEO.
|
Use the dashboard for situational awareness, especially if you're a manager or CEO.
|
||||||
|
|
||||||
|
## OpenClaw Invite Prompt (CEO)
|
||||||
|
|
||||||
|
Use this endpoint to generate a short-lived OpenClaw onboarding invite prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/companies/{companyId}/openclaw/invite-prompt
|
||||||
|
{
|
||||||
|
"agentMessage": "optional note for the joining OpenClaw agent"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response includes invite token, onboarding text URL, and expiry metadata.
|
||||||
|
|
||||||
|
Access is intentionally constrained:
|
||||||
|
- board users with invite permission
|
||||||
|
- CEO agent only (non-CEO agents are rejected)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Setting Agent Instructions Path
|
## Setting Agent Instructions Path
|
||||||
@@ -505,6 +522,7 @@ Terminal states: `done`, `cancelled`
|
|||||||
| GET | `/api/goals/:goalId` | Goal details |
|
| GET | `/api/goals/:goalId` | Goal details |
|
||||||
| POST | `/api/companies/:companyId/goals` | Create goal |
|
| POST | `/api/companies/:companyId/goals` | Create goal |
|
||||||
| PATCH | `/api/goals/:goalId` | Update goal |
|
| PATCH | `/api/goals/:goalId` | Update goal |
|
||||||
|
| POST | `/api/companies/:companyId/openclaw/invite-prompt` | Generate OpenClaw invite prompt (CEO/board only) |
|
||||||
|
|
||||||
### Approvals, Costs, Activity, Dashboard
|
### Approvals, Costs, Activity, Dashboard
|
||||||
|
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ npm dist-tag add @paperclipai/db@{version} latest
|
|||||||
npm dist-tag add @paperclipai/adapter-utils@{version} latest
|
npm dist-tag add @paperclipai/adapter-utils@{version} latest
|
||||||
npm dist-tag add @paperclipai/adapter-claude-local@{version} latest
|
npm dist-tag add @paperclipai/adapter-claude-local@{version} latest
|
||||||
npm dist-tag add @paperclipai/adapter-codex-local@{version} latest
|
npm dist-tag add @paperclipai/adapter-codex-local@{version} latest
|
||||||
npm dist-tag add @paperclipai/adapter-openclaw@{version} latest
|
npm dist-tag add @paperclipai/adapter-openclaw-gateway@{version} latest
|
||||||
```
|
```
|
||||||
|
|
||||||
**Script option:** Add `./scripts/release.sh --promote {version}` to automate
|
**Script option:** Add `./scripts/release.sh --promote {version}` to automate
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
|
||||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/shared": "workspace:*",
|
"@paperclipai/shared": "workspace:*",
|
||||||
|
|||||||
@@ -204,15 +204,11 @@ export function OpenClawGatewayConfigFields({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Disable device auth">
|
<Field label="Device auth">
|
||||||
<select
|
<div className="text-xs text-muted-foreground leading-relaxed">
|
||||||
value={String(eff("adapterConfig", "disableDeviceAuth", Boolean(config.disableDeviceAuth ?? false)))}
|
Always enabled for gateway agents. Paperclip persists a device key during onboarding so pairing approvals
|
||||||
onChange={(e) => mark("adapterConfig", "disableDeviceAuth", e.target.value === "true")}
|
remain stable across runs.
|
||||||
className={inputClass}
|
</div>
|
||||||
>
|
|
||||||
<option value="false">No (recommended)</option>
|
|
||||||
<option value="true">Yes</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
</Field>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<Field label={label}>
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setVisible((v) => !v)}
|
|
||||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
|
||||||
>
|
|
||||||
{visible ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
|
||||||
</button>
|
|
||||||
<DraftInput
|
|
||||||
value={value}
|
|
||||||
onCommit={onCommit}
|
|
||||||
immediate
|
|
||||||
type={visible ? "text" : "password"}
|
|
||||||
className={inputClass + " pl-8"}
|
|
||||||
placeholder={placeholder}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const effectiveHeaders =
|
|
||||||
(eff("adapterConfig", "headers", configuredHeaders) as Record<string, unknown>) ?? {};
|
|
||||||
const effectiveGatewayAuthHeader = typeof effectiveHeaders["x-openclaw-auth"] === "string"
|
|
||||||
? String(effectiveHeaders["x-openclaw-auth"])
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const commitGatewayAuthHeader = (rawValue: string) => {
|
|
||||||
const nextValue = rawValue.trim();
|
|
||||||
const nextHeaders: Record<string, unknown> = { ...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 (
|
|
||||||
<>
|
|
||||||
<Field label="Gateway URL" hint={help.webhookUrl}>
|
|
||||||
<DraftInput
|
|
||||||
value={
|
|
||||||
isCreate
|
|
||||||
? values!.url
|
|
||||||
: eff("adapterConfig", "url", String(config.url ?? ""))
|
|
||||||
}
|
|
||||||
onCommit={(v) =>
|
|
||||||
isCreate
|
|
||||||
? set!({ url: v })
|
|
||||||
: mark("adapterConfig", "url", v || undefined)
|
|
||||||
}
|
|
||||||
immediate
|
|
||||||
className={inputClass}
|
|
||||||
placeholder="https://..."
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
{!isCreate && (
|
|
||||||
<>
|
|
||||||
<Field label="Paperclip API URL override">
|
|
||||||
<DraftInput
|
|
||||||
value={
|
|
||||||
eff(
|
|
||||||
"adapterConfig",
|
|
||||||
"paperclipApiUrl",
|
|
||||||
String(config.paperclipApiUrl ?? ""),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onCommit={(v) => mark("adapterConfig", "paperclipApiUrl", v || undefined)}
|
|
||||||
immediate
|
|
||||||
className={inputClass}
|
|
||||||
placeholder="https://paperclip.example"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Transport">
|
|
||||||
<select
|
|
||||||
value={transport}
|
|
||||||
onChange={(e) => mark("adapterConfig", "streamTransport", e.target.value)}
|
|
||||||
className={inputClass}
|
|
||||||
>
|
|
||||||
<option value="sse">SSE (recommended)</option>
|
|
||||||
<option value="webhook">Webhook</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Session strategy">
|
|
||||||
<select
|
|
||||||
value={sessionStrategy}
|
|
||||||
onChange={(e) => mark("adapterConfig", "sessionKeyStrategy", e.target.value)}
|
|
||||||
className={inputClass}
|
|
||||||
>
|
|
||||||
<option value="fixed">Fixed</option>
|
|
||||||
<option value="issue">Per issue</option>
|
|
||||||
<option value="run">Per run</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{sessionStrategy === "fixed" && (
|
|
||||||
<Field label="Session key">
|
|
||||||
<DraftInput
|
|
||||||
value={eff("adapterConfig", "sessionKey", String(config.sessionKey ?? "paperclip"))}
|
|
||||||
onCommit={(v) => mark("adapterConfig", "sessionKey", v || undefined)}
|
|
||||||
immediate
|
|
||||||
className={inputClass}
|
|
||||||
placeholder="paperclip"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SecretField
|
|
||||||
label="Webhook auth header (optional)"
|
|
||||||
value={eff("adapterConfig", "webhookAuthHeader", String(config.webhookAuthHeader ?? ""))}
|
|
||||||
onCommit={(v) => mark("adapterConfig", "webhookAuthHeader", v || undefined)}
|
|
||||||
placeholder="Bearer <token>"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SecretField
|
|
||||||
label="Gateway auth token (x-openclaw-auth)"
|
|
||||||
value={effectiveGatewayAuthHeader}
|
|
||||||
onCommit={commitGatewayAuthHeader}
|
|
||||||
placeholder="OpenClaw gateway token"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -4,7 +4,6 @@ import { codexLocalUIAdapter } from "./codex-local";
|
|||||||
import { cursorLocalUIAdapter } from "./cursor";
|
import { cursorLocalUIAdapter } from "./cursor";
|
||||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||||
import { piLocalUIAdapter } from "./pi-local";
|
import { piLocalUIAdapter } from "./pi-local";
|
||||||
import { openClawUIAdapter } from "./openclaw";
|
|
||||||
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
||||||
import { processUIAdapter } from "./process";
|
import { processUIAdapter } from "./process";
|
||||||
import { httpUIAdapter } from "./http";
|
import { httpUIAdapter } from "./http";
|
||||||
@@ -16,7 +15,6 @@ const adaptersByType = new Map<string, UIAdapterModule>(
|
|||||||
openCodeLocalUIAdapter,
|
openCodeLocalUIAdapter,
|
||||||
piLocalUIAdapter,
|
piLocalUIAdapter,
|
||||||
cursorLocalUIAdapter,
|
cursorLocalUIAdapter,
|
||||||
openClawUIAdapter,
|
|
||||||
openClawGatewayUIAdapter,
|
openClawGatewayUIAdapter,
|
||||||
processUIAdapter,
|
processUIAdapter,
|
||||||
httpUIAdapter,
|
httpUIAdapter,
|
||||||
|
|||||||
@@ -64,6 +64,17 @@ type BoardClaimStatus = {
|
|||||||
claimedByUserId: string | null;
|
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 = {
|
export const accessApi = {
|
||||||
createCompanyInvite: (
|
createCompanyInvite: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
@@ -73,16 +84,18 @@ export const accessApi = {
|
|||||||
agentMessage?: string | null;
|
agentMessage?: string | null;
|
||||||
} = {},
|
} = {},
|
||||||
) =>
|
) =>
|
||||||
api.post<{
|
api.post<CompanyInviteCreated>(`/companies/${companyId}/invites`, input),
|
||||||
id: string;
|
|
||||||
token: string;
|
createOpenClawInvitePrompt: (
|
||||||
inviteUrl: string;
|
companyId: string,
|
||||||
expiresAt: string;
|
input: {
|
||||||
allowedJoinTypes: "human" | "agent" | "both";
|
agentMessage?: string | null;
|
||||||
onboardingTextPath?: string;
|
} = {},
|
||||||
onboardingTextUrl?: string;
|
) =>
|
||||||
inviteMessage?: string | null;
|
api.post<CompanyInviteCreated>(
|
||||||
}>(`/companies/${companyId}/invites`, input),
|
`/companies/${companyId}/openclaw/invite-prompt`,
|
||||||
|
input,
|
||||||
|
),
|
||||||
|
|
||||||
getInvite: (token: string) => api.get<InviteSummary>(`/invites/${token}`),
|
getInvite: (token: string) => api.get<InviteSummary>(`/invites/${token}`),
|
||||||
getInviteOnboarding: (token: string) =>
|
getInviteOnboarding: (token: string) =>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const adapterLabels: Record<string, string> = {
|
|||||||
claude_local: "Claude (local)",
|
claude_local: "Claude (local)",
|
||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw: "OpenClaw",
|
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ function parseStdoutChunk(
|
|||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
const parsed = adapter.parseStdoutLine(trimmed, ts);
|
const parsed = adapter.parseStdoutLine(trimmed, ts);
|
||||||
if (parsed.length === 0) {
|
if (parsed.length === 0) {
|
||||||
if (run.adapterType === "openclaw" || run.adapterType === "openclaw_gateway") {
|
if (run.adapterType === "openclaw_gateway") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
|
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, type ComponentType } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@/lib/router";
|
import { useNavigate } from "@/lib/router";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
@@ -9,12 +10,77 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
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() {
|
export function NewAgentDialog() {
|
||||||
const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog();
|
const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [showAdvancedCards, setShowAdvancedCards] = useState(false);
|
||||||
|
|
||||||
const { data: agents } = useQuery({
|
const { data: agents } = useQuery({
|
||||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
@@ -34,15 +100,23 @@ export function NewAgentDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAdvancedConfig() {
|
function handleAdvancedConfig() {
|
||||||
|
setShowAdvancedCards(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdvancedAdapterPick(adapterType: AdvancedAdapterType) {
|
||||||
closeNewAgent();
|
closeNewAgent();
|
||||||
navigate("/agents/new");
|
setShowAdvancedCards(false);
|
||||||
|
navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={newAgentOpen}
|
open={newAgentOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) closeNewAgent();
|
if (!open) {
|
||||||
|
setShowAdvancedCards(false);
|
||||||
|
closeNewAgent();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
@@ -56,13 +130,18 @@ export function NewAgentDialog() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
onClick={closeNewAgent}
|
onClick={() => {
|
||||||
|
setShowAdvancedCards(false);
|
||||||
|
closeNewAgent();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-lg leading-none">×</span>
|
<span className="text-lg leading-none">×</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
|
{!showAdvancedCards ? (
|
||||||
|
<>
|
||||||
{/* Recommendation */}
|
{/* Recommendation */}
|
||||||
<div className="text-center space-y-3">
|
<div className="text-center space-y-3">
|
||||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
|
||||||
@@ -89,6 +168,46 @@ export function NewAgentDialog() {
|
|||||||
I want advanced configuration myself
|
I want advanced configuration myself
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => setShowAdvancedCards(false)}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Choose your adapter type for advanced setup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{ADVANCED_ADAPTER_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-1.5 rounded-md border border-border p-3 text-xs transition-colors hover:bg-accent/50 relative"
|
||||||
|
)}
|
||||||
|
onClick={() => handleAdvancedAdapterPick(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.recommended && (
|
||||||
|
<span className="absolute -top-1.5 right-1.5 bg-green-500 text-white text-[9px] font-semibold px-1.5 py-0.5 rounded-full leading-none">
|
||||||
|
Recommended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<opt.icon className="h-4 w-4" />
|
||||||
|
<span className="font-medium">{opt.label}</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
{opt.desc}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Terminal,
|
Terminal,
|
||||||
Globe,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
MousePointer2,
|
MousePointer2,
|
||||||
Check,
|
Check,
|
||||||
@@ -57,7 +56,6 @@ type AdapterType =
|
|||||||
| "cursor"
|
| "cursor"
|
||||||
| "process"
|
| "process"
|
||||||
| "http"
|
| "http"
|
||||||
| "openclaw"
|
|
||||||
| "openclaw_gateway";
|
| "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)
|
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,
|
icon: Terminal,
|
||||||
desc: "Local Pi agent"
|
desc: "Local Pi agent"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "openclaw" as const,
|
|
||||||
label: "OpenClaw",
|
|
||||||
icon: Bot,
|
|
||||||
desc: "Notify OpenClaw webhook",
|
|
||||||
comingSoon: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "openclaw_gateway" as const,
|
value: "openclaw_gateway" as const,
|
||||||
label: "OpenClaw Gateway",
|
label: "OpenClaw Gateway",
|
||||||
icon: Bot,
|
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,
|
value: "cursor" as const,
|
||||||
label: "Cursor",
|
label: "Cursor",
|
||||||
icon: MousePointer2,
|
icon: MousePointer2,
|
||||||
desc: "Local Cursor agent"
|
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) => (
|
].map((opt) => (
|
||||||
<button
|
<button
|
||||||
@@ -744,7 +723,10 @@ export function OnboardingWizard() {
|
|||||||
<opt.icon className="h-4 w-4" />
|
<opt.icon className="h-4 w-4" />
|
||||||
<span className="font-medium">{opt.label}</span>
|
<span className="font-medium">{opt.label}</span>
|
||||||
<span className="text-muted-foreground text-[10px]">
|
<span className="text-muted-foreground text-[10px]">
|
||||||
{opt.comingSoon ? "Coming soon" : opt.desc}
|
{opt.comingSoon
|
||||||
|
? (opt as { disabledLabel?: string }).disabledLabel ??
|
||||||
|
"Coming soon"
|
||||||
|
: opt.desc}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -988,7 +970,7 @@ export function OnboardingWizard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(adapterType === "http" || adapterType === "openclaw" || adapterType === "openclaw_gateway") && (
|
{(adapterType === "http" || adapterType === "openclaw_gateway") && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">
|
<label className="text-xs text-muted-foreground mb-1 block">
|
||||||
{adapterType === "openclaw_gateway" ? "Gateway URL" : "Webhook URL"}
|
{adapterType === "openclaw_gateway" ? "Gateway URL" : "Webhook URL"}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const help: Record<string, string> = {
|
|||||||
role: "Organizational role. Determines position and capabilities.",
|
role: "Organizational role. Determines position and capabilities.",
|
||||||
reportsTo: "The agent this one reports to in the org hierarchy.",
|
reportsTo: "The agent this one reports to in the org hierarchy.",
|
||||||
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
||||||
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw (HTTP hooks or Gateway protocol), spawned process, or generic HTTP webhook.",
|
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw Gateway, spawned process, or generic HTTP webhook.",
|
||||||
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
|
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
|
||||||
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
||||||
model: "Override the default model used by the adapter.",
|
model: "Override the default model used by the adapter.",
|
||||||
@@ -53,7 +53,6 @@ export const adapterLabels: Record<string, string> = {
|
|||||||
claude_local: "Claude (local)",
|
claude_local: "Claude (local)",
|
||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw: "OpenClaw",
|
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const adapterLabels: Record<string, string> = {
|
|||||||
codex_local: "Codex",
|
codex_local: "Codex",
|
||||||
opencode_local: "OpenCode",
|
opencode_local: "OpenCode",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
openclaw: "OpenClaw",
|
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
|
|||||||
@@ -77,9 +77,7 @@ export function CompanySettings() {
|
|||||||
|
|
||||||
const inviteMutation = useMutation({
|
const inviteMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
accessApi.createCompanyInvite(selectedCompanyId!, {
|
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
|
||||||
allowedJoinTypes: "agent"
|
|
||||||
}),
|
|
||||||
onSuccess: async (invite) => {
|
onSuccess: async (invite) => {
|
||||||
setInviteError(null);
|
setInviteError(null);
|
||||||
const base = window.location.origin.replace(/\/+$/, "");
|
const base = window.location.origin.replace(/\/+$/, "");
|
||||||
@@ -317,9 +315,9 @@ export function CompanySettings() {
|
|||||||
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Generate an agent snippet for join flows.
|
Generate an OpenClaw agent invite snippet.
|
||||||
</span>
|
</span>
|
||||||
<HintIcon text="Creates an agent-only invite (10m) and renders a copy-ready snippet." />
|
<HintIcon text="Creates a short-lived OpenClaw agent invite and renders a copy-ready prompt." />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -329,7 +327,7 @@ export function CompanySettings() {
|
|||||||
>
|
>
|
||||||
{inviteMutation.isPending
|
{inviteMutation.isPending
|
||||||
? "Generating..."
|
? "Generating..."
|
||||||
: "Generate agent snippet"}
|
: "Generate OpenClaw Invite Prompt"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{inviteError && (
|
{inviteError && (
|
||||||
@@ -339,7 +337,7 @@ export function CompanySettings() {
|
|||||||
<div className="rounded-md border border-border bg-muted/30 p-2">
|
<div className="rounded-md border border-border bg-muted/30 p-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Agent Snippet
|
OpenClaw Invite Prompt
|
||||||
</div>
|
</div>
|
||||||
{snippetCopied && (
|
{snippetCopied && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -10,16 +10,12 @@ import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
|||||||
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
|
||||||
|
|
||||||
type JoinType = "human" | "agent";
|
type JoinType = "human" | "agent";
|
||||||
const joinAdapterOptions: AgentAdapterType[] = [
|
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
|
||||||
"openclaw",
|
|
||||||
...AGENT_ADAPTER_TYPES.filter((type): type is Exclude<AgentAdapterType, "openclaw"> => type !== "openclaw"),
|
|
||||||
];
|
|
||||||
|
|
||||||
const adapterLabels: Record<string, string> = {
|
const adapterLabels: Record<string, string> = {
|
||||||
claude_local: "Claude (local)",
|
claude_local: "Claude (local)",
|
||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw: "OpenClaw",
|
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@/lib/router";
|
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
@@ -19,12 +19,45 @@ import { AgentConfigForm, type CreateConfigValues } from "../components/AgentCon
|
|||||||
import { defaultCreateValues } from "../components/agent-config-defaults";
|
import { defaultCreateValues } from "../components/agent-config-defaults";
|
||||||
import { getUIAdapter } from "../adapters";
|
import { getUIAdapter } from "../adapters";
|
||||||
import { AgentIcon } from "../components/AgentIconPicker";
|
import { AgentIcon } from "../components/AgentIconPicker";
|
||||||
|
import {
|
||||||
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||||
|
DEFAULT_CODEX_LOCAL_MODEL,
|
||||||
|
} from "@paperclipai/adapter-codex-local";
|
||||||
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||||
|
|
||||||
|
const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType"]>([
|
||||||
|
"claude_local",
|
||||||
|
"codex_local",
|
||||||
|
"opencode_local",
|
||||||
|
"pi_local",
|
||||||
|
"cursor",
|
||||||
|
"openclaw_gateway",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function createValuesForAdapterType(
|
||||||
|
adapterType: CreateConfigValues["adapterType"],
|
||||||
|
): CreateConfigValues {
|
||||||
|
const { adapterType: _discard, ...defaults } = defaultCreateValues;
|
||||||
|
const nextValues: CreateConfigValues = { ...defaults, adapterType };
|
||||||
|
if (adapterType === "codex_local") {
|
||||||
|
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
|
||||||
|
nextValues.dangerouslyBypassSandbox =
|
||||||
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||||
|
} else if (adapterType === "cursor") {
|
||||||
|
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||||
|
} else if (adapterType === "opencode_local") {
|
||||||
|
nextValues.model = "";
|
||||||
|
}
|
||||||
|
return nextValues;
|
||||||
|
}
|
||||||
|
|
||||||
export function NewAgent() {
|
export function NewAgent() {
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const presetAdapterType = searchParams.get("adapterType");
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
@@ -71,6 +104,18 @@ export function NewAgent() {
|
|||||||
}
|
}
|
||||||
}, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const requested = presetAdapterType;
|
||||||
|
if (!requested) return;
|
||||||
|
if (!SUPPORTED_ADVANCED_ADAPTER_TYPES.has(requested as CreateConfigValues["adapterType"])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfigValues((prev) => {
|
||||||
|
if (prev.adapterType === requested) return prev;
|
||||||
|
return createValuesForAdapterType(requested as CreateConfigValues["adapterType"]);
|
||||||
|
});
|
||||||
|
}, [presetAdapterType]);
|
||||||
|
|
||||||
const createAgent = useMutation({
|
const createAgent = useMutation({
|
||||||
mutationFn: (data: Record<string, unknown>) =>
|
mutationFn: (data: Record<string, unknown>) =>
|
||||||
agentsApi.hire(selectedCompanyId!, data),
|
agentsApi.hire(selectedCompanyId!, data),
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ const adapterLabels: Record<string, string> = {
|
|||||||
codex_local: "Codex",
|
codex_local: "Codex",
|
||||||
opencode_local: "OpenCode",
|
opencode_local: "OpenCode",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
openclaw: "OpenClaw",
|
|
||||||
openclaw_gateway: "OpenClaw Gateway",
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
|
|||||||
Reference in New Issue
Block a user