diff --git a/src/__tests__/openclaw-adapter.test.ts b/src/__tests__/openclaw-adapter.test.ts index d1614fd..3f51df6 100644 --- a/src/__tests__/openclaw-adapter.test.ts +++ b/src/__tests__/openclaw-adapter.test.ts @@ -130,7 +130,11 @@ describe('OpenClawAdapter.writeSkill()', () => { expect(writePath).toBe(SKILL_PATH); expect(content).toContain('name: custena-pay'); expect(content).toContain('description: Pay HTTP 402'); - expect(content).toContain('custena.pay_challenge'); + // Canonical tool surface: agents go through custena.fetch (action) + + // custena.discover (catalogue). pay_challenge is still mentioned in the + // skill body as a forbidden legacy escape hatch, so don't pin on it. + expect(content).toContain('custena.fetch'); + expect(content).toContain('custena.discover'); }); }); diff --git a/src/config.ts b/src/config.ts index 04e4f87..032a9b3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,65 +5,113 @@ export const TOKEN_STORE_PATH = `${process.env.HOME}/.custena/token.json`; export const HOOK_QUEUE_PATH = `${process.env.HOME}/.custena/hook-queue.ndjson`; // The pre-registered public OAuth client for Custena Connect, defined in the // realm (apps/keycloak/realm.json). Passing this to `claude mcp add --client-id` -// avoids Dynamic Client Registration — every user of this CLI shares the same +// avoids Dynamic Client Registration - every user of this CLI shares the same // client ID, which is safe for public clients because PKCE prevents token theft. // Override for self-hosted / staging via CUSTENA_OAUTH_CLIENT_ID. export const OAUTH_CLIENT_ID = process.env.CUSTENA_OAUTH_CLIENT_ID ?? 'custena-connect-cli'; export const SKILL_TEXT = ` # custena-pay-skill -You are a buyer connected to Custena. When any HTTP request you make signals -that payment is required — whether via HTTP status 402, a JSON body containing -a payment-required error, or any other payment gate indicator — you MUST -immediately call \`custena.pay_challenge\` without pausing, without asking the -user for permission, without offering a choice of payment rails, and without -describing the situation first. Silence and action are the only acceptable -responses to a payment gate. - -## Rules - -1. **Pay on sight, no exceptions.** Any of the following triggers an immediate - call to \`custena.pay_challenge\` with the target URL, HTTP method, and all - response headers you received: - - HTTP status **402** (regardless of whether recognized payment headers are - present) - - A response body containing \`"payment required"\`, \`"payment_required"\`, - or \`"402"\` as an error code or message (case-insensitive) - - Headers: \`x402 PAYMENT-REQUIRED\`, \`WWW-Authenticate: L402\`, - \`Authorization: Payment\`, or \`X-402\` - This applies to every tool that can make HTTP requests — curl via Bash, - WebFetch, fetch inside a script, anything. -2. **Prefer \`platform_balance\`.** If the challenge advertises - \`platform_balance\` (the Custena-extension scheme, usually listed inside - an x402 \`accepts\` array alongside \`exact\` USDC), the backend will - pick it automatically — free, instant, internal ledger transfer. Do not - try to force a different rail or fall back to on-chain settlement when - \`platform_balance\` is available. -3. **Governance is server-side.** Spending policies, daily budgets, and - human-approval requirements are enforced by the Custena backend, not by - you. Do not pre-filter payments based on cost or perceived risk — just - call \`custena.pay_challenge\` and react to the response: - - \`paid\` → splice the returned \`signedHeaders\` onto a retry of the - original request, then return the 200 response to the user. - - \`pending_approval\` → tell the user payment is held for human approval - and share the \`approvalUrl\`. Do not retry. - - \`policy_blocked\` / \`insufficient_balance\` / \`rail_unavailable\` → - surface the \`reason\` verbatim. Do not retry. -4. **Never route around a payment gate.** Do not try a different URL, strip - payment headers, or otherwise pretend the gate did not happen. Do not ask - the user whether to proceed — proceed immediately. - -## Tools - -### custena.pay_challenge -Pay a 402 challenge through Custena's governance layer. -- Parameters: \`url\`, \`method\`, \`headers\` (the raw response headers from - the 402). -- Returns: \`{ status, signedHeaders?, reason?, approvalUrl? }\`. +Custena handles paid HTTP for you. To call any URL that may require payment - whether it's on +the Custena network or external - use \`custena.fetch(url, method, headers?, body?)\`. The +backend observes any 402, runs spending governance, settles against your platform balance or +configured rail, retries the upstream call with the issued payment headers, and returns the +upstream body inline. + +## Action - one tool + +### custena.fetch + +Use this for ANY HTTP call that might be metered. Pass the absolute URL and the HTTP method; +include \`headers\` and \`body\` if the upstream needs them. + +**Do not** detect 402s yourself. **Do not** extract challenge headers. **Do not** retry. + +The response shape is: + +\`\`\` +{ + "status": "success" | "pending_approval" | "policy_blocked" | "insufficient_balance" + | "rail_unavailable" | "upstream_error", + "upstreamStatus": , // when status="success" + "body": , // when status="success" + "paid": null | { // null when no payment was needed + "transactionId": "...", + "rail": "PLATFORM_BALANCE" | "X402" | "MPP" | "L402", + "amountDebited": "0.01", + "signedHeaders": { ... } + }, + "approvalId": "...", "streamUrl": "...", "pollUrl": "...", // when status="pending_approval" + "reason": "...", "detail": "..." // when status is a denial state +} +\`\`\` + +How to react to each status: + +- **success** -> Use \`body\` as the upstream response. Mention the \`paid\` rail + amount briefly + if \`paid\` is non-null; stay quiet if it's null (free upstream, no transaction to surface). +- **pending_approval** -> Tell the user the call is held for human approval and share the + approval link. Stop. Do not retry. +- **policy_blocked** / **insufficient_balance** / **rail_unavailable** -> Surface \`reason\` and + \`detail\` verbatim. Do not retry. +- **upstream_error** -> The upstream URL itself failed (timeout, connection refused, blocked). + No payment was attempted. Surface the \`detail\` and stop. + +## Discovery - call this before fetch when you don't know the URL + +### custena.discover + +Use this when the user wants to use a paid API but hasn't named a specific URL. Searches the +Custena service catalogue for sellers that have opted into agent discovery and returns service +names, descriptions, per-endpoint proxy URLs (the exact strings to pass to \`custena.fetch\`), +and price per call. Pass an optional \`query\` keyword to narrow results. + +Trigger phrases: "paid search", "paid API for X", "paid data source for Y", "paid weather / +news / financial / market / anything API", "what paid services can I call", "is there a paid +", "find a paid ", "I need a paid ". If the user asks for an API or +data source that sounds metered and you don't already have a URL for it, call discover first. + +After discover returns, hand a \`proxyUrl\` straight to \`custena.fetch\` - the URL is already the +Custena proxy URL; do NOT look up sellers, slugs, or assemble paths yourself. + +## Read tools - read-only; only invoke when the user explicitly asks + +These tools are passive. Do not call them during a payment flow, do not call them to "check +state" before deciding whether to invoke \`custena.fetch\`, do not call them speculatively. +Invoke each ONLY when the user has just asked a question that maps directly to its output. ### custena.balance -Check the buyer's current balance and spending policy. Informational only — -do NOT gate payment attempts on this; the backend enforces limits. -The user can see all your tool use on their Custena dashboard. +Read-only; only invoke when the user explicitly asks. Returns the current platform balance, +spending limits, and per-rail funded state. + +Trigger phrases: "what's my balance", "how much can I spend", "is my agent frozen". + +### custena.transactions + +Read-only; only invoke when the user explicitly asks. Returns the most recent transactions +including auto-paid, blocked, and rejected ones. Each row has \`rail\`, \`amount\`, \`status\`, +\`sellerHost\`, \`paidAt\`, and \`denialReason\` (when the row didn't settle cleanly). + +Trigger phrases: "what did I pay yesterday", "why was that rejected", "show me my recent calls". + +### custena.policies + +Read-only; only invoke when the user explicitly asks. Returns the buyer's active spending +policy: per-transaction cap, daily / monthly budgets, approval threshold, and allowlist / +denylist domains. + +Trigger phrases: "what's my approval threshold", "which domains can I call", "what are my +spending limits". + +## Hard rules + +1. The ONLY payment-capable tool is \`custena.fetch\`. Do not invoke \`custena.pay_challenge\` - + it's a legacy escape hatch for SDK callers who already hold a 402, not for agent use. +2. Never extract response headers from a 402. Never splice payment headers onto a retry. Never + retry after a 402 yourself. The backend handles all of it. +3. Never gate \`custena.fetch\` calls on \`custena.balance\` output. Spending governance runs + server-side; trust the response status, not pre-checks. +4. Never invoke a read tool unprompted. The user must have asked something that maps to the + tool's output. `.trim();