From 8d61c8c9706d1f94819ec70b4879b4542bb54067 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 5 Jun 2026 21:32:42 -0400 Subject: [PATCH 01/14] docs: specify OpenClaw provider --- CONTEXT.md | 6 + docs/adr/0005-openclaw-gateway-provider.md | 130 ++++++++++ docs/adr/README.md | 1 + ...-06-05-openclaw-provider-implementation.md | 188 +++++++++++++++ .../2026-06-05-openclaw-provider-design.md | 225 ++++++++++++++++++ 5 files changed, 550 insertions(+) create mode 100644 docs/adr/0005-openclaw-gateway-provider.md create mode 100644 docs/superpowers/plans/2026-06-05-openclaw-provider-implementation.md create mode 100644 docs/superpowers/specs/2026-06-05-openclaw-provider-design.md diff --git a/CONTEXT.md b/CONTEXT.md index 574b6a5e..eb9fb4af 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,6 +14,12 @@ A cockpit is the user-facing control surface for coding-agent work. In JCode thi The cockpit coordinates local tools and provider runtimes; it should not leak raw provider protocol details directly into ordinary user-facing concepts. +### OpenClaw Gateway Provider + +The OpenClaw Gateway Provider is a first-class JCode Provider that connects to a user-configured OpenClaw Gateway URL and presents OpenClaw chat inside normal JCode Threads. + +Its first version should treat the OpenClaw Gateway as a provider runtime rather than a separate external chat launcher. JCode owns the Settings configuration, provider health, thread-to-session mapping, and runtime-event translation while keeping OpenClaw protocol details behind the Provider boundary. + ### Skill Library The Skill Library is the settings-native surface for discovering and managing coding-agent skills across providers such as OpenCode and Codex. diff --git a/docs/adr/0005-openclaw-gateway-provider.md b/docs/adr/0005-openclaw-gateway-provider.md new file mode 100644 index 00000000..e4864360 --- /dev/null +++ b/docs/adr/0005-openclaw-gateway-provider.md @@ -0,0 +1,130 @@ +# ADR 0005: OpenClaw Gateway Integrates As A First-Class Provider + +| Field | Value | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Status | Proposed | +| Type | Architecture decision record | +| Owner | Engineering | +| Audience | Maintainers, reviewers, and automation agents | +| Scope | OpenClaw provider integration, gateway chat runtime, Settings provider configuration, provider runtime events, and OpenClaw source reuse | +| Canonical path | `docs/adr/0005-openclaw-gateway-provider.md` | +| Last reviewed | 2026-06-05 | +| Review cadence | Event-driven; review if OpenClaw gateway protocol changes, JCode adds richer OpenClaw capabilities, or source reuse moves beyond protocol/client/runtime translation patterns | +| Source of truth | `CONTEXT.md`, `docs/superpowers/specs/2026-06-05-openclaw-provider-design.md`, provider contracts, server provider adapters, and OpenClaw gateway protocol source | +| Verification | Confirm OpenClaw appears as a provider, gateway secrets remain server-owned, JCode threads map to isolated OpenClaw sessions, and runtime events stay canonical | + +## Context + +JCode models coding-agent integrations as Providers. Provider settings, health, discovery, session start, turn sending, and runtime events are already shared across Codex, Claude, OpenCode, Kilo, Cursor, Gemini, and Pi. + +OpenClaw exposes chat through a WebSocket Gateway rather than through a simple external web page or local CLI-only runtime. Its gateway protocol includes a `connect.challenge` handshake, operator client roles and scopes, and chat methods such as `chat.history` and `chat.send`. + +The tempting implementation choices are not equivalent: + +- A Settings "connection" sounds user-friendly but conflicts with JCode's existing Connections settings, which pair clients to the JCode server. +- An external OpenClaw chat launcher would bypass JCode Threads, Provider health, Orchestration, and canonical runtime events. +- Importing OpenClaw's full web chat UI would duplicate JCode's transcript, composer, provider picker, and local-first cockpit boundaries. + +## Decision + +OpenClaw will integrate as a first-class JCode Provider named `openclaw`, configured under Settings -> Providers and selected from the normal provider picker. + +The first version is intentionally narrow: + +- Users configure a Gateway URL plus optional token/password authentication. +- JCode accepts `http(s)://` and `ws(s)://` input, normalizes internally to WebSocket, and shows the normalized URL in health details. +- Loopback `ws://` gateways remain valid for local development, but public remote gateways require `wss://`; LAN/tailnet exceptions must be explicit user choices. +- JCode identifies as a minimal operator client using OpenClaw's canonical gateway client id, backend mode, and a `JCode` display name. It requests only `operator.read` and `operator.write` scopes. +- JCode negotiates OpenClaw protocol v4 in v1 and completes the `connect.challenge` device flow when remote scoped access requires it. +- Each JCode Thread maps to an isolated OpenClaw `sessionKey`, recommended as `jcode:`. +- The visible target is a single text-only `OpenClaw Gateway` option. +- JCode persists that target as `{ provider: "openclaw", model: "gateway" }`, where `gateway` is a JCode sentinel for the single gateway target rather than an OpenClaw model slug. +- Attachments, agent selection, custom model slugs, slash-command discovery, skill/plugin discovery, approvals, and steering are deferred. +- OpenClaw does not participate in Git text generation or default model selection in v1. + +OpenClaw non-secret configuration belongs in server settings. OpenClaw secrets, device private keys, and paired-device tokens belong in a separate server-owned secret file under `ServerConfig.secretsDir`. This creates a new provider-secret storage seam; implementation must define atomic writes, owner-only permissions where supported, clear/rotate behavior, write-only secret update operations, and read responses that expose only secret presence metadata. OpenClaw credentials and device material must be resolved by the server-side adapter and must not be copied into persisted `ProviderStartOptions` or runtime/session payloads. + +OpenClaw's MIT-licensed gateway protocol, gateway client, retry, chat history, chat send, chat abort, and stream reconciliation behavior may be inspected and adapted into small JCode-native pieces. Current `@openclaw/*` client packages are private workspace packages, so JCode should not assume npm-installable OpenClaw clients unless publication changes. JCode should not wholesale vendor OpenClaw's web chat UI unless a later ADR changes the cockpit boundary. + +## Options Considered + +### Option A: External OpenClaw Chat Launcher + +| Dimension | Assessment | +| --------------- | ---------------------------------------------------------------------------------- | +| Complexity | Low | +| Cost | Low initial cost, high product fragmentation | +| JCode fit | Weak; bypasses Thread, Provider, Orchestration, health, and runtime-event concepts | +| User experience | Familiar to OpenClaw users but inconsistent with JCode provider workflows | +| Maintainability | Low JCode maintenance, but little control over integrated behavior or diagnostics | + +**Pros:** Fast to ship; minimal gateway protocol work. + +**Cons:** Does not make OpenClaw a JCode Provider; cannot preserve JCode thread/session semantics; hides provider health and canonical event handling outside JCode. + +### Option B: Settings Connections Entry + +| Dimension | Assessment | +| --------------- | -------------------------------------------------------------------------------------------- | +| Complexity | Medium | +| Cost | Moderate; needs a new remote-chat concept | +| JCode fit | Weak; JCode Connections currently mean client-to-server pairing, not provider runtime config | +| User experience | Matches the user's word "connection" but creates ambiguous Settings taxonomy | +| Maintainability | Risky; future agents could confuse network pairing with provider runtime settings | + +**Pros:** Lets users paste a URL in a Settings area that sounds natural. + +**Cons:** Conflicts with existing Settings semantics; would still need provider/runtime plumbing to chat inside JCode. + +### Option C: First-Class Gateway Provider + +| Dimension | Assessment | +| --------------- | --------------------------------------------------------------------------------------- | +| Complexity | Medium-high | +| Cost | Higher initial adapter and settings work | +| JCode fit | Strong; preserves Provider, Thread, Orchestration, health, and runtime-event boundaries | +| User experience | Consistent with existing provider picker and thread chat | +| Maintainability | Strong; gateway behavior is isolated behind an adapter and source-aware runtime events | + +**Pros:** Keeps OpenClaw in normal JCode workflows; supports clear health states; allows future capabilities to grow behind the Provider boundary. + +**Cons:** Requires gateway client integration, secret storage, health probing, and event translation. + +## Trade-Off Analysis + +The first-class Provider approach costs more than an external launcher, but it prevents a second chat surface from forming outside JCode's local cockpit model. It also keeps implementation vocabulary aligned with `CONTEXT.md`: OpenClaw is a Provider runtime, not a JCode client Connection. + +The narrow v1 capability set is deliberate. Text-only chat through one `OpenClaw Gateway` target proves URL/auth/session/event plumbing before JCode takes on OpenClaw-specific agent selection, attachments, commands, approvals, or model routing. + +Source reuse should focus on protocol and runtime behavior, not UI. OpenClaw's web chat code can prevent reimplementing handshake, retry, `chat.history`, `chat.send`, and stream reconciliation details from scratch, but JCode's transcript, composer, provider picker, and health surface remain product boundaries. + +The OpenClaw protocol currently validates gateway client ids against a closed enum. JCode should therefore use OpenClaw's canonical backend gateway-client identity on the wire and use `JCode` as display metadata, unless OpenClaw later adds a first-class `jcode` client id. + +OpenClaw credentials and device material are resolved inside the server-side adapter or gateway-client host callbacks. They must not be placed in `ProviderStartOptions`, persisted runtime payloads, raw runtime events, browser storage, React Query keys, toasts, or logs. + +## Consequences + +- OpenClaw becomes part of the normal provider picker and thread chat flow. +- Settings -> Connections remains reserved for pairing clients to the JCode server. +- JCode gains a new server-side secret-storage concern for provider credentials and paired device material. +- Remote OpenClaw access may require persisted device identity, challenge signing, paired-token storage, stale-token clearing, and user-visible rotate/clear-device controls. +- OpenClaw health checks must validate gateway reachability, auth, and required chat methods rather than local CLI freshness, then map gateway-specific states onto existing JCode provider status/authentication states. +- OpenClaw health checks must handle gateways that do not advertise `hello.features.methods`; advertised method lists are authoritative when present, but absent lists require narrow probe calls or an explicit unknown/unsupported state. +- Provider runtime translation must preserve canonical JCode events and keep raw OpenClaw protocol details behind redacted, source-aware raw events such as `openclaw.gateway.event`. The adapter must reconcile OpenClaw stream chunks into JCode text items, completion states, failures, and interruptions instead of exposing protocol frames as chat transcript content. +- JCode turn interruption and session stop flows must map to OpenClaw `chat.abort` when a gateway run is active. +- JCode must keep OpenClaw `sessionKey` values bounded and namespaced so JCode Thread ids cannot collide with unrelated OpenClaw sessions or exceed gateway primitive limits. +- OpenClaw must join Pi as a provider that does not have a default model in v1, so default-model and Git text-generation fallback code must not route to it. It still needs the explicit `gateway` thread model-selection sentinel for persistence. +- OpenClaw v1 must advertise no approvals, no native commands, no skill/plugin discovery, no runtime model list, and no thread compaction/import. If the gateway emits an approval/permission interaction before that capability is designed, JCode should fail clearly rather than silently approve, deny, or drop it. +- Future richer OpenClaw capabilities need explicit design work before they appear in the composer. + +## Action Items + +1. [ ] Add `openclaw` to provider contracts, provider ordering, status cache ids, and runtime raw-source schema while excluding it from default-model/Git text-generation provider sets. +2. [ ] Add `OpenClawModelSelection` with the single `gateway` model-selection sentinel. +3. [ ] Add OpenClaw server settings for gateway URL and auth mode, plus separate server-owned secret storage for credentials and device material under `ServerConfig.secretsDir`. +4. [ ] Add write-only secret mutations, redacted settings reads, and safeguards that keep OpenClaw credentials out of provider start options and runtime payloads. +5. [ ] Implement OpenClaw Gateway URL normalization and health probing, including loopback `ws://`, public remote `wss://`, and explicit LAN/tailnet exception handling. +6. [ ] Implement `OpenClawAdapter` with protocol v4 negotiation, `connect.challenge`, canonical backend gateway-client identity plus `JCode` display name, device identity signing, paired-token persistence, per-thread bounded `sessionKey`, `chat.history`, `chat.send`, `chat.abort`, and event translation. +7. [ ] Add Settings -> Providers UI for OpenClaw gateway configuration and health, excluding custom model settings for v1. +8. [ ] Add focused tests for contracts, settings migration, secret storage/redaction, URL normalization, protocol negotiation, device identity/challenge handling, provider health, session-key mapping, model-selection persistence, turn interruption, capability flags, and runtime event translation. +9. [ ] Preserve MIT attribution if substantial OpenClaw source is copied or adapted. diff --git a/docs/adr/README.md b/docs/adr/README.md index c22cdf17..7d7883d8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -21,6 +21,7 @@ | [0002](0002-release-notes-and-latest-package-retention.md) | Accepted | Release notes and latest-package retention | | [0003](0003-settings-native-skill-library.md) | Accepted | Settings-native provider-aware Skill Library | | [0004](0004-project-language-icons.md) | Proposed | Project language icons as metadata | +| [0005](0005-openclaw-gateway-provider.md) | Proposed | OpenClaw Gateway as a first-class Provider | ## When To Add An ADR diff --git a/docs/superpowers/plans/2026-06-05-openclaw-provider-implementation.md b/docs/superpowers/plans/2026-06-05-openclaw-provider-implementation.md new file mode 100644 index 00000000..0d7b0280 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-openclaw-provider-implementation.md @@ -0,0 +1,188 @@ +# OpenClaw Provider Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add OpenClaw as a first-class JCode Provider that connects to an OpenClaw Gateway from Settings -> Providers, keeps credentials server-owned, and chats inside normal JCode Threads. + +## Source Documents + +- `docs/superpowers/specs/2026-06-05-openclaw-provider-design.md` +- `docs/adr/0005-openclaw-gateway-provider.md` +- `CONTEXT.md` + +## Hard Constraints + +- OpenClaw is a JCode `Provider`, not a Settings -> Connections entry. +- V1 is text-only with one visible target: `OpenClaw Gateway`. +- Persist the Thread model selection as `{ provider: "openclaw", model: "gateway" }`. +- Keep OpenClaw out of default-model, Git text-generation, runtime model-list, and custom-model flows. +- Do not put OpenClaw secrets, paired-device tokens, or private keys into `ServerSettings`, `ProviderStartOptions`, provider runtime payloads, browser state, React Query keys, toasts, logs, or raw runtime events. +- Use server-owned secret storage under `ServerConfig.secretsDir`; prefer the existing `ServerSecretStore` abstraction instead of creating a second low-level file store. +- Use OpenClaw protocol v4 for v1. +- Use OpenClaw wire identity `client.id = "gateway-client"`, backend mode, display name `JCode`, role `operator`, scopes `operator.read` and `operator.write`. +- Handle `connect.challenge`, device identity, paired-token persistence, stale-token clearing, and rotate/clear-device operations. +- Allow loopback `ws://`; require public remote `wss://`; make LAN/tailnet insecure WebSocket exceptions explicit user decisions. +- Treat current `@openclaw/*` packages as private workspace packages. Adapt minimal MIT-licensed source or implement raw WebSocket protocol unless package publication changes. +- If the gateway emits approval/permission requests in v1, fail clearly; do not auto-approve, auto-deny, or silently drop. + +## File Responsibilities + +### Contracts And Shared Types + +- `packages/contracts/src/orchestration.ts`: add `openclaw` to `ProviderKind`; add `OpenClawModelSelection` with fixed `model: "gateway"`; include it in `ModelSelection`. +- `packages/contracts/src/model.ts`: add OpenClaw display/model metadata only if needed for UI labels; exclude OpenClaw from `ProviderWithDefaultModel` and `DEFAULT_MODEL_BY_PROVIDER`. +- `packages/shared/src/model.ts`: keep model resolution safe for providers without defaults; verify OpenClaw does not fall through to default model helpers. +- `packages/shared/src/serverSettings.ts`: update text-generation model patch behavior so provider switches do not try `DEFAULT_MODEL_BY_PROVIDER.openclaw`. +- `packages/contracts/src/providerDiscovery.ts`: add `openclaw` to discovery/provider capability kinds; add OpenClaw non-secret provider start options if needed, but no secret fields. +- `packages/contracts/src/settings.ts`: add `OpenClawServerProviderSettings` under `ServerSettings.providers` for non-secret settings only: enabled, gateway URL, auth mode, explicit remote-insecure allowance if implemented, and redacted metadata flags if exposed by settings responses. +- `packages/contracts/src/providerRuntime.ts`: add raw source `openclaw.gateway.event`. +- `packages/contracts/src/server.ts`: use existing provider status/auth status unless a redacted OpenClaw secret-status response is added. + +### Server Settings, Secrets, And Health + +- `apps/server/src/auth/Layers/ServerSecretStore.ts`: reuse this service for named OpenClaw secrets. It already creates `secretsDir`, chmods directory/files, writes via temp file, and renames atomically. +- `apps/server/src/auth/Services/ServerSecretStore.ts`: extend only if OpenClaw needs higher-level helpers; otherwise keep low-level API unchanged. +- `apps/server/src/serverSettings.ts`: persist only non-secret OpenClaw settings in `settings.json`. +- `apps/server/src/provider/Layers/ProviderHealth.ts`: add OpenClaw gateway health probe with states for ready, pairing needed, unauthenticated, unreachable, unsupported, and protocol mismatch. Map those to existing `ServerProviderStatus` values. +- `apps/server/src/provider/providerStatusCache.ts`: add `openclaw` to provider status cache ids/order. +- New server helper candidate, `apps/server/src/provider/openclawGatewayUrl.ts`: normalize `http(s)` to `ws(s)`, enforce loopback/public remote policy, redact URL userinfo/query secrets. +- New server helper candidate, `apps/server/src/provider/openclawSecrets.ts`: centralize secret names, redacted metadata, set/clear/rotate operations, stale-token clearing, and device identity access through `ServerSecretStore`. + +### Server Adapter And Protocol + +- `apps/server/src/provider/Services/OpenClawAdapter.ts`: add service tag/interface following other provider adapter service files. +- `apps/server/src/provider/Layers/OpenClawAdapter.ts`: implement the adapter and protocol client/reducer. +- `apps/server/src/provider/Layers/ProviderAdapterRegistry.ts`: register `OpenClawAdapter`. +- `apps/server/src/provider/runtimeLayer.ts`: wire the OpenClaw adapter layer and dependencies. +- `apps/server/src/provider/Errors.ts`: reuse existing validation/request/session errors unless OpenClaw needs a specific tagged error for URL/auth/protocol detail. +- `apps/server/src/provider/Layers/EventNdjsonLogger.ts`: do not change unless redaction support needs a central helper; raw OpenClaw payloads should already be redacted before logging. + +OpenClaw adapter responsibilities: + +- `capabilities`: `sessionModelSwitch: "unsupported"`, no runtime model list, no native commands, no skills/plugins, no turn steering. +- `startSession`: resolve non-secret settings and server-side secrets; normalize URL; connect WebSocket; negotiate protocol v4; complete `connect.challenge`; verify methods; call `chat.history`; produce a `ProviderSession` with provider `openclaw` and provider thread/session refs using `jcode:` or a bounded deterministic variant. +- `sendTurn`: send `chat.send` with `sessionKey`, `message`, and stable idempotency key. Do not forward attachments, `agentId`, `thinking`, or fast-mode fields in v1. +- `interruptTurn`: call `chat.abort` with active `sessionKey` and known `runId` when available. +- `stopSession`: abort active run if needed, close WebSocket/session state, emit stopped/interrupted events consistently. +- `respondToRequest` and `respondToUserInput`: fail clearly as unsupported in v1. +- `readThread`: return snapshots based on current adapter state/history; keep implementation minimal until import/external thread support is designed. +- `rollbackThread`, `forkThread`, `listModels`, `listCommands`, `listSkills`, `listPlugins`, `listAgents`: unsupported or empty according to existing adapter conventions. +- `streamEvents`: emit canonical `ProviderRuntimeEvent` values and optional redacted raw events with source `openclaw.gateway.event`. + +Protocol source details to preserve: + +- `PROTOCOL_VERSION = 4`, `MIN_CLIENT_PROTOCOL_VERSION = 4`. +- Initial connect frame includes `minProtocol`, `maxProtocol`, `client`, `role`, `scopes`, `auth`, and optional `device`. +- Challenge event is `connect.challenge` with nonce/timestamp; device signing must bind the nonce and client identity. +- Required methods: `chat.history`, `chat.send`, `chat.abort`. +- `chat.send` must use stable per-turn `idempotencyKey`. +- Gateway streaming is event-based, not response-stream based; implement a reducer over chat events and terminal states. + +### Web Settings And Picker + +- `apps/web/src/appSettings.ts`: map OpenClaw non-secret settings only; no secret values in app settings; exclude OpenClaw from custom model helpers and Git text-generation options. +- `apps/web/src/routes/_chat.settings.tsx`: add a dedicated OpenClaw gateway provider card. Borrow layout, not the OpenCode/Kilo password-backed data shape. +- `apps/web/src/providerOrdering.ts`: add OpenClaw to default ordering and hidden-provider normalization. +- `apps/web/src/session-logic.ts`: add `OpenClaw` to provider picker options. +- Icon handling: add or reuse a simple provider icon in `apps/web/src/components/Icons` only if needed by Settings/picker conventions. +- Provider start/options helpers, likely `apps/web/src/lib/providerOptions.ts`: ensure OpenClaw provider start options contain no secrets. +- WebSocket/native API transport files: update only if new server methods are required for write-only secret mutation or device rotation. + +## Implementation Steps + +### Phase 1: Contracts And Shared Settings + +- [ ] Write failing contract tests for decoding `ProviderKind` and `ModelSelection` with `{ provider: "openclaw", model: "gateway" }`. +- [ ] Add `openclaw` to `ProviderKind` and add `OpenClawModelSelection` to `ModelSelection`. +- [ ] Run focused contracts tests and confirm the new contract tests pass. +- [ ] Write failing tests proving OpenClaw is excluded from default-model maps and Git text-generation model defaults. +- [ ] Update `ProviderWithDefaultModel`, `DEFAULT_MODEL_BY_PROVIDER`, display/model helpers, and shared model resolution so OpenClaw behaves like a non-default-model provider while preserving the gateway sentinel. +- [ ] Update `packages/shared/src/serverSettings.ts` tests so text-generation settings cannot switch into OpenClaw and do not index `DEFAULT_MODEL_BY_PROVIDER.openclaw`. +- [ ] Add `openclaw` to provider discovery kinds and composer capability contracts. +- [ ] Add OpenClaw non-secret settings schema and patch schema under `ServerSettings.providers`. +- [ ] Add `openclaw.gateway.event` to `RuntimeEventRawSource`. +- [ ] Run focused package tests for contracts/shared changes. + +### Phase 2: Server Secret And URL Helpers + +- [ ] Write failing tests for OpenClaw secret metadata: set token/password, read redacted presence, clear token/password, rotate/clear device identity, stale-token clearing. +- [ ] Implement `openclawSecrets` on top of `ServerSecretStore`; use deterministic names such as `provider.openclaw.token`, `provider.openclaw.password`, `provider.openclaw.device-key`, and `provider.openclaw.device-token`. +- [ ] Write failing tests for URL normalization and redaction: `http -> ws`, `https -> wss`, loopback `ws` allowed, public remote `ws` rejected, userinfo/query secrets stripped from logs/details. +- [ ] Implement `openclawGatewayUrl` helpers. +- [ ] Add server settings tests proving OpenClaw secrets do not appear in `settings.json`, settings update payloads, or app settings mappings. +- [ ] Run focused server/auth/settings helper tests. + +### Phase 3: Gateway Protocol Client + +- [ ] Write unit tests for connect-frame creation: protocol v4, `gateway-client`, backend mode, display name `JCode`, role `operator`, scopes `operator.read` and `operator.write`. +- [ ] Write unit tests for `connect.challenge` handling: waits for nonce, signs with device identity, times out with redacted error, clears stale paired token on auth failure. +- [ ] Write unit tests for method support handling: advertised `hello-ok.features.methods` accepted when required methods exist, unsupported when missing, fallback/probe behavior when methods list is absent. +- [ ] Write unit tests for chat request shapes: `chat.history`, `chat.send` with stable idempotency key, `chat.abort` with session key and optional run id. +- [ ] Implement minimal WebSocket/protocol helper or adapted OpenClaw source in `OpenClawAdapter.ts` or a small colocated protocol helper file. +- [ ] Add MIT attribution/notice updates if any OpenClaw source is copied or substantially adapted. + +### Phase 4: OpenClaw Adapter + +- [ ] Write adapter tests using a fake gateway/protocol harness for `startSession` happy path: settings + secret resolution, handshake, method verification, `chat.history`, and `ProviderSession` output. +- [ ] Write adapter tests proving `sendTurn` emits canonical assistant text/completion events from gateway chat events. +- [ ] Write adapter tests proving gateway errors emit canonical failed runtime events with redacted raw payloads. +- [ ] Write adapter tests proving `interruptTurn`/`stopSession` call `chat.abort` and emit interrupted/cancelled state. +- [ ] Write adapter tests proving approvals/user-input responses fail clearly as unsupported. +- [ ] Implement `Services/OpenClawAdapter.ts` service tag. +- [ ] Implement `Layers/OpenClawAdapter.ts` with session map, event queue, lifecycle, event reducer, and unsupported capability methods. +- [ ] Add redaction tests for raw `openclaw.gateway.event` payloads. +- [ ] Register the adapter in `ProviderAdapterRegistry` and `runtimeLayer`. +- [ ] Run focused provider adapter/registry tests. + +### Phase 5: Provider Health And Status + +- [ ] Write health tests for unconfigured, ready, pairing needed, unauthenticated, unreachable, unsupported methods, protocol mismatch, and public `ws://` rejection. +- [ ] Implement OpenClaw health probing in `ProviderHealth` using the URL/protocol helpers and redacted status messages. +- [ ] Add `openclaw` to `providerStatusCache` order and cache ids; add ordering/cache tests. +- [ ] Verify status payloads never include credentials, signed device payloads, tokens, or raw URL userinfo/query secrets. + +### Phase 6: Web Settings And Provider Picker + +- [ ] Write app settings tests for OpenClaw non-secret mapping, no secret reflection, hidden provider normalization, and provider start options excluding OpenClaw credentials. +- [ ] Update `appSettings.ts` for OpenClaw non-secret fields and exclusions from custom model/Git text-generation settings. +- [ ] Write provider-order tests proving OpenClaw appears in default order and normalizes hidden/order arrays. +- [ ] Update `providerOrdering.ts` and `session-logic.ts` to include OpenClaw. +- [ ] Write focused Settings UI tests if the route has existing test coverage; otherwise keep UI changes small and verify with typecheck/manual browser pass during execution. +- [ ] Add a dedicated OpenClaw gateway card in `_chat.settings.tsx` with URL, auth mode, masked secret write action, clear/rotate actions, and connection status. +- [ ] Ensure secret input writes only through server mutation and is never populated from settings responses. +- [ ] Run focused web tests for app settings, provider ordering, provider options, and session logic. + +### Phase 7: End-To-End Verification And Cleanup + +- [ ] Run `bunx oxfmt@0.52.0 --check ` for all touched source/docs files. +- [ ] Run `bun run --cwd packages/contracts test` or narrower contract tests if available. +- [ ] Run `bun run --cwd packages/shared test` or narrower shared tests if available. +- [ ] Run focused server tests for OpenClaw secrets, URL helpers, adapter, health, and provider registry. +- [ ] Run focused web tests for app settings, provider ordering, provider options, and session logic. +- [ ] Run `bun run --cwd apps/server typecheck` after server implementation compiles. +- [ ] Run `bun run --cwd apps/web typecheck` after web implementation compiles. +- [ ] Use `safe-run --profile test -- ` or `safe-run --profile build -- ` for broad test/typecheck commands if widening beyond focused checks. +- [ ] Manual/browser verification with dev automation access: Settings -> Providers shows OpenClaw, URL normalization/status works, secrets are write-only, OpenClaw appears in provider picker, unhealthy provider blocks turns with a clear banner. +- [ ] If a fake or local OpenClaw gateway is available, run a manual chat smoke test: configure loopback URL, start an OpenClaw Thread, send text, interrupt a running turn, and verify per-thread history isolation. + +## Negative Test Checklist + +- [ ] Secret set values do not appear in settings JSON. +- [ ] Secret set values do not appear in browser app settings, React Query keys, toasts, provider start options, provider runtime payloads, or event logs. +- [ ] URL userinfo and sensitive query params are stripped from logs/status messages. +- [ ] Public remote `ws://` fails before connecting; loopback `ws://` succeeds. +- [ ] Missing `chat.history`, `chat.send`, or `chat.abort` reports unsupported. +- [ ] Protocol range outside v4 reports protocol mismatch. +- [ ] Failed auth clears stale paired token without deleting the device private key unless rotate/clear-device is requested. +- [ ] Gateway approval/permission event fails clearly in v1. +- [ ] OpenClaw cannot be selected for Git text generation or custom model settings. + +## Completion Criteria + +- `openclaw` appears in contracts, server provider registry/health, Settings -> Providers, and provider picker. +- Configuring an OpenClaw Gateway URL and auth metadata is possible without exposing secret values on read. +- Starting an OpenClaw Thread uses the `gateway` model-selection sentinel and maps to a bounded `jcode:` OpenClaw `sessionKey`. +- `chat.history`, `chat.send`, and `chat.abort` are translated into canonical JCode runtime behavior. +- Health checks cover URL, protocol, auth/pairing, and required methods. +- Focused tests and typechecks for touched packages/apps pass. +- No adapted OpenClaw source lacks MIT attribution. diff --git a/docs/superpowers/specs/2026-06-05-openclaw-provider-design.md b/docs/superpowers/specs/2026-06-05-openclaw-provider-design.md new file mode 100644 index 00000000..02f21c0b --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-openclaw-provider-design.md @@ -0,0 +1,225 @@ +# OpenClaw Provider Design + +## Summary + +Add OpenClaw as a first-class JCode Provider backed by a user-configured OpenClaw Gateway URL. Users configure the gateway in Settings, pick `OpenClaw` from the normal provider picker, and chat with OpenClaw inside standard JCode Threads. + +## Context + +JCode already models coding-agent integrations as Providers. The shared contracts define provider settings, provider discovery, provider health, provider runtime events, and provider start/send-turn flows. The web Settings route already has a Providers section with provider-specific configuration rows, and the server adapter registry binds provider kinds such as Codex, Claude, OpenCode, Kilo, Cursor, Gemini, and Pi to concrete provider adapters. + +OpenClaw exposes a WebSocket Gateway rather than a local CLI-only provider. Current OpenClaw docs/source describe a WebSocket handshake with `connect.challenge`, operator roles and scopes, and chat methods such as `chat.history` and `chat.send`. A JCode integration should therefore bridge OpenClaw Gateway chat into JCode's Provider abstraction instead of launching an external OpenClaw UI. + +## Goals + +- Add `openclaw` as a provider kind across contracts, settings, provider health, discovery, runtime events, and provider picker surfaces. +- Let users configure an OpenClaw Gateway URL and optional secret from Settings. +- Let users select OpenClaw in the normal JCode provider picker and chat inside a JCode Thread. +- Map each JCode Thread to an isolated OpenClaw `sessionKey` so OpenClaw conversations do not collapse into a shared `main` session. +- Keep v1 narrow: text chat through the gateway with one visible target named `OpenClaw Gateway`. +- Reuse OpenClaw's open-source gateway client/protocol behavior where it fits JCode's Provider boundary, without importing OpenClaw's full web chat UI. + +## Non-Goals + +- No external OpenClaw chat launcher in v1. +- No OpenClaw-specific standalone Settings section separate from Providers. +- No attachment support in v1. +- No OpenClaw agent picker in v1. +- No slash-command, skill, plugin, approval, steering, or advanced gateway-method discovery in v1. +- No automatic remote network exposure. The user-provided gateway URL must remain explicit. +- No use of OpenClaw as a Git text-generation/default-model provider in v1. +- No persistence of OpenClaw credentials, paired-device tokens, or device private keys in provider start options, runtime payloads, app settings responses, browser storage, logs, or raw runtime events. + +## Recommended Approach + +Implement OpenClaw as a full Provider adapter. + +This approach keeps OpenClaw inside the existing JCode concepts: + +- `Provider`: `openclaw` becomes selectable like `opencode`, `codex`, and `pi`. +- `Thread`: a JCode Thread maps to a stable OpenClaw gateway `sessionKey`. +- `Orchestration`: user turns flow through the normal provider service and runtime event stream. +- `ModelSelection`: v1 uses an explicit `openclaw` model-selection shape whose only valid model is the sentinel target `gateway`. This keeps thread persistence concrete while making clear that `gateway` is not an OpenClaw model slug. +- `Settings`: the OpenClaw gateway configuration lives in the existing Providers settings surface. +- `Provider Health`: gateway reachability and auth are reported alongside other provider statuses. + +Rejected alternatives: + +- External launcher: simplest visually, but bypasses JCode Thread, Provider, Orchestration, event-log, and health semantics. +- Connections-only URL entry: fits the word "connection" but conflicts with JCode's current Connections settings, which are about pairing clients to this JCode server rather than configuring provider runtimes. + +## Settings Design + +OpenClaw should appear under Settings -> Providers with a dedicated gateway card. It can borrow the URL-backed layout patterns used by OpenCode/Kilo, but it must not reuse their password-backed settings flow as-is because OpenClaw secrets and paired-device material use write-only server-side storage. + +Fields: + +- Gateway URL: required for v1. Placeholder: `ws://127.0.0.1:18789`. Accept `http(s)://` and `ws(s)://` input, normalize internally to WebSocket, and show the normalized URL in health details. +- Authentication mode: `None / local pairing`, `Token`, or `Password`. +- Secret: masked input shown only for token/password modes. Store this outside the main server settings JSON. +- Enable provider: follows the existing provider settings default of enabled unless hidden/disabled by user settings. +- Check connection: verifies gateway reachability, handshake, auth, and required chat methods. + +URL normalization: + +```text +http://127.0.0.1:18789 -> ws://127.0.0.1:18789 +https://example.com -> wss://example.com +ws://127.0.0.1:18789 -> ws://127.0.0.1:18789 +``` + +OpenClaw's gateway client blocks public remote `ws://` targets. JCode should preserve that policy: loopback `ws://` is allowed for local gateways, while public remote gateways must use `wss://`. LAN or tailnet exceptions need explicit user configuration and should be treated as remote exposure decisions, not automatic discovery. + +The Settings card should show a compact health line: + +- `ready`: gateway is reachable, authenticated, and advertises required chat methods. +- `pairing needed`: gateway answered but did not accept the stored/local identity. +- `unauthenticated`: token/password is missing or rejected. +- `unreachable`: the URL cannot be reached or the WebSocket handshake fails. +- `unsupported`: gateway does not advertise the required chat methods. + +Map those states onto JCode's existing provider status shape instead of inventing a separate UI contract: `ready` reports a ready/authenticated status, `pairing needed` and `unauthenticated` report warning/error with unauthenticated auth state, `unreachable` reports error with unknown auth state, and `unsupported` reports warning/error with a method-capability message. + +The UI should not expose raw protocol frames. It may mention "Gateway URL" because that is the user-owned OpenClaw concept being configured. + +## Runtime Design + +Add an `OpenClawAdapter` that implements the existing provider adapter contract. + +Adapter responsibilities: + +- Connect to the configured OpenClaw Gateway URL over WebSocket. +- Use OpenClaw protocol v4 for v1 (`minProtocol: 4`, `maxProtocol: 4`) and fail clearly if the gateway negotiates outside that range. +- Complete the OpenClaw `connect.challenge` handshake as an operator client by waiting for the gateway nonce, signing it with the persisted device identity when required, and timing out cleanly if the challenge flow does not complete. +- Use OpenClaw's canonical gateway client identity on the wire: client id `gateway-client`, mode `backend`, and a human-readable display name such as `JCode`. The OpenClaw protocol currently validates client ids against a closed enum, so `jcode` should be a display name unless OpenClaw adds `jcode` as a protocol id. +- Request only the scopes needed for v1: `operator.read` and `operator.write`. +- Persist any OpenClaw device identity/token material server-side, not in browser local storage. Remote scoped access requires a device identity in addition to token/password auth except for OpenClaw's special local backend cases; JCode must create or load the device key, persist paired-device tokens, clear stale tokens after auth failures, and expose a rotate/clear-device action. +- Translate `startSession` into gateway readiness plus initial `chat.history` for the JCode Thread's session key. +- Translate `sendTurn` into `chat.send` with only v1-supported text fields: `sessionKey`, `message`, and a stable `idempotencyKey`. Do not forward advanced OpenClaw fields such as `agentId`, `sessionId`, attachments, `thinking`, or fast-mode controls until a later ADR defines how they map to JCode. +- Translate gateway chat deltas/finals/errors into JCode `ProviderRuntimeEvent` values. The adapter must maintain enough per-turn state to reconcile streamed OpenClaw chunks into canonical JCode text items, completion states, failures, and interruption/cancellation events without exposing raw protocol payloads as transcript content. +- Translate JCode `interruptTurn` and relevant session stop flows into OpenClaw `chat.abort` with the active `sessionKey` and, when available, the active OpenClaw `runId`. +- Close or idle-stop gateway sessions according to the existing ProviderService lifecycle. + +The adapter should use OpenClaw's gateway client/package behavior when practical. The current OpenClaw gateway client exposes host callbacks for device identity, token storage, logging, TLS formatting, and redaction; JCode should implement those host callbacks instead of duplicating handshake and reconnect logic. Current `@openclaw/*` client packages are private workspace packages, so implementation should not assume they can be installed from npm. If they remain private/unpublished, adapt the smallest needed source with MIT attribution or implement the raw WebSocket protocol directly rather than vendoring the full OpenClaw UI. + +Health probing should not assume every gateway advertises `hello.features.methods`. Treat advertised methods as authoritative when present; when the list is absent, perform narrow probe calls or report a clear `unsupported`/`unknown` state rather than assuming all methods are available. Required v1 gateway methods are `chat.history`, `chat.send`, and `chat.abort`. + +The first runtime-event raw source should be explicit, for example `openclaw.gateway.event`, so logs remain source-aware like `opencode.sdk.event` and `pi.sdk.event`. Raw OpenClaw event payloads must be redacted before storage or emission, and any raw-event schema addition should be tested with the event sources that the adapter can actually emit. + +## OpenClaw Source Reuse + +OpenClaw's web gateway chat code is open source and MIT licensed, so implementation should inspect and adapt it before rebuilding gateway behavior from scratch. + +Good reuse candidates: + +- Protocol schemas and validation behavior from OpenClaw gateway protocol packages. +- Gateway client handshake, reconnect, request/response, and auth callback patterns. +- Chat history retry behavior around startup/unavailable gateway states. +- `chat.history` and `chat.send` tests as fixtures for request shapes and error handling. +- `chat.abort` behavior as the fixture for JCode turn interruption and stop flows. +- Stream reconciliation ideas that help translate OpenClaw gateway events into JCode `ProviderRuntimeEvent` values. + +Do not wholesale import OpenClaw's web chat UI. JCode's chat transcript, composer, provider picker, health banners, Thread model, and Orchestration state are product boundaries that should stay JCode-native. Any copied or substantially adapted OpenClaw source must preserve MIT attribution in the appropriate repository credits/notices. + +## Thread Mapping + +Each JCode Thread should map to a stable OpenClaw `sessionKey` derived from the JCode Thread id. + +Recommended shape: + +```text +jcode: +``` + +Runtime calls use that key: + +```text +chat.history({ sessionKey: "jcode:", limit: 100 }) +chat.send({ sessionKey: "jcode:", message, idempotencyKey }) +``` + +This keeps OpenClaw conversations isolated per JCode Thread and avoids mixing all JCode work into OpenClaw's default `main` or `global` session. + +The derived `sessionKey` must stay within OpenClaw's primitive limits and avoid collisions. If a raw JCode Thread id can exceed the gateway limit, hash or shorten the suffix while preserving a deterministic `jcode:` namespace. + +## V1 Capabilities + +OpenClaw v1 should expose one target in JCode: + +```text +OpenClaw Gateway +``` + +Capabilities: + +- Text input only. +- One persisted JCode model-selection target: `{ provider: "openclaw", model: "gateway" }`. The UI label remains `OpenClaw Gateway`, and `gateway` is a JCode routing sentinel rather than a configurable OpenClaw model. +- No runtime model list. +- No skill discovery. +- No native slash-command discovery. +- No plugin discovery. +- No OpenClaw approval forwarding. JCode approval-mode UI may still exist globally, but OpenClaw v1 must advertise no approval capability and fail clearly if the gateway asks for an approval or permission interaction that JCode cannot represent yet. +- No thread compaction or import unless a later OpenClaw method maps cleanly to JCode's provider contracts. + +The provider picker should display OpenClaw only when it is enabled and not hidden by provider visibility settings. If the gateway is not configured or unhealthy, selecting OpenClaw should surface the same provider health/banner pattern used by other providers rather than sending a turn that cannot succeed. + +## Contract And Code Seams + +Likely implementation areas: + +- `packages/contracts/src/orchestration.ts`: add `openclaw` to `ProviderKind` and `ModelSelection` using a fixed gateway-routing model value such as `gateway`. +- `packages/contracts/src/providerDiscovery.ts`: add OpenClaw settings/start options and composer capability schemas. OpenClaw start options must contain only non-secret launch context; credentials and device tokens resolve server-side inside the adapter. +- `packages/contracts/src/settings.ts`: add `OpenClawServerProviderSettings` under `ServerSettings.providers`. +- `packages/contracts/src/providerRuntime.ts`: add `openclaw.gateway.event` raw source. +- `packages/contracts/src/model.ts` and `packages/shared/src/model.ts`: keep OpenClaw out of default model and model-resolution paths unless a later design adds explicit model routing. Today Pi is the only provider without a default model; OpenClaw should join that non-default-model category for v1 while still preserving thread persistence through the `gateway` model-selection sentinel. +- `apps/server/src/provider/Layers/ProviderAdapterRegistry.ts`: register `OpenClawAdapter`. +- `apps/server/src/provider/Layers/ProviderHealth.ts`: add OpenClaw gateway health probing. +- `apps/server/src/config.ts`: use the existing `ServerConfig.secretsDir` for OpenClaw provider secret storage. +- `apps/server/src/provider/providerStatusCache.ts`: include OpenClaw in provider status ordering/cache ids. +- `apps/web/src/appSettings.ts`: add OpenClaw URL/auth settings migration and app settings mapping without reflecting secret values back to the browser. +- `apps/web/src/routes/_chat.settings.tsx`: add the dedicated OpenClaw provider settings card and provider picker handling. Do not include OpenClaw in custom model settings for v1. +- `apps/web/src/session-logic.ts` and provider ordering helpers: include OpenClaw in provider options/order. + +## Security And Privacy + +- Secrets must stay server-authoritative and must not appear in React Query keys, URLs, logs, toasts, provider start options, persisted runtime payloads, app settings responses, or provider runtime raw-event payloads. +- Browser local storage may keep UI-only preferences, but OpenClaw tokens, passwords, device private keys, and paired-device tokens belong to a server-owned secret file under `ServerConfig.secretsDir`. +- The OpenClaw secret store is a new provider-secret abstraction, not an existing JCode service. It should create `secretsDir` when needed, write atomically, use owner-only permissions where the platform supports them, and expose explicit clear/rotate behavior for auth-mode changes. +- Settings reads should expose only redacted state such as `authMode`, `hasSecret`, and `paired`; settings writes should use write-only secret mutations such as set secret, clear secret, and rotate device identity. Do not round-trip secret values through app settings responses. +- The main server settings JSON should keep only non-secret OpenClaw configuration such as gateway URL, auth mode, and provider enabled/visibility state. +- The gateway URL must be user-provided and visible, because it defines the boundary JCode connects to. +- Gateway URLs and gateway-client errors must be redacted before logs or provider runtime raw events. In particular, strip userinfo and sensitive query parameters such as `token`, `password`, `client_secret`, and similar credential names. +- JCode should not silently expose or tunnel OpenClaw gateways. Remote/LAN/tailnet use is an explicit user configuration decision. +- Health errors should redact credentials and include actionable connection/auth state. + +## Acceptance Criteria + +- OpenClaw appears as a provider in Settings and the provider picker. +- A user can enter an OpenClaw Gateway URL and optional token/password in Settings. +- `http(s)://` and `ws(s)://` gateway inputs normalize to the WebSocket URL used by the adapter. +- OpenClaw Thread state uses `ModelSelection` provider `openclaw` with the fixed `gateway` routing target, while default-model and custom-model paths exclude OpenClaw. +- Public remote gateways require `wss://`; loopback `ws://` remains valid for local gateways. +- The adapter negotiates OpenClaw protocol v4 and handles `connect.challenge` device signing, paired-token persistence, stale-token clearing, and device rotation. +- `Check connection` reports clear ready/auth/unreachable/unsupported states without leaking secrets. +- JCode connects as a recognizable minimal operator client using OpenClaw's canonical gateway client id plus a `JCode` display name, with `operator.read` and `operator.write` scopes. +- Starting a JCode Thread with OpenClaw creates or resumes the corresponding OpenClaw gateway session key. +- Sending a text turn through OpenClaw produces JCode chat output through normal provider runtime events. +- Interrupting a running JCode turn calls OpenClaw `chat.abort` and emits canonical interrupted/aborted runtime events. +- Separate JCode Threads do not share OpenClaw chat history. +- Provider status cache, provider ordering, app settings migration, and query keys handle OpenClaw without secret leakage. +- OpenClaw does not appear in Settings -> Models -> Custom models for v1. +- OpenClaw does not become a Git text-generation/default-model provider in v1. +- Focused tests cover contracts, settings mapping, provider status ordering, secret-store read/write/clear/redaction, URL normalization, health probing, adapter session-key mapping, model-selection persistence for the `gateway` sentinel, `chat.history`, `chat.send`, `chat.abort`, capability flags, and runtime event translation. +- OpenClaw source reuse is limited to protocol/client/runtime translation patterns unless a later design explicitly approves UI-level reuse. + +## Future Considerations + +- Agent selection via OpenClaw `agentId` once the desired UX is clear. +- Attachments and image support if OpenClaw gateway attachment schemas map safely to JCode attachments. +- Gateway method discovery to unlock commands, slash commands, approvals, steering, or richer composer capabilities. +- Importing or linking existing OpenClaw sessions into JCode Threads. +- A dedicated runtime health/details panel once OpenClaw exposes enough metadata to justify it. + +## Open Questions + +- None for v1 spec. New questions should be captured during implementation planning if a concrete code seam contradicts this design. From 27fc0e447088aca0e398ea297dd87a200ba5e1b2 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 5 Jun 2026 21:32:56 -0400 Subject: [PATCH 02/14] feat: add OpenClaw provider contracts --- packages/contracts/src/agentMentions.ts | 2 + packages/contracts/src/ipc.ts | 5 ++ packages/contracts/src/model.ts | 21 ++++- packages/contracts/src/orchestration.test.ts | 19 +++++ packages/contracts/src/orchestration.ts | 10 +++ packages/contracts/src/provider.test.ts | 18 +++++ packages/contracts/src/providerDiscovery.ts | 4 + .../contracts/src/providerRuntime.test.ts | 23 ++++++ packages/contracts/src/providerRuntime.ts | 1 + packages/contracts/src/rpc.ts | 9 +++ packages/contracts/src/server.ts | 20 +++++ packages/contracts/src/settings.test.ts | 50 ++++++++++++ packages/contracts/src/settings.ts | 20 +++++ packages/contracts/src/ws.test.ts | 20 +++++ packages/contracts/src/ws.ts | 3 + packages/shared/src/model.test.ts | 17 ++++ packages/shared/src/model.ts | 10 ++- packages/shared/src/serverSettings.test.ts | 52 ++++++++++++ packages/shared/src/serverSettings.ts | 79 +++++++++++++++++-- 19 files changed, 373 insertions(+), 10 deletions(-) create mode 100644 packages/contracts/src/settings.test.ts create mode 100644 packages/shared/src/serverSettings.test.ts diff --git a/packages/contracts/src/agentMentions.ts b/packages/contracts/src/agentMentions.ts index edc035e1..bc9784b8 100644 --- a/packages/contracts/src/agentMentions.ts +++ b/packages/contracts/src/agentMentions.ts @@ -217,6 +217,7 @@ export const AGENT_MENTION_ALIASES_BY_PROVIDER: Record< gemini: {}, kilo: OPENCODE_AGENT_MENTION_ALIASES, opencode: OPENCODE_AGENT_MENTION_ALIASES, + openclaw: {}, pi: {}, } as const satisfies Record>; @@ -233,6 +234,7 @@ const AGENT_MENTION_AUTOCOMPLETE_ALIASES_BY_PROVIDER: Record Promise; getSettings: () => Promise; updateSettings: (input: ServerUpdateSettingsInput) => Promise; + updateOpenClawSecrets: ( + input: ServerUpdateOpenClawSecretsInput, + ) => Promise; getAuthSession: () => Promise; bootstrapAuth: (input: AuthBootstrapInput) => Promise; bootstrapBearerAuth: (input: AuthBootstrapInput) => Promise; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index adfb217e..ea59bbe6 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -118,6 +118,9 @@ export const CursorModelOptions = Schema.Struct({ }); export type CursorModelOptions = typeof CursorModelOptions.Type; +export const OpenClawModelOptions = Schema.Struct({}); +export type OpenClawModelOptions = typeof OpenClawModelOptions.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), claudeAgent: Schema.optional(ClaudeModelOptions), @@ -125,6 +128,7 @@ export const ProviderModelOptions = Schema.Struct({ gemini: Schema.optional(GeminiModelOptions), kilo: Schema.optional(OpenCodeModelOptions), opencode: Schema.optional(OpenCodeModelOptions), + openclaw: Schema.optional(OpenClawModelOptions), pi: Schema.optional(PiModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -439,6 +443,19 @@ export const MODEL_OPTIONS_BY_PROVIDER = { }, }, ], + openclaw: [ + { + slug: "gateway", + name: "OpenClaw Gateway", + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + contextWindowOptions: [], + }, + }, + ], kilo: [ { slug: "kilo/kilo-auto/free", @@ -515,7 +532,7 @@ export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; export type ModelSlug = BuiltInModelSlug | (string & {}); -export type ProviderWithDefaultModel = Exclude; +export type ProviderWithDefaultModel = Exclude; export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.5", @@ -589,6 +606,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record = { gemini: "Gemini", kilo: "Kilo", opencode: "OpenCode", + openclaw: "OpenClaw", pi: "Pi", }; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 6d47bac1..ecc981e7 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -12,6 +12,7 @@ import { OrchestrationGetTurnDiffInput, OrchestrationLatestTurn, OrchestrationReadModel, + ProviderKind, ProjectIconMetadata, ProjectCreatedPayload, ProjectMetaUpdatedPayload, @@ -40,11 +41,29 @@ const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(Orchestration const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); +const decodeProviderKind = Schema.decodeUnknownEffect(ProviderKind); const decodeModelSelection = Schema.decodeUnknownEffect(ModelSelection); const decodeClientOrchestrationCommand = Schema.decodeUnknownEffect(ClientOrchestrationCommand); const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); +it.effect("accepts OpenClaw as a provider kind", () => + Effect.gen(function* () { + const parsed = yield* decodeProviderKind("openclaw"); + + assert.equal(parsed, "openclaw"); + }), +); + +it.effect("accepts the OpenClaw gateway model-selection sentinel", () => + Effect.gen(function* () { + const parsed = yield* decodeModelSelection({ provider: "openclaw", model: "gateway" }); + + assert.equal(parsed.provider, "openclaw"); + assert.equal(parsed.model, "gateway"); + }), +); + it.effect("preserves thread activity payloads through the RPC JSON codec", () => Effect.gen(function* () { const codec = Schema.toCodecJson(OrchestrationReadModel); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index cf78b6d0..27f8cd3b 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -5,6 +5,7 @@ import { CursorModelOptions, GeminiModelOptions, OpenCodeModelOptions, + OpenClawModelOptions, PiModelOptions, } from "./model"; import { @@ -57,6 +58,7 @@ export const ProviderKind = Schema.Literals([ "gemini", "kilo", "opencode", + "openclaw", "pi", ]); export type ProviderKind = typeof ProviderKind.Type; @@ -124,6 +126,13 @@ export const PiModelSelection = Schema.Struct({ }); export type PiModelSelection = typeof PiModelSelection.Type; +export const OpenClawModelSelection = Schema.Struct({ + provider: Schema.Literal("openclaw"), + model: Schema.Literal("gateway"), + options: Schema.optional(OpenClawModelOptions), +}); +export type OpenClawModelSelection = typeof OpenClawModelSelection.Type; + export const ModelSelection = Schema.Union([ CodexModelSelection, ClaudeModelSelection, @@ -131,6 +140,7 @@ export const ModelSelection = Schema.Union([ GeminiModelSelection, KiloModelSelection, OpenCodeModelSelection, + OpenClawModelSelection, PiModelSelection, ]); export type ModelSelection = typeof ModelSelection.Type; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index b8ab1794..9ee2d733 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -7,6 +7,24 @@ const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSession const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput); describe("ProviderSessionStartInput", () => { + it("accepts OpenClaw starts without secret provider options", () => { + const parsed = decodeProviderSessionStartInput({ + threadId: "thread-openclaw", + provider: "openclaw", + cwd: "/tmp/workspace", + modelSelection: { provider: "openclaw", model: "gateway" }, + runtimeMode: "full-access", + providerOptions: { openclaw: {} }, + }); + + expect(parsed.provider).toBe("openclaw"); + expect(parsed.modelSelection?.provider).toBe("openclaw"); + expect(parsed.modelSelection?.model).toBe("gateway"); + expect(parsed.providerOptions?.openclaw).toEqual({}); + expect("token" in (parsed.providerOptions?.openclaw ?? {})).toBe(false); + expect("password" in (parsed.providerOptions?.openclaw ?? {})).toBe(false); + }); + it("accepts codex-compatible payloads", () => { const parsed = decodeProviderSessionStartInput({ threadId: "thread-1", diff --git a/packages/contracts/src/providerDiscovery.ts b/packages/contracts/src/providerDiscovery.ts index dbbf66ed..601d7bbb 100644 --- a/packages/contracts/src/providerDiscovery.ts +++ b/packages/contracts/src/providerDiscovery.ts @@ -14,6 +14,7 @@ const ProviderDiscoveryKind = Schema.Literals([ "gemini", "kilo", "opencode", + "openclaw", "pi", ]); @@ -102,6 +103,8 @@ export const PiProviderStartOptions = Schema.Struct({ agentDir: Schema.optional(TrimmedNonEmptyString), }); +export const OpenClawProviderStartOptions = Schema.Struct({}); + export const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), claudeAgent: Schema.optional(ClaudeProviderStartOptions), @@ -109,6 +112,7 @@ export const ProviderStartOptions = Schema.Struct({ gemini: Schema.optional(GeminiProviderStartOptions), kilo: Schema.optional(KiloProviderStartOptions), opencode: Schema.optional(OpenCodeProviderStartOptions), + openclaw: Schema.optional(OpenClawProviderStartOptions), pi: Schema.optional(PiProviderStartOptions), }); export type ProviderStartOptions = typeof ProviderStartOptions.Type; diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index cb64f00b..9fa2bd9a 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -32,6 +32,29 @@ describe("ProviderRuntimeEvent", () => { expect(parsed.payload.tasks[1]?.status).toBe("inProgress"); }); + it("decodes redacted OpenClaw raw gateway events", () => { + const parsed = decodeRuntimeEvent({ + type: "runtime.warning", + eventId: "event-openclaw-raw", + provider: "openclaw", + createdAt: "2026-06-05T00:00:00.000Z", + threadId: "thread-openclaw", + payload: { message: "OpenClaw gateway event captured" }, + raw: { + source: "openclaw.gateway.event", + method: "chat.send", + payload: { redacted: true }, + }, + }); + + expect(parsed.provider).toBe("openclaw"); + expect(parsed.type).toBe("runtime.warning"); + if (parsed.type !== "runtime.warning") { + throw new Error("expected runtime warning event"); + } + expect(parsed.raw?.source).toBe("openclaw.gateway.event"); + }); + it("decodes proposed-plan completion events", () => { const parsed = decodeRuntimeEvent({ type: "turn.proposed.completed", diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 74f9b48a..f1d5a901 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -31,6 +31,7 @@ const RuntimeEventRawSource = Schema.Literals([ "acp.cursor.extension", "kilo.sdk.event", "opencode.sdk.event", + "openclaw.gateway.event", "pi.sdk.event", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index e4d709e1..9d7fd244 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -94,6 +94,8 @@ import { ServerRefreshProvidersResult, ServerResetKeybindingInput, ServerResetKeybindingsResult, + ServerUpdateOpenClawSecretsInput, + ServerUpdateOpenClawSecretsResult, ServerUpdateSettingsInput, ServerUpdateSettingsResult, ServerUpsertKeybindingResult, @@ -439,6 +441,12 @@ export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSetting error: WsRpcError, }); +export const WsServerUpdateOpenClawSecretsRpc = Rpc.make(WS_METHODS.serverUpdateOpenClawSecrets, { + payload: ServerUpdateOpenClawSecretsInput, + success: ServerUpdateOpenClawSecretsResult, + error: WsRpcError, +}); + export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({}), success: ServerRefreshProvidersResult, @@ -641,6 +649,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetEnvironmentRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, + WsServerUpdateOpenClawSecretsRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, WsServerListWorktreesRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 89fc8121..30ed4ace 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -329,3 +329,23 @@ export type ServerUpdateSettingsInput = typeof ServerUpdateSettingsInput.Type; export const ServerUpdateSettingsResult = ServerSettings; export type ServerUpdateSettingsResult = typeof ServerUpdateSettingsResult.Type; + +const OpenClawSecretValue = Schema.NullOr(Schema.String.check(Schema.isMaxLength(4096))); + +export const ServerUpdateOpenClawSecretsInput = Schema.Struct({ + token: Schema.optionalKey(OpenClawSecretValue), + password: Schema.optionalKey(OpenClawSecretValue), + deviceToken: Schema.optionalKey(OpenClawSecretValue), + rotateDeviceKey: Schema.optionalKey(Schema.Boolean), + clearDeviceIdentity: Schema.optionalKey(Schema.Boolean), +}); +export type ServerUpdateOpenClawSecretsInput = typeof ServerUpdateOpenClawSecretsInput.Type; + +export const ServerUpdateOpenClawSecretsResult = Schema.Struct({ + hasToken: Schema.Boolean, + hasPassword: Schema.Boolean, + hasDeviceKey: Schema.Boolean, + hasDeviceToken: Schema.Boolean, + paired: Schema.Boolean, +}); +export type ServerUpdateOpenClawSecretsResult = typeof ServerUpdateOpenClawSecretsResult.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts new file mode 100644 index 00000000..e1e51db2 --- /dev/null +++ b/packages/contracts/src/settings.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; + +import { ServerSettings, ServerSettingsPatch } from "./settings"; + +const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); +const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); + +describe("ServerSettings OpenClaw provider settings", () => { + it("decodes OpenClaw non-secret settings with redacted secret metadata", () => { + const parsed = decodeServerSettings({ + providers: { + openclaw: { + enabled: true, + gatewayUrl: "ws://127.0.0.1:18789", + authMode: "token", + hasSecret: true, + paired: false, + }, + }, + }); + + expect(parsed.providers.openclaw.gatewayUrl).toBe("ws://127.0.0.1:18789"); + expect(parsed.providers.openclaw.authMode).toBe("token"); + expect(parsed.providers.openclaw.hasSecret).toBe(true); + expect(parsed.providers.openclaw.paired).toBe(false); + expect("token" in parsed.providers.openclaw).toBe(false); + expect("password" in parsed.providers.openclaw).toBe(false); + }); + + it("decodes OpenClaw settings patches without secret values", () => { + const parsed = decodeServerSettingsPatch({ + providers: { + openclaw: { + gatewayUrl: "https://gateway.example.test", + authMode: "password", + hasSecret: true, + paired: true, + }, + }, + }); + + expect(parsed.providers?.openclaw?.gatewayUrl).toBe("https://gateway.example.test"); + expect(parsed.providers?.openclaw?.authMode).toBe("password"); + expect("token" in (parsed.providers?.openclaw ?? {})).toBe(false); + expect("password" in (parsed.providers?.openclaw ?? {})).toBe(false); + expect("hasSecret" in (parsed.providers?.openclaw ?? {})).toBe(false); + expect("paired" in (parsed.providers?.openclaw ?? {})).toBe(false); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 342cbe7b..7c808a48 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -72,6 +72,18 @@ export const PiServerProviderSettings = Schema.Struct({ }); export type PiServerProviderSettings = typeof PiServerProviderSettings.Type; +export const OpenClawAuthMode = Schema.Literals(["none", "token", "password", "device"]); +export type OpenClawAuthMode = typeof OpenClawAuthMode.Type; + +export const OpenClawServerProviderSettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), + gatewayUrl: StringSetting.pipe(Schema.withDecodingDefault(() => "")), + authMode: OpenClawAuthMode.pipe(Schema.withDecodingDefault(() => "none")), + hasSecret: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), + paired: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), +}); +export type OpenClawServerProviderSettings = typeof OpenClawServerProviderSettings.Type; + export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), defaultThreadEnvMode: ThreadEnvironmentMode.pipe(Schema.withDecodingDefault(() => "local")), @@ -89,6 +101,7 @@ export const ServerSettings = Schema.Struct({ gemini: GeminiServerProviderSettings.pipe(Schema.withDecodingDefault(() => ({}))), kilo: KiloServerProviderSettings.pipe(Schema.withDecodingDefault(() => ({}))), opencode: OpenCodeServerProviderSettings.pipe(Schema.withDecodingDefault(() => ({}))), + openclaw: OpenClawServerProviderSettings.pipe(Schema.withDecodingDefault(() => ({}))), pi: PiServerProviderSettings.pipe(Schema.withDecodingDefault(() => ({}))), }).pipe(Schema.withDecodingDefault(() => ({}))), }); @@ -151,6 +164,13 @@ export const ServerSettingsPatch = Schema.Struct({ activeRuntimeProfileId: Schema.optionalKey(StringSetting), }), ), + openclaw: Schema.optionalKey( + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + gatewayUrl: Schema.optionalKey(StringSetting), + authMode: Schema.optionalKey(OpenClawAuthMode), + }), + ), pi: Schema.optionalKey( Schema.Struct({ ...ProviderSettingsBasePatch, diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index 8bfc9f43..9da7eee3 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -80,6 +80,26 @@ it.effect("accepts git.preparePullRequestThread requests", () => }), ); +it.effect("accepts server.updateOpenClawSecrets requests", () => + Effect.gen(function* () { + const parsed = yield* decode(WebSocketRequest, { + id: "req-openclaw-secrets-1", + body: { + _tag: WS_METHODS.serverUpdateOpenClawSecrets, + token: "token-secret", + password: "password-secret", + rotateDeviceKey: true, + deviceToken: "paired-token", + }, + }); + assert.strictEqual(parsed.body._tag, WS_METHODS.serverUpdateOpenClawSecrets); + if (parsed.body._tag === WS_METHODS.serverUpdateOpenClawSecrets) { + assert.strictEqual(parsed.body.token, "token-secret"); + assert.strictEqual(parsed.body.rotateDeviceKey, true); + } + }), +); + it.effect("accepts typed websocket push envelopes with sequence", () => Effect.gen(function* () { const parsed = yield* decode(WsResponse, { diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 8f46d0f2..826111c6 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -66,6 +66,7 @@ import { ServerLifecycleStreamEvent, ServerProviderUpdateInput, ServerUpdateSettingsInput, + ServerUpdateOpenClawSecretsInput, ServerGetProviderUsageSnapshotInput, ServerProviderStatusesUpdatedPayload, ServerSettingsUpdatedPayload, @@ -136,6 +137,7 @@ export const WS_METHODS = { serverGetEnvironment: "server.getEnvironment", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", + serverUpdateOpenClawSecrets: "server.updateOpenClawSecrets", serverRefreshProviders: "server.refreshProviders", serverUpdateProvider: "server.updateProvider", serverListWorktrees: "server.listWorktrees", @@ -257,6 +259,7 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.serverGetEnvironment, Schema.Struct({})), tagRequestBody(WS_METHODS.serverGetSettings, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpdateSettings, ServerUpdateSettingsInput), + tagRequestBody(WS_METHODS.serverUpdateOpenClawSecrets, ServerUpdateOpenClawSecretsInput), tagRequestBody(WS_METHODS.serverRefreshProviders, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpdateProvider, ServerProviderUpdateInput), tagRequestBody(WS_METHODS.serverListWorktrees, Schema.Struct({})), diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index da9b1eb7..0fa26bd7 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -30,6 +30,7 @@ import { getProviderOptionCurrentLabel, getProviderOptionDescriptors, buildProviderOptionSelectionsFromDescriptors, + EMPTY_MODEL_CAPABILITIES, hasEffortLevel, } from "./model"; @@ -98,6 +99,22 @@ describe("resolveModelSlug", () => { expect(getModelOptions()).toEqual(MODEL_OPTIONS); expect(getModelOptions("claudeAgent")).toEqual(MODEL_OPTIONS_BY_PROVIDER.claudeAgent); }); + + it("exposes the fixed OpenClaw Gateway picker option without a default model", () => { + expect(getDefaultModel("openclaw")).toBeNull(); + expect(getModelOptions("openclaw")).toEqual([ + { + slug: "gateway", + name: "OpenClaw Gateway", + capabilities: EMPTY_MODEL_CAPABILITIES, + }, + ]); + expect("openclaw" in DEFAULT_MODEL_BY_PROVIDER).toBe(false); + expect(resolveModelSlug(undefined, "openclaw")).toBeNull(); + expect(resolveModelSlug("gateway", "openclaw")).toBe("gateway"); + expect(resolveModelSlug("OpenClaw Gateway", "openclaw")).toBe("gateway"); + expect(resolveModelSlug("gpt-5.5", "openclaw")).toBeNull(); + }); }); describe("resolveSelectableModel", () => { diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index d0eb2f3c..54b636c5 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -30,6 +30,7 @@ const MODEL_SLUG_SET_BY_PROVIDER: Record> = gemini: new Set(MODEL_OPTIONS_BY_PROVIDER.gemini.map((option) => option.slug)), kilo: new Set(MODEL_OPTIONS_BY_PROVIDER.kilo.map((option) => option.slug)), opencode: new Set(MODEL_OPTIONS_BY_PROVIDER.opencode.map((option) => option.slug)), + openclaw: new Set(MODEL_OPTIONS_BY_PROVIDER.openclaw.map((option) => option.slug)), pi: new Set(), }; @@ -110,10 +111,10 @@ export function getModelOptions(provider: ProviderKind = "codex") { } function hasDefaultModel(provider: ProviderKind): provider is ProviderWithDefaultModel { - return provider !== "pi"; + return provider !== "openclaw" && provider !== "pi"; } -export function getDefaultModel(provider: "pi"): null; +export function getDefaultModel(provider: "openclaw" | "pi"): null; export function getDefaultModel(provider?: ProviderWithDefaultModel): ModelSlug; export function getDefaultModel(provider: ProviderKind): ModelSlug | null; export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug | null { @@ -615,7 +616,10 @@ export function resolveModelSlug( provider: ProviderKind = "codex", ): ModelSlug | null { const normalized = normalizeModelSlug(model, provider); - if (provider === "pi") { + if (!hasDefaultModel(provider)) { + if (provider === "openclaw") { + return resolveSelectableModel(provider, model, MODEL_OPTIONS_BY_PROVIDER.openclaw); + } return normalized; } if (!normalized) { diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts new file mode 100644 index 00000000..2677ea9a --- /dev/null +++ b/packages/shared/src/serverSettings.test.ts @@ -0,0 +1,52 @@ +import { DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS } from "@jcode/contracts"; +import { describe, expect, it } from "vitest"; + +import { applyServerSettingsPatch } from "./serverSettings"; + +describe("applyServerSettingsPatch", () => { + it("sanitizes OpenClaw gateway URLs before persistence and exposure", () => { + const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + providers: { + openclaw: { + gatewayUrl: "https://user:pass@gateway.example.test/path?token=secret#fragment", + }, + }, + }); + + expect(next.providers.openclaw.gatewayUrl).toBe("https://gateway.example.test/path"); + }); + + it("scrubs credentials and query data from malformed OpenClaw gateway URLs", () => { + const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + providers: { + openclaw: { + gatewayUrl: "https://user:pass@gateway.example.test/%zz?token=secret#fragment", + }, + }, + }); + + expect(next.providers.openclaw.gatewayUrl).toBe("https://gateway.example.test/%zz"); + }); + + it("normalizes Git text-generation selections away from OpenClaw and Pi", () => { + const openClawNext = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + textGenerationModelSelection: { provider: "openclaw", model: "gateway" }, + }); + const piNext = applyServerSettingsPatch( + { + ...DEFAULT_SERVER_SETTINGS, + textGenerationModelSelection: { provider: "pi", model: "pi-model" }, + }, + {}, + ); + + expect(openClawNext.textGenerationModelSelection).toEqual({ + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }); + expect(piNext.textGenerationModelSelection).toEqual({ + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }); + }); +}); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 764dbc3d..dadf4abb 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,6 +1,8 @@ import { DEFAULT_MODEL_BY_PROVIDER, type ModelSelection, + type ProviderKind, + type ProviderWithDefaultModel, type ServerSettings, type ServerSettingsPatch, } from "@jcode/contracts"; @@ -12,21 +14,86 @@ function shouldReplaceTextGenerationModelSelection( return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined)); } +function providerHasDefaultModel(provider: ProviderKind): provider is ProviderWithDefaultModel { + return provider !== "openclaw" && provider !== "pi"; +} + +function sanitizeOpenClawGatewayUrl(value: string | undefined): string | undefined { + if (value === undefined || value.trim().length === 0) { + return value; + } + const trimmed = value.trim(); + try { + const url = new URL(trimmed); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + const withoutQueryOrFragment = trimmed.split(/[?#]/, 1)[0] ?? ""; + return withoutQueryOrFragment.replace(/^([a-zA-Z][a-zA-Z\d+.-]*:\/\/)([^/@\s]+@)(.*)$/, "$1$3"); + } +} + +function normalizeTextGenerationSelection(selection: ModelSelection): ModelSelection { + if (providerHasDefaultModel(selection.provider)) { + return selection; + } + return { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex }; +} + +function stripOpenClawServerMetadata(patch: ServerSettingsPatch): ServerSettingsPatch { + const openclaw = patch.providers?.openclaw; + if (openclaw === undefined || (!("hasSecret" in openclaw) && !("paired" in openclaw))) { + return patch; + } + + const openclawRecord = openclaw as Record; + const { hasSecret: _hasSecret, paired: _paired, ...clientOpenClaw } = openclawRecord; + return { + ...patch, + providers: { + ...patch.providers, + openclaw: clientOpenClaw, + }, + } as ServerSettingsPatch; +} + export function applyServerSettingsPatch( current: ServerSettings, patch: ServerSettingsPatch, ): ServerSettings { - const selectionPatch = patch.textGenerationModelSelection; - const next = deepMerge(current, patch as DeepPartial); + const sanitizedPatch = stripOpenClawServerMetadata(patch); + const selectionPatch = sanitizedPatch.textGenerationModelSelection; + const merged = deepMerge(current, sanitizedPatch as DeepPartial); + const sanitizedOpenClawGatewayUrl = merged.providers.openclaw.gatewayUrl + ? (sanitizeOpenClawGatewayUrl(merged.providers.openclaw.gatewayUrl) ?? "") + : merged.providers.openclaw.gatewayUrl; + const next = { + ...merged, + providers: { + ...merged.providers, + openclaw: { + ...merged.providers.openclaw, + gatewayUrl: sanitizedOpenClawGatewayUrl, + }, + }, + }; if (!selectionPatch) { - return next; + return { + ...next, + textGenerationModelSelection: normalizeTextGenerationSelection( + next.textGenerationModelSelection, + ), + }; } const provider = selectionPatch.provider ?? current.textGenerationModelSelection.provider; const model = selectionPatch.model ?? (selectionPatch.provider && - selectionPatch.provider !== "pi" && + providerHasDefaultModel(selectionPatch.provider) && selectionPatch.provider !== current.textGenerationModelSelection.provider ? DEFAULT_MODEL_BY_PROVIDER[selectionPatch.provider] : current.textGenerationModelSelection.model); @@ -36,10 +103,10 @@ export function applyServerSettingsPatch( return { ...next, - textGenerationModelSelection: { + textGenerationModelSelection: normalizeTextGenerationSelection({ provider, model, ...(options !== undefined ? { options } : {}), - } as ModelSelection, + } as ModelSelection), }; } From 9f84410fcae6197753650e943703793a6dd16b8f Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 5 Jun 2026 21:33:11 -0400 Subject: [PATCH 03/14] feat(server): add OpenClaw gateway runtime --- .../orchestrationEngine.integration.test.ts | 35 +- .../Layers/ProviderCommandReactor.ts | 2 +- .../Layers/ProviderRuntimeIngestion.ts | 6 +- .../provider/Layers/OpenClawAdapter.test.ts | 435 +++++++++++++ .../src/provider/Layers/OpenClawAdapter.ts | 601 ++++++++++++++++++ .../Layers/ProviderAdapterRegistry.test.ts | 22 + .../Layers/ProviderAdapterRegistry.ts | 2 + .../src/provider/Services/OpenClawAdapter.ts | 20 + .../provider/openclawGatewayClient.test.ts | 20 + .../src/provider/openclawGatewayClient.ts | 416 ++++++++++++ .../provider/openclawGatewayProtocol.test.ts | 150 +++++ .../src/provider/openclawGatewayProtocol.ts | 246 +++++++ .../src/provider/openclawGatewayUrl.test.ts | 51 ++ .../server/src/provider/openclawGatewayUrl.ts | 85 +++ .../src/provider/providerStatusCache.test.ts | 14 + .../src/provider/providerStatusCache.ts | 1 + apps/server/src/provider/runtimeLayer.ts | 7 + 17 files changed, 2099 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/provider/Layers/OpenClawAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/OpenClawAdapter.ts create mode 100644 apps/server/src/provider/Services/OpenClawAdapter.ts create mode 100644 apps/server/src/provider/openclawGatewayClient.test.ts create mode 100644 apps/server/src/provider/openclawGatewayClient.ts create mode 100644 apps/server/src/provider/openclawGatewayProtocol.test.ts create mode 100644 apps/server/src/provider/openclawGatewayProtocol.ts create mode 100644 apps/server/src/provider/openclawGatewayUrl.test.ts create mode 100644 apps/server/src/provider/openclawGatewayUrl.ts diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 883e2889..1b5cea05 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -43,6 +43,25 @@ const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); const itLiveUnlessCi = (process.env.CI ? it.skip : it.live) as typeof it.live; type IntegrationProvider = ProviderKind; +function defaultModelSelectionFor( + provider: Exclude, +): ModelSelection { + switch (provider) { + case "codex": + return { provider, model: DEFAULT_MODEL_BY_PROVIDER.codex }; + case "claudeAgent": + return { provider, model: DEFAULT_MODEL_BY_PROVIDER.claudeAgent }; + case "cursor": + return { provider, model: DEFAULT_MODEL_BY_PROVIDER.cursor }; + case "gemini": + return { provider, model: DEFAULT_MODEL_BY_PROVIDER.gemini }; + case "kilo": + return { provider, model: DEFAULT_MODEL_BY_PROVIDER.kilo }; + case "opencode": + return { provider, model: DEFAULT_MODEL_BY_PROVIDER.opencode }; + } +} + function nowIso() { return new Date().toISOString(); } @@ -109,10 +128,10 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => Effect.gen(function* () { const createdAt = nowIso(); const provider = harness.adapterHarness?.provider ?? "codex"; - if (provider === "pi") { - throw new Error("Pi integration tests require an explicit model selection."); + if (provider === "openclaw" || provider === "pi") { + throw new Error("OpenClaw and Pi integration tests require an explicit model selection."); } - const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider]; + const defaultModelSelection = defaultModelSelectionFor(provider); yield* harness.engine.dispatch({ type: "project.create", @@ -120,10 +139,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => projectId: PROJECT_ID, title: "Integration Project", workspaceRoot: harness.workspaceDir, - defaultModelSelection: { - provider, - model: defaultModel, - }, + defaultModelSelection, createdAt, }); @@ -133,10 +149,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => threadId: THREAD_ID, projectId: PROJECT_ID, title: "Integration Thread", - modelSelection: { - provider, - model: defaultModel, - }, + modelSelection: defaultModelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", branch: null, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 6df3e123..0e63b276 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -882,7 +882,7 @@ const make = Effect.gen(function* () { input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection; const modelForTurn = sessionModelSwitch === "unsupported" - ? activeSession?.model !== undefined + ? activeSession?.model !== undefined && requestedModelSelection.provider !== "openclaw" ? { ...requestedModelSelection, model: activeSession.model, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index eeb54f23..4728f1a7 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1715,9 +1715,11 @@ const make = Effect.gen(function* () { (entry) => entry.id === childThreadId, ); const resolvedModelSelection = - identity?.model && identity.modelIsRequestedHint !== true + identity?.model && + identity.modelIsRequestedHint !== true && + parentThread.modelSelection.provider !== "openclaw" ? { - provider: parentThread.modelSelection.provider, + ...parentThread.modelSelection, model: identity.model, } : undefined; diff --git a/apps/server/src/provider/Layers/OpenClawAdapter.test.ts b/apps/server/src/provider/Layers/OpenClawAdapter.test.ts new file mode 100644 index 00000000..7906bc62 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenClawAdapter.test.ts @@ -0,0 +1,435 @@ +import assert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ApprovalRequestId, ThreadId, TurnId } from "@jcode/contracts"; +import { Effect, Fiber, Layer, Stream } from "effect"; +import { it, vi } from "@effect/vitest"; + +import { + ServerSecretStore, + type ServerSecretStoreShape, +} from "../../auth/Services/ServerSecretStore.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import type { OpenClawRequest } from "../openclawGatewayProtocol.ts"; +import { + type OpenClawGatewayClient, + type OpenClawGatewayConnectInput, + type OpenClawGatewayEvent, + type OpenClawGatewayRequestResult, +} from "../openclawGatewayClient.ts"; +import { OPENCLAW_SECRET_NAMES, deriveOpenClawDeviceId } from "../openclawSecrets.ts"; +import { OpenClawAdapter } from "../Services/OpenClawAdapter.ts"; +import { makeOpenClawAdapterLive } from "./OpenClawAdapter.ts"; + +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); +const asApprovalRequestId = (value: string): ApprovalRequestId => + ApprovalRequestId.makeUnsafe(value); + +const textEncoder = new TextEncoder(); + +class FakeOpenClawGatewayClient implements OpenClawGatewayClient { + public connectImpl = vi.fn((_input: OpenClawGatewayConnectInput) => + Effect.succeed({ methods: ["chat.history", "chat.send", "chat.abort"] }), + ); + public requestImpl = vi.fn((_request: OpenClawRequest) => + Effect.succeed({} satisfies OpenClawGatewayRequestResult), + ); + + connect: OpenClawGatewayClient["connect"] = (input) => this.connectImpl(input); + request: OpenClawGatewayClient["request"] = (request) => this.requestImpl(request); +} + +type SecretStoreValue = string | Uint8Array; + +function makeSecretStore(values: Record): ServerSecretStoreShape { + return { + get: (name) => + Effect.succeed( + values[name] instanceof Uint8Array + ? values[name] + : values[name] + ? textEncoder.encode(values[name]) + : null, + ), + set: vi.fn((_name, _value) => Effect.void), + getOrCreateRandom: vi.fn((_name, bytes) => Effect.succeed(new Uint8Array(bytes))), + remove: vi.fn((_name) => Effect.void), + }; +} + +function makeLayer(input: { + readonly client: FakeOpenClawGatewayClient; + readonly secrets?: Record; +}) { + return makeOpenClawAdapterLive({ gatewayClient: input.client }).pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + openclaw: { + gatewayUrl: "https://gateway.example.test/path?token=must-not-leak", + authMode: "token", + hasSecret: true, + }, + }, + }), + ), + Layer.provideMerge( + Layer.succeed( + ServerSecretStore, + makeSecretStore(input.secrets ?? { [OPENCLAW_SECRET_NAMES.token]: "openclaw-secret" }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); +} + +function collectEvents(count: number) { + return Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + return yield* adapter.streamEvents.pipe(Stream.take(count), Stream.runCollect); + }); +} + +const startOpenClawSession = Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + return yield* adapter.startSession({ + threadId: asThreadId("thread-openclaw"), + provider: "openclaw", + runtimeMode: "full-access", + modelSelection: { provider: "openclaw", model: "gateway" }, + }); +}); + +it.effect( + "starts a session by resolving settings/secrets, validating methods, and loading history", + () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + client.requestImpl.mockImplementation((request) => + request.method === "chat.history" + ? Effect.succeed({ turns: [{ id: "existing-turn", items: [] }] }) + : Effect.succeed({}), + ); + + const session = yield* Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + return yield* adapter.startSession({ + threadId: asThreadId("thread-openclaw"), + provider: "openclaw", + cwd: "/tmp/project", + runtimeMode: "full-access", + modelSelection: { provider: "openclaw", model: "gateway" }, + }); + }).pipe(Effect.provide(makeLayer({ client }))); + + assert.equal(session.provider, "openclaw"); + assert.equal(session.status, "ready"); + assert.equal(session.threadId, asThreadId("thread-openclaw")); + assert.equal(session.model, "gateway"); + + assert.equal( + client.connectImpl.mock.calls[0]?.[0].websocketUrl, + "wss://gateway.example.test/path", + ); + assert.deepEqual(client.connectImpl.mock.calls[0]?.[0].auth, { + type: "token", + token: "openclaw-secret", + }); + assert.deepEqual(client.requestImpl.mock.calls[0]?.[0], { + method: "chat.history", + params: { sessionKey: "jcode:thread-openclaw" }, + }); + }), +); + +it.effect("uses settings gateway URL and ignores browser-supplied OpenClaw provider options", () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + + yield* Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + yield* adapter.startSession({ + threadId: asThreadId("thread-openclaw"), + provider: "openclaw", + providerOptions: { openclaw: { gatewayUrl: "https://browser.example.test/steal" } }, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(makeLayer({ client }))); + + assert.equal( + client.connectImpl.mock.calls[0]?.[0].websocketUrl, + "wss://gateway.example.test/path", + ); + }), +); + +it.effect("sends stable derived device ids instead of raw binary device keys", () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + const deviceKey = new Uint8Array([1, 2, 3, 4]); + + yield* Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + yield* adapter.startSession({ + threadId: asThreadId("thread-openclaw"), + provider: "openclaw", + runtimeMode: "full-access", + }); + }).pipe( + Effect.provide( + makeOpenClawAdapterLive({ gatewayClient: client }).pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + openclaw: { + gatewayUrl: "https://gateway.example.test/path", + authMode: "device", + paired: true, + }, + }, + }), + ), + Layer.provideMerge( + Layer.succeed( + ServerSecretStore, + makeSecretStore({ + [OPENCLAW_SECRET_NAMES.deviceKey]: deviceKey, + [OPENCLAW_SECRET_NAMES.deviceToken]: "paired-token", + }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + + assert.deepEqual(client.connectImpl.mock.calls[0]?.[0].device, { + id: deriveOpenClawDeviceId(deviceKey), + token: "paired-token", + }); + }), +); + +it.effect("rejects gateways missing required chat methods", () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + client.connectImpl.mockImplementation(() => Effect.succeed({ methods: ["chat.history"] })); + + const result = yield* Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + return yield* adapter + .startSession({ + threadId: asThreadId("thread-openclaw"), + provider: "openclaw", + runtimeMode: "full-access", + }) + .pipe(Effect.result); + }).pipe(Effect.provide(makeLayer({ client }))); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "ProviderAdapterRequestError"); + assert.match(result.failure.message, /chat\.send/); + assert.match(result.failure.message, /chat\.abort/); + } + }), +); + +it.effect("sends text turns and emits assistant/completion events from gateway events", () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + const gatewayEvents: ReadonlyArray = [ + { type: "assistant.delta", runId: "run-1", text: "Hel" }, + { type: "assistant.delta", runId: "run-1", text: "lo" }, + { type: "assistant.completed", runId: "run-1", text: "Hello" }, + { type: "run.completed", runId: "run-1", stopReason: "end_turn" }, + ]; + client.requestImpl.mockImplementation((request) => + request.method === "chat.send" + ? Effect.succeed({ runId: "run-1", events: gatewayEvents }) + : Effect.succeed({}), + ); + + const { result, events } = yield* Effect.gen(function* () { + const eventsFiber = yield* collectEvents(5).pipe(Effect.forkChild); + const adapter = yield* OpenClawAdapter; + yield* startOpenClawSession; + client.requestImpl.mockClear(); + const result = yield* adapter.sendTurn({ + threadId: asThreadId("thread-openclaw"), + input: "Hello OpenClaw", + }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + return { result, events }; + }).pipe(Effect.provide(makeLayer({ client }))); + + assert.equal(result.threadId, asThreadId("thread-openclaw")); + assert.deepEqual(client.requestImpl.mock.calls[0]?.[0], { + method: "chat.send", + params: { + sessionKey: "jcode:thread-openclaw", + message: "Hello OpenClaw", + idempotencyKey: `jcode:thread-openclaw:${result.turnId}`, + }, + }); + assert.deepEqual( + events.map((event) => event.type), + ["turn.started", "content.delta", "content.delta", "item.completed", "turn.completed"], + ); + assert.deepEqual(events[1]?.payload, { streamKind: "assistant_text", delta: "Hel" }); + assert.deepEqual(events[2]?.payload, { streamKind: "assistant_text", delta: "lo" }); + assert.deepEqual(events[3]?.payload, { + itemType: "assistant_message", + status: "completed", + data: { text: "Hello" }, + }); + assert.deepEqual(events[4]?.payload, { state: "completed", stopReason: "end_turn" }); + }), +); + +it.effect("emits failed canonical events with redacted gateway raw payloads", () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + client.requestImpl.mockImplementation((request) => + request.method === "chat.send" + ? Effect.succeed({ + runId: "run-2", + events: [ + { + type: "error", + runId: "run-2", + message: "gateway failed", + token: "secret-token", + password: "secret-password", + nested: { authorization: "Bearer secret" }, + }, + ], + }) + : Effect.succeed({}), + ); + + const events = yield* Effect.gen(function* () { + const eventsFiber = yield* collectEvents(3).pipe(Effect.forkChild); + const adapter = yield* OpenClawAdapter; + yield* startOpenClawSession; + yield* adapter.sendTurn({ threadId: asThreadId("thread-openclaw"), input: "fail" }); + return Array.from(yield* Fiber.join(eventsFiber)); + }).pipe(Effect.provide(makeLayer({ client }))); + + assert.deepEqual( + events.map((event) => event.type), + ["turn.started", "runtime.error", "turn.completed"], + ); + assert.deepEqual(events[1]?.payload, { + message: "gateway failed", + class: "provider_error", + }); + assert.equal(events[1]?.raw?.source, "openclaw.gateway.event"); + assert.equal(JSON.stringify(events[1]?.raw?.payload).includes("secret"), false); + assert.deepEqual(events[2]?.payload, { state: "failed", errorMessage: "gateway failed" }); + }), +); + +it.effect("aborts active turns and stopped sessions with chat.abort", () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + client.requestImpl.mockImplementation((request) => + request.method === "chat.send" ? Effect.succeed({ runId: "run-1" }) : Effect.succeed({}), + ); + + yield* Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + yield* startOpenClawSession; + client.requestImpl.mockClear(); + const turn = yield* adapter.sendTurn({ + threadId: asThreadId("thread-openclaw"), + input: "abort me", + }); + yield* adapter.interruptTurn(asThreadId("thread-openclaw"), turn.turnId, "provider-thread-1"); + yield* adapter.stopSession(asThreadId("thread-openclaw")); + }).pipe(Effect.provide(makeLayer({ client }))); + + const requests = client.requestImpl.mock.calls.map((call) => call[0]); + const sendRequest = requests[0]; + assert.ok(sendRequest); + assert.equal(sendRequest.method, "chat.send"); + assert.ok("sessionKey" in sendRequest.params); + assert.ok("message" in sendRequest.params); + assert.ok("idempotencyKey" in sendRequest.params); + assert.equal(sendRequest.params.sessionKey, "jcode:thread-openclaw"); + assert.equal(sendRequest.params.message, "abort me"); + assert.match(String(sendRequest.params.idempotencyKey), /^jcode:thread-openclaw:/); + assert.deepEqual(requests.slice(1), [ + { method: "chat.abort", params: { sessionKey: "jcode:thread-openclaw", runId: "run-1" } }, + { method: "chat.abort", params: { sessionKey: "jcode:thread-openclaw" } }, + ]); + }), +); + +it.effect("fails unsupported approvals and structured user-input clearly", () => + Effect.gen(function* () { + const client = new FakeOpenClawGatewayClient(); + + const result = yield* Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + const approval = yield* adapter + .respondToRequest(asThreadId("thread-openclaw"), asApprovalRequestId("request-1"), "accept") + .pipe(Effect.result); + const userInput = yield* adapter + .respondToUserInput(asThreadId("thread-openclaw"), asApprovalRequestId("request-2"), {}) + .pipe(Effect.result); + return { approval, userInput }; + }).pipe(Effect.provide(makeLayer({ client }))); + + assert.equal(result.approval._tag, "Failure"); + assert.equal(result.userInput._tag, "Failure"); + if (result.approval._tag === "Failure") { + assert.equal(result.approval.failure._tag, "ProviderAdapterValidationError"); + assert.match(result.approval.failure.message, /does not support approvals/); + } + if (result.userInput._tag === "Failure") { + assert.equal(result.userInput.failure._tag, "ProviderAdapterValidationError"); + assert.match(result.userInput.failure.message, /does not support structured user input/); + } + }), +); + +it.effect("redacts gateway secrets when the production gateway client cannot connect", () => + Effect.gen(function* () { + const result = yield* Effect.gen(function* () { + const adapter = yield* OpenClawAdapter; + return yield* adapter + .startSession({ + threadId: asThreadId("thread-openclaw"), + provider: "openclaw", + runtimeMode: "full-access", + }) + .pipe(Effect.result); + }).pipe( + Effect.provide( + makeOpenClawAdapterLive().pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + openclaw: { + gatewayUrl: "https://user:pass@gateway.example.test/ws?token=secret", + }, + }, + }), + ), + Layer.provideMerge(Layer.succeed(ServerSecretStore, makeSecretStore({}))), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "ProviderAdapterRequestError"); + assert.equal(result.failure.message.includes("secret"), false); + assert.equal(result.failure.message.includes("pass"), false); + assert.match(result.failure.message, /gateway\.example\.test/); + } + }), +); diff --git a/apps/server/src/provider/Layers/OpenClawAdapter.ts b/apps/server/src/provider/Layers/OpenClawAdapter.ts new file mode 100644 index 00000000..0ac8a08d --- /dev/null +++ b/apps/server/src/provider/Layers/OpenClawAdapter.ts @@ -0,0 +1,601 @@ +import { randomUUID } from "node:crypto"; + +import { + EventId, + type ProviderRuntimeEvent, + type ProviderSession, + RuntimeItemId, + ThreadId, + TurnId, +} from "@jcode/contracts"; +import { Effect, Layer, Queue, Ref, Stream } from "effect"; + +import { ServerSecretStore } from "../../auth/Services/ServerSecretStore.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { + buildOpenClawAbortRequest, + buildOpenClawChallengeResponse, + buildOpenClawHistoryRequest, + buildOpenClawSendRequest, + type OpenClawAuthFrame, + type OpenClawChallenge, + type OpenClawChallengeResponse, + type OpenClawDeviceFrame, + type OpenClawRequest, + validateOpenClawMethodSupport, +} from "../openclawGatewayProtocol.ts"; +import { + defaultOpenClawGatewayClient, + type OpenClawGatewayClient, + type OpenClawGatewayEvent, + type OpenClawGatewayRequestResult, + type OpenClawGatewaySendResult, +} from "../openclawGatewayClient.ts"; +import { normalizeOpenClawGatewayUrl, OpenClawGatewayUrlError } from "../openclawGatewayUrl.ts"; +import { + deriveOpenClawDeviceId, + getOpenClawSecret, + getOpenClawSecretBytes, +} from "../openclawSecrets.ts"; +import { OpenClawAdapter, type OpenClawAdapterShape } from "../Services/OpenClawAdapter.ts"; +import type { ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; + +const PROVIDER = "openclaw" as const; + +export interface OpenClawAdapterLiveOptions { + readonly gatewayClient?: OpenClawGatewayClient; +} + +interface OpenClawSessionContext { + readonly session: ProviderSession; + readonly turns: ReadonlyArray<{ readonly id: TurnId; readonly items: ReadonlyArray }>; + readonly activeRunIdsByTurn: ReadonlyMap; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function asTurnId(value: string): TurnId { + return TurnId.makeUnsafe(value); +} + +function asRuntimeItemId(value: string): RuntimeItemId { + return RuntimeItemId.makeUnsafe(value); +} + +function sessionGatewayKey(threadId: ThreadId): string { + return `jcode:${threadId}`; +} + +function requestError( + method: string, + detail: string, + cause?: unknown, +): ProviderAdapterRequestError { + return new ProviderAdapterRequestError({ provider: PROVIDER, method, detail, cause }); +} + +function validationError(operation: string, issue: string): ProviderAdapterValidationError { + return new ProviderAdapterValidationError({ provider: PROVIDER, operation, issue }); +} + +function causeMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.trim().length > 0) { + return cause.message.trim(); + } + return fallback; +} + +function redactSensitiveValue(value: unknown, key = ""): unknown { + const lowerKey = key.toLowerCase(); + if ( + lowerKey.includes("token") || + lowerKey.includes("password") || + lowerKey.includes("secret") || + lowerKey.includes("authorization") || + lowerKey.includes("apikey") || + lowerKey.includes("api_key") + ) { + return "[redacted]"; + } + if (Array.isArray(value)) { + return value.map((item) => redactSensitiveValue(item)); + } + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value as Record).map(([entryKey, entryValue]) => [ + entryKey, + redactSensitiveValue(entryValue, entryKey), + ]), + ); + } + if (typeof value === "string") { + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return value; + } + } + return value; +} + +function buildEventBase(input: { + readonly threadId: ThreadId; + readonly turnId?: TurnId; + readonly itemId?: string; + readonly raw?: unknown; +}): Pick< + ProviderRuntimeEvent, + "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "raw" +> { + return { + eventId: EventId.makeUnsafe(randomUUID()), + provider: PROVIDER, + threadId: input.threadId, + createdAt: nowIso(), + ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), + ...(input.itemId !== undefined ? { itemId: asRuntimeItemId(input.itemId) } : {}), + ...(input.raw !== undefined + ? { raw: { source: "openclaw.gateway.event", payload: redactSensitiveValue(input.raw) } } + : {}), + }; +} + +function sendResultEvents( + result: OpenClawGatewayRequestResult, +): ReadonlyArray { + if (typeof result !== "object" || result === null || !("events" in result)) { + return []; + } + const events = (result as OpenClawGatewaySendResult).events; + return Array.isArray(events) ? events : []; +} + +function historyTurns( + result: OpenClawGatewayRequestResult, +): ReadonlyArray<{ readonly id: TurnId; readonly items: ReadonlyArray }> { + if (typeof result !== "object" || result === null || !("turns" in result)) { + return []; + } + const turns = (result as { readonly turns?: ReadonlyArray }).turns; + if (!Array.isArray(turns)) { + return []; + } + return turns.flatMap((turn) => { + if (typeof turn !== "object" || turn === null) { + return []; + } + const record = turn as Record; + const id = typeof record.id === "string" && record.id.trim().length > 0 ? record.id : undefined; + if (id === undefined) { + return []; + } + return [ + { + id: asTurnId(id), + items: Array.isArray(record.items) ? record.items : [], + }, + ]; + }); +} + +export const makeOpenClawAdapterLive = (options: OpenClawAdapterLiveOptions = {}) => + Layer.effect( + OpenClawAdapter, + Effect.gen(function* () { + const settingsService = yield* ServerSettingsService; + const secretStore = yield* ServerSecretStore; + const gatewayClient = options.gatewayClient ?? defaultOpenClawGatewayClient; + const runtimeEvents = yield* Queue.unbounded(); + const sessionsRef = yield* Ref.make(new Map()); + + const emit = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + + const resolveGateway = () => + Effect.gen(function* () { + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError((cause) => + requestError( + "connect", + causeMessage(cause, "Failed to load OpenClaw settings."), + cause, + ), + ), + ); + const gatewayUrl = settings.providers.openclaw.gatewayUrl.trim(); + if (gatewayUrl.length === 0) { + return yield* Effect.fail( + requestError("connect", "OpenClaw gateway URL is not configured."), + ); + } + const normalized = yield* Effect.try({ + try: () => normalizeOpenClawGatewayUrl(gatewayUrl), + catch: (cause) => + requestError( + "connect", + cause instanceof OpenClawGatewayUrlError + ? cause.message + : "Invalid OpenClaw gateway URL.", + cause, + ), + }); + + const authMode = settings.providers.openclaw.authMode; + const readSecret = (kind: "token" | "password" | "deviceKey" | "deviceToken") => + getOpenClawSecret(kind).pipe( + Effect.provideService(ServerSecretStore, secretStore), + Effect.mapError((cause) => + requestError( + "connect", + causeMessage(cause, "Failed to read OpenClaw secret metadata."), + cause, + ), + ), + ); + const readSecretBytes = (kind: "deviceKey") => + getOpenClawSecretBytes(kind).pipe( + Effect.provideService(ServerSecretStore, secretStore), + Effect.mapError((cause) => + requestError( + "connect", + causeMessage(cause, "Failed to read OpenClaw secret metadata."), + cause, + ), + ), + ); + const token = authMode === "token" ? yield* readSecret("token") : null; + const password = authMode === "password" ? yield* readSecret("password") : null; + const deviceKey = authMode === "device" ? yield* readSecretBytes("deviceKey") : null; + const deviceToken = authMode === "device" ? yield* readSecret("deviceToken") : null; + + const auth: OpenClawAuthFrame | undefined = + authMode === "token" + ? token + ? { type: "token", token } + : undefined + : authMode === "password" + ? password + ? { type: "password", password } + : undefined + : undefined; + const device: OpenClawDeviceFrame | undefined = + authMode === "device" && deviceKey !== null + ? { + id: deriveOpenClawDeviceId(deviceKey), + ...(deviceToken !== null ? { token: deviceToken } : {}), + } + : undefined; + + if ((authMode === "token" || authMode === "password") && auth === undefined) { + return yield* Effect.fail( + requestError("connect", `OpenClaw ${authMode} secret is not configured.`), + ); + } + if (authMode === "device" && device === undefined) { + return yield* Effect.fail( + requestError("connect", "OpenClaw device identity is not configured."), + ); + } + + const respondToChallenge = + authMode === "device" && deviceKey !== null && device !== undefined + ? (challenge: OpenClawChallenge) => + buildOpenClawChallengeResponse({ + challenge, + deviceId: device.id, + deviceKey, + }) + : undefined; + + return { normalized, auth, device, respondToChallenge }; + }); + + const requestGateway = (request: OpenClawRequest) => + gatewayClient + .request(request) + .pipe( + Effect.mapError((cause) => + requestError(request.method, causeMessage(cause, `${request.method} failed.`), cause), + ), + ); + + const adapter: OpenClawAdapterShape = { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "unsupported" }, + startSession: (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* Effect.fail( + validationError( + "startSession", + `Expected provider ${PROVIDER}, received ${input.provider}.`, + ), + ); + } + if ( + input.modelSelection !== undefined && + (input.modelSelection.provider !== PROVIDER || + input.modelSelection.model !== "gateway") + ) { + return yield* Effect.fail( + validationError( + "startSession", + "OpenClaw sessions must use the gateway model sentinel.", + ), + ); + } + + const { normalized, auth, device, respondToChallenge } = yield* resolveGateway(); + const connectResult = yield* gatewayClient + .connect({ + websocketUrl: normalized.websocketUrl, + redactedGatewayUrl: normalized.redactedUrl, + ...(auth !== undefined ? { auth } : {}), + ...(device !== undefined ? { device } : {}), + ...(respondToChallenge !== undefined ? { respondToChallenge } : {}), + }) + .pipe( + Effect.mapError((cause) => + requestError( + "connect", + causeMessage(cause, "OpenClaw gateway connection failed."), + cause, + ), + ), + ); + const support = validateOpenClawMethodSupport(connectResult.methods); + if (!support.supported) { + return yield* Effect.fail( + requestError( + "connect", + `OpenClaw gateway is missing required methods: ${support.missing.join(", ")}.`, + ), + ); + } + const history = yield* requestGateway( + buildOpenClawHistoryRequest({ sessionKey: sessionGatewayKey(input.threadId) }), + ); + const now = nowIso(); + const session: ProviderSession = { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + threadId: input.threadId, + model: "gateway", + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + createdAt: now, + updatedAt: now, + }; + yield* Ref.update(sessionsRef, (sessions) => + new Map(sessions).set(input.threadId, { + session, + turns: historyTurns(history), + activeRunIdsByTurn: new Map(), + }), + ); + return session; + }), + sendTurn: (input) => + Effect.gen(function* () { + if (input.attachments !== undefined && input.attachments.length > 0) { + return yield* Effect.fail( + validationError("sendTurn", "OpenClaw v1 does not support attachments."), + ); + } + if (input.skills !== undefined && input.skills.length > 0) { + return yield* Effect.fail( + validationError("sendTurn", "OpenClaw v1 does not support skill mentions."), + ); + } + if (input.mentions !== undefined && input.mentions.length > 0) { + return yield* Effect.fail( + validationError("sendTurn", "OpenClaw v1 does not support provider mentions."), + ); + } + if (input.modelSelection !== undefined && input.modelSelection.provider !== PROVIDER) { + return yield* Effect.fail( + validationError( + "sendTurn", + `Expected provider ${PROVIDER}, received ${input.modelSelection.provider}.`, + ), + ); + } + if (input.input === undefined || input.input.trim().length === 0) { + return yield* Effect.fail( + validationError("sendTurn", "OpenClaw turns require text input."), + ); + } + const sessions = yield* Ref.get(sessionsRef); + if (!sessions.has(input.threadId)) { + return yield* Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId: input.threadId, + }), + ); + } + + const turnId = asTurnId(`openclaw-turn-${randomUUID()}`); + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId }), + type: "turn.started", + payload: { model: "gateway" }, + }); + + const result = yield* requestGateway( + buildOpenClawSendRequest({ + sessionKey: sessionGatewayKey(input.threadId), + threadId: input.threadId, + turnId, + message: input.input, + }), + ); + const gatewayEvents = sendResultEvents(result); + const runId = + ("runId" in result && typeof result.runId === "string" ? result.runId : undefined) ?? + gatewayEvents.find((event) => typeof event.runId === "string")?.runId; + yield* Ref.update(sessionsRef, (currentSessions) => { + const context = currentSessions.get(input.threadId); + if (context === undefined) { + return currentSessions; + } + const activeRunIdsByTurn = new Map(context.activeRunIdsByTurn); + if (runId !== undefined) { + activeRunIdsByTurn.set(turnId, runId); + } + const nextTurns = [ + ...context.turns, + { id: turnId, items: [{ role: "user", text: input.input }] }, + ]; + return new Map(currentSessions).set(input.threadId, { + ...context, + turns: nextTurns, + activeRunIdsByTurn, + }); + }); + for (const event of gatewayEvents) { + switch (event.type) { + case "assistant.delta": { + const delta = event.text ?? event.delta ?? ""; + if (delta.length > 0) { + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId, raw: event }), + type: "content.delta", + payload: { streamKind: "assistant_text", delta }, + }); + } + break; + } + case "assistant.completed": { + yield* emit({ + ...buildEventBase({ + threadId: input.threadId, + turnId, + itemId: `openclaw-assistant-${event.runId ?? turnId}`, + raw: event, + }), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + data: { text: event.text ?? "" }, + }, + }); + break; + } + case "run.completed": { + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId, raw: event }), + type: "turn.completed", + payload: { state: "completed", stopReason: event.stopReason ?? null }, + }); + break; + } + case "error": { + const message = event.message ?? "OpenClaw gateway error."; + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId, raw: event }), + type: "runtime.error", + payload: { message, class: "provider_error" }, + }); + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId, raw: event }), + type: "turn.completed", + payload: { state: "failed", errorMessage: message }, + }); + break; + } + } + } + return { threadId: input.threadId, turnId }; + }), + interruptTurn: (threadId, turnId) => + Ref.get(sessionsRef).pipe( + Effect.flatMap((sessions) => { + const runId = turnId + ? sessions.get(threadId)?.activeRunIdsByTurn.get(turnId) + : undefined; + return requestGateway( + buildOpenClawAbortRequest({ + sessionKey: sessionGatewayKey(threadId), + ...(runId !== undefined ? { runId } : {}), + }), + ); + }), + Effect.asVoid, + ), + respondToRequest: () => + Effect.fail( + validationError("respondToRequest", "OpenClaw v1 does not support approvals."), + ), + respondToUserInput: () => + Effect.fail( + validationError( + "respondToUserInput", + "OpenClaw v1 does not support structured user input.", + ), + ), + stopSession: (threadId) => + requestGateway( + buildOpenClawAbortRequest({ sessionKey: sessionGatewayKey(threadId) }), + ).pipe( + Effect.tap(() => + Ref.update(sessionsRef, (sessions) => { + const next = new Map(sessions); + next.delete(threadId); + return next; + }), + ), + Effect.asVoid, + ), + listSessions: () => + Ref.get(sessionsRef).pipe( + Effect.map((sessions) => Array.from(sessions.values(), (context) => context.session)), + ), + hasSession: (threadId) => + Ref.get(sessionsRef).pipe(Effect.map((sessions) => sessions.has(threadId))), + readThread: (threadId): Effect.Effect => + Ref.get(sessionsRef).pipe( + Effect.flatMap((sessions) => { + const context = sessions.get(threadId); + if (context === undefined) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed({ threadId, turns: context.turns }); + }), + ), + rollbackThread: () => + Effect.fail(validationError("rollbackThread", "OpenClaw v1 does not support rollback.")), + stopAll: () => + Ref.get(sessionsRef).pipe( + Effect.flatMap((sessions) => + Effect.forEach( + Array.from(sessions.keys()), + (threadId) => adapter.stopSession(ThreadId.makeUnsafe(threadId)), + { discard: true }, + ), + ), + Effect.asVoid, + ), + streamEvents: Stream.fromQueue(runtimeEvents), + }; + + return adapter; + }), + ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 635dd37e..6bbf45b7 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -10,6 +10,7 @@ import { CursorAdapter, CursorAdapterShape } from "../Services/CursorAdapter.ts" import { GeminiAdapter, GeminiAdapterShape } from "../Services/GeminiAdapter.ts"; import { KiloAdapter, KiloAdapterShape } from "../Services/KiloAdapter.ts"; import { OpenCodeAdapter, OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { OpenClawAdapter, OpenClawAdapterShape } from "../Services/OpenClawAdapter.ts"; import { PiAdapter, PiAdapterShape } from "../Services/PiAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; @@ -101,6 +102,23 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = { streamEvents: Stream.empty, }; +const fakeOpenClawAdapter: OpenClawAdapterShape = { + provider: "openclaw", + capabilities: { sessionModelSwitch: "unsupported" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const fakeKiloAdapter: KiloAdapterShape = { provider: "kilo", capabilities: { sessionModelSwitch: "in-session" }, @@ -146,6 +164,7 @@ const layer = it.layer( Layer.succeed(GeminiAdapter, fakeGeminiAdapter), Layer.succeed(KiloAdapter, fakeKiloAdapter), Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter), + Layer.succeed(OpenClawAdapter, fakeOpenClawAdapter), Layer.succeed(PiAdapter, fakePiAdapter), ), ), @@ -163,6 +182,7 @@ layer("ProviderAdapterRegistryLive", (it) => { const gemini = yield* registry.getByProvider("gemini"); const kilo = yield* registry.getByProvider("kilo"); const opencode = yield* registry.getByProvider("opencode"); + const openclaw = yield* registry.getByProvider("openclaw"); const pi = yield* registry.getByProvider("pi"); assert.equal(codex, fakeCodexAdapter); assert.equal(claude, fakeClaudeAdapter); @@ -170,6 +190,7 @@ layer("ProviderAdapterRegistryLive", (it) => { assert.equal(gemini, fakeGeminiAdapter); assert.equal(kilo, fakeKiloAdapter); assert.equal(opencode, fakeOpenCodeAdapter); + assert.equal(openclaw, fakeOpenClawAdapter); assert.equal(pi, fakePiAdapter); const providers = yield* registry.listProviders(); @@ -180,6 +201,7 @@ layer("ProviderAdapterRegistryLive", (it) => { "gemini", "kilo", "opencode", + "openclaw", "pi", ]); }), diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index b607ebbd..7fce6fdc 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -21,6 +21,7 @@ import { CursorAdapter } from "../Services/CursorAdapter.ts"; import { GeminiAdapter } from "../Services/GeminiAdapter.ts"; import { KiloAdapter } from "../Services/KiloAdapter.ts"; import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; +import { OpenClawAdapter } from "../Services/OpenClawAdapter.ts"; import { PiAdapter } from "../Services/PiAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { @@ -39,6 +40,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption yield* GeminiAdapter, yield* KiloAdapter, yield* OpenCodeAdapter, + yield* OpenClawAdapter, yield* PiAdapter, ]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); diff --git a/apps/server/src/provider/Services/OpenClawAdapter.ts b/apps/server/src/provider/Services/OpenClawAdapter.ts new file mode 100644 index 00000000..b1873f97 --- /dev/null +++ b/apps/server/src/provider/Services/OpenClawAdapter.ts @@ -0,0 +1,20 @@ +/** + * OpenClawAdapter - OpenClaw gateway implementation of the generic provider adapter contract. + * + * This service owns JCode's OpenClaw gateway session mapping and emits canonical + * provider runtime events. The initial adapter is intentionally text-only. + * + * @module OpenClawAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface OpenClawAdapterShape extends ProviderAdapterShape { + readonly provider: "openclaw"; +} + +export class OpenClawAdapter extends ServiceMap.Service()( + "jcode/provider/Services/OpenClawAdapter", +) {} diff --git a/apps/server/src/provider/openclawGatewayClient.test.ts b/apps/server/src/provider/openclawGatewayClient.test.ts new file mode 100644 index 00000000..52634410 --- /dev/null +++ b/apps/server/src/provider/openclawGatewayClient.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { scrubOpenClawGatewayDiagnostic } from "./openclawGatewayClient"; + +describe("scrubOpenClawGatewayDiagnostic", () => { + it("redacts auth tokens, credentials, query strings, and fragments", () => { + const scrubbed = scrubOpenClawGatewayDiagnostic( + "gateway rejected token=must-not-leak password=also-secret Bearer bearer-secret at https://user:pass@gateway.example.test/path?token=query-secret#fragment", + ); + + expect(scrubbed).toBe( + "gateway rejected token= password= Bearer at https://gateway.example.test/path", + ); + expect(scrubbed).not.toContain("must-not-leak"); + expect(scrubbed).not.toContain("also-secret"); + expect(scrubbed).not.toContain("bearer-secret"); + expect(scrubbed).not.toContain("user:pass"); + expect(scrubbed).not.toContain("query-secret"); + }); +}); diff --git a/apps/server/src/provider/openclawGatewayClient.ts b/apps/server/src/provider/openclawGatewayClient.ts new file mode 100644 index 00000000..d2c283b5 --- /dev/null +++ b/apps/server/src/provider/openclawGatewayClient.ts @@ -0,0 +1,416 @@ +import WebSocket, { type RawData } from "ws"; + +import { Effect } from "effect"; + +import { + buildOpenClawConnectFrame, + type OpenClawAuthFrame, + type OpenClawChallenge, + type OpenClawChallengeResponse, + type OpenClawDeviceFrame, + type OpenClawRequest, + isOpenClawAuthFailureFrame, +} from "./openclawGatewayProtocol"; + +const CONNECT_TIMEOUT_MS = 10_000; +const REQUEST_TIMEOUT_MS = 30_000; + +export type OpenClawGatewayEvent = + | { + readonly type: "assistant.delta"; + readonly runId?: string; + readonly text?: string; + readonly delta?: string; + readonly [key: string]: unknown; + } + | { + readonly type: "assistant.completed"; + readonly runId?: string; + readonly text?: string; + readonly [key: string]: unknown; + } + | { + readonly type: "run.completed"; + readonly runId?: string; + readonly stopReason?: string | null; + readonly [key: string]: unknown; + } + | { + readonly type: "error"; + readonly runId?: string; + readonly message?: string; + readonly [key: string]: unknown; + }; + +export interface OpenClawGatewayConnectInput { + readonly websocketUrl: string; + readonly redactedGatewayUrl: string; + readonly auth?: OpenClawAuthFrame; + readonly device?: OpenClawDeviceFrame; + readonly respondToChallenge?: (challenge: OpenClawChallenge) => OpenClawChallengeResponse; +} + +export interface OpenClawGatewayConnectResult { + readonly methods?: ReadonlyArray; + readonly protocolVersion?: number; +} + +export interface OpenClawGatewaySendResult { + readonly runId?: string; + readonly events?: ReadonlyArray; +} + +export type OpenClawGatewayRequestResult = + | OpenClawGatewaySendResult + | { + readonly turns?: ReadonlyArray<{ + readonly id: string; + readonly items: ReadonlyArray; + }>; + } + | Record; + +export interface OpenClawGatewayClient { + readonly connect: ( + input: OpenClawGatewayConnectInput, + ) => Effect.Effect; + readonly request: ( + request: OpenClawRequest, + ) => Effect.Effect; +} + +export interface OpenClawHealthProbeResult { + readonly methods?: ReadonlyArray; + readonly protocolVersion?: number; +} + +export interface OpenClawHealthProbeClient { + readonly probe: ( + input: OpenClawGatewayConnectInput, + ) => Effect.Effect; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readString(value: Record, key: string): string | undefined { + const field = value[key]; + return typeof field === "string" && field.trim().length > 0 ? field : undefined; +} + +function frameType(frame: unknown): string | undefined { + return isRecord(frame) ? readString(frame, "type") : undefined; +} + +function isGatewayEvent(frame: unknown): frame is OpenClawGatewayEvent { + const type = frameType(frame); + return ( + type === "assistant.delta" || + type === "assistant.completed" || + type === "run.completed" || + type === "error" + ); +} + +function isChallengeFrame(frame: unknown): frame is OpenClawChallenge { + return ( + isRecord(frame) && + frame.type === "connect.challenge" && + typeof frame.nonce === "string" && + frame.nonce.trim().length > 0 && + typeof frame.timestamp === "string" && + frame.timestamp.trim().length > 0 + ); +} + +function rawDataToString(data: RawData): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); + return Buffer.from(data).toString("utf8"); +} + +function parseFrame(data: RawData): unknown { + return JSON.parse(rawDataToString(data)) as unknown; +} + +function extractResult(frame: unknown): unknown { + return isRecord(frame) && "result" in frame ? frame.result : frame; +} + +function stringArray(value: unknown): ReadonlyArray | undefined { + return Array.isArray(value) && value.every((entry) => typeof entry === "string") + ? value + : undefined; +} + +function extractMethods(frame: unknown): ReadonlyArray | undefined { + const result = extractResult(frame); + if (!isRecord(result)) return undefined; + const direct = stringArray(result.methods); + if (direct) return direct; + const features = isRecord(result.features) ? stringArray(result.features.methods) : undefined; + if (features) return features; + return isRecord(result.hello) && isRecord(result.hello.features) + ? stringArray(result.hello.features.methods) + : undefined; +} + +function extractProtocolVersion(frame: unknown): number | undefined { + const result = extractResult(frame); + if (!isRecord(result)) return undefined; + if (typeof result.protocolVersion === "number") return result.protocolVersion; + if (isRecord(result.protocol) && typeof result.protocol.version === "number") { + return result.protocol.version; + } + return undefined; +} + +function errorMessageFromFrame(frame: unknown): string | undefined { + const result = extractResult(frame); + if (!isRecord(result)) return undefined; + const message = readString(result, "message"); + if (message) return scrubOpenClawGatewayDiagnostic(message); + const error = result.error; + if (typeof error === "string") return scrubOpenClawGatewayDiagnostic(error); + if (isRecord(error)) { + const nestedMessage = readString(error, "message") ?? readString(error, "code"); + return nestedMessage ? scrubOpenClawGatewayDiagnostic(nestedMessage) : undefined; + } + return undefined; +} + +export function scrubOpenClawGatewayDiagnostic(message: string): string { + return message + .replace(/\b(Bearer\s+)[^\s]+/gi, "$1") + .replace( + /\b(token|password|deviceToken|device-token|authorization|auth)=([^\s&#]+)/gi, + "$1=", + ) + .replace( + /\b([a-zA-Z][a-zA-Z\d+.-]*:\/\/)([^\s/@]+@)([^\s?#]+)([^\s]*)/g, + (_match, scheme, _userinfo, host, rest) => { + const path = String(rest).split(/[?#]/, 1)[0] ?? ""; + return `${scheme}${host}${path}`; + }, + ); +} + +function receiveFrame( + socket: WebSocket, + timeoutMs: number, + redactedGatewayUrl: string, +): Promise { + return new Promise((resolve, reject) => { + let done = false; + const timeout = setTimeout(() => { + finish({ + error: new Error( + `Timed out waiting for OpenClaw gateway frame from ${redactedGatewayUrl}.`, + ), + }); + }, timeoutMs); + const cleanup = () => { + clearTimeout(timeout); + socket.off("message", onMessage); + socket.off("error", onError); + socket.off("close", onClose); + }; + const finish = (result: { readonly value?: unknown; readonly error?: unknown }) => { + if (done) return; + done = true; + cleanup(); + if ("error" in result) reject(result.error); + else resolve(result.value); + }; + const onMessage = (data: RawData) => { + try { + finish({ value: parseFrame(data) }); + } catch (cause) { + finish({ error: cause }); + } + }; + const onError = (error: Error) => finish({ error }); + const onClose = (code: number, reason: Buffer) => { + const detail = + reason.length > 0 ? `: ${scrubOpenClawGatewayDiagnostic(reason.toString("utf8"))}` : ""; + finish({ error: new Error(`OpenClaw gateway closed before responding (${code})${detail}.`) }); + }; + socket.once("message", onMessage); + socket.once("error", onError); + socket.once("close", onClose); + }); +} + +function openSocket(websocketUrl: string, redactedGatewayUrl: string): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(websocketUrl); + let done = false; + const timeout = setTimeout(() => { + socket.close(); + finish({ + error: new Error(`Timed out connecting to OpenClaw gateway at ${redactedGatewayUrl}.`), + }); + }, CONNECT_TIMEOUT_MS); + const cleanup = () => { + clearTimeout(timeout); + socket.off("open", onOpen); + socket.off("error", onError); + socket.off("close", onClose); + }; + const finish = (result: { readonly socket?: WebSocket; readonly error?: unknown }) => { + if (done) return; + done = true; + cleanup(); + if ("error" in result) reject(result.error); + else if (result.socket !== undefined) resolve(result.socket); + else reject(new Error("OpenClaw gateway connection failed.")); + }; + const onOpen = () => finish({ socket }); + const onError = (error: Error) => finish({ error }); + const onClose = (code: number, reason: Buffer) => { + const detail = + reason.length > 0 ? `: ${scrubOpenClawGatewayDiagnostic(reason.toString("utf8"))}` : ""; + finish({ error: new Error(`OpenClaw gateway closed during connect (${code})${detail}.`) }); + }; + socket.once("open", onOpen); + socket.once("error", onError); + socket.once("close", onClose); + }); +} + +function sendFrame(socket: WebSocket, frame: unknown): Promise { + return new Promise((resolve, reject) => { + if (socket.readyState !== WebSocket.OPEN) { + reject(new Error("OpenClaw gateway socket is not open.")); + return; + } + socket.send(JSON.stringify(frame), (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +async function completeHandshake( + socket: WebSocket, + input: OpenClawGatewayConnectInput, +): Promise { + await sendFrame( + socket, + buildOpenClawConnectFrame({ + ...(input.auth !== undefined ? { auth: input.auth } : {}), + ...(input.device !== undefined ? { device: input.device } : {}), + }), + ); + let frame = await receiveFrame(socket, CONNECT_TIMEOUT_MS, input.redactedGatewayUrl); + if (isChallengeFrame(frame)) { + if (!input.respondToChallenge) { + throw new Error( + "OpenClaw gateway requested device challenge but no challenge responder is configured.", + ); + } + await sendFrame(socket, input.respondToChallenge(frame)); + frame = await receiveFrame(socket, CONNECT_TIMEOUT_MS, input.redactedGatewayUrl); + } + if (isOpenClawAuthFailureFrame(frame)) { + throw new Error(errorMessageFromFrame(frame) ?? "OpenClaw gateway rejected authentication."); + } + if (frameType(frame) === "error") { + throw new Error(errorMessageFromFrame(frame) ?? "OpenClaw gateway connect failed."); + } + const methods = extractMethods(frame); + const protocolVersion = extractProtocolVersion(frame); + return { + ...(methods !== undefined ? { methods } : {}), + ...(protocolVersion !== undefined ? { protocolVersion } : {}), + }; +} + +function normalizeRequestResult(frame: unknown): OpenClawGatewayRequestResult { + const result = extractResult(frame); + return isRecord(result) ? result : {}; +} + +async function requestOverSocket( + socket: WebSocket, + redactedGatewayUrl: string, + request: OpenClawRequest, +): Promise { + await sendFrame(socket, request); + if (request.method !== "chat.send") { + return normalizeRequestResult( + await receiveFrame(socket, REQUEST_TIMEOUT_MS, redactedGatewayUrl), + ); + } + + const firstFrame = await receiveFrame(socket, REQUEST_TIMEOUT_MS, redactedGatewayUrl); + const firstResult = normalizeRequestResult(firstFrame); + if ("events" in firstResult || "runId" in firstResult) { + return firstResult; + } + if (!isGatewayEvent(firstFrame)) { + return firstResult; + } + + const events: OpenClawGatewayEvent[] = [firstFrame]; + while (true) { + const latest = events[events.length - 1]; + if (latest?.type === "run.completed" || latest?.type === "error") break; + const frame = await receiveFrame(socket, REQUEST_TIMEOUT_MS, redactedGatewayUrl); + if (!isGatewayEvent(frame)) break; + events.push(frame); + } + const runId = events.find((event) => typeof event.runId === "string")?.runId; + return { ...(runId !== undefined ? { runId } : {}), events }; +} + +export function makeOpenClawGatewayClient(): OpenClawGatewayClient { + let socket: WebSocket | null = null; + let redactedGatewayUrl = "OpenClaw gateway"; + return { + connect: (input) => + Effect.tryPromise({ + try: async () => { + if (socket !== null) { + socket.close(); + socket = null; + } + const nextSocket = await openSocket(input.websocketUrl, input.redactedGatewayUrl); + const result = await completeHandshake(nextSocket, input); + socket = nextSocket; + redactedGatewayUrl = input.redactedGatewayUrl; + return result; + }, + catch: (cause) => cause, + }), + request: (request) => + Effect.tryPromise({ + try: async () => { + if (socket === null || socket.readyState !== WebSocket.OPEN) { + throw new Error("OpenClaw gateway is not connected."); + } + return await requestOverSocket(socket, redactedGatewayUrl, request); + }, + catch: (cause) => cause, + }), + }; +} + +export const defaultOpenClawGatewayClient = makeOpenClawGatewayClient(); + +export const defaultOpenClawHealthProbeClient: OpenClawHealthProbeClient = { + probe: (input) => + Effect.tryPromise({ + try: async () => { + const socket = await openSocket(input.websocketUrl, input.redactedGatewayUrl); + try { + return await completeHandshake(socket, input); + } finally { + socket.close(); + } + }, + catch: (cause) => cause, + }), +}; diff --git a/apps/server/src/provider/openclawGatewayProtocol.test.ts b/apps/server/src/provider/openclawGatewayProtocol.test.ts new file mode 100644 index 00000000..61e05f3f --- /dev/null +++ b/apps/server/src/provider/openclawGatewayProtocol.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +import { + OPENCLAW_CLIENT_DISPLAY_NAME, + OPENCLAW_CLIENT_ID, + OPENCLAW_CLIENT_MODE, + OPENCLAW_MAX_PROTOCOL_VERSION, + OPENCLAW_MIN_PROTOCOL_VERSION, + OPENCLAW_REQUIRED_METHODS, + OPENCLAW_SCOPES, + buildOpenClawAbortRequest, + buildOpenClawChallengeResponse, + buildOpenClawConnectFrame, + isOpenClawAuthFailureFrame, + buildOpenClawHistoryRequest, + buildOpenClawSendRequest, + deriveOpenClawIdempotencyKey, + validateOpenClawMethodSupport, + waitForOpenClawChallenge, +} from "./openclawGatewayProtocol"; + +describe("openclawGatewayProtocol", () => { + it("builds the canonical backend operator connect frame", () => { + expect(buildOpenClawConnectFrame({ auth: { type: "token", token: "secret" } })).toEqual({ + type: "connect", + minProtocol: OPENCLAW_MIN_PROTOCOL_VERSION, + maxProtocol: OPENCLAW_MAX_PROTOCOL_VERSION, + client: { + id: OPENCLAW_CLIENT_ID, + mode: OPENCLAW_CLIENT_MODE, + displayName: OPENCLAW_CLIENT_DISPLAY_NAME, + }, + role: "operator", + scopes: OPENCLAW_SCOPES, + auth: { type: "token", token: "secret" }, + }); + }); + + it("creates deterministic challenge responses bound to nonce, timestamp, and client id", () => { + const response = buildOpenClawChallengeResponse({ + challenge: { nonce: "nonce-1", timestamp: "2026-06-05T00:00:00.000Z" }, + deviceId: "device-1", + deviceKey: new Uint8Array([1, 2, 3, 4]), + }); + + expect(response).toEqual({ + type: "connect.challenge-response", + clientId: OPENCLAW_CLIENT_ID, + deviceId: "device-1", + nonce: "nonce-1", + timestamp: "2026-06-05T00:00:00.000Z", + signature: expect.any(String), + }); + expect(response.signature).toBe( + buildOpenClawChallengeResponse({ + challenge: { nonce: "nonce-1", timestamp: "2026-06-05T00:00:00.000Z" }, + deviceId: "device-1", + deviceKey: new Uint8Array([1, 2, 3, 4]), + }).signature, + ); + expect(response.signature).not.toBe( + buildOpenClawChallengeResponse({ + challenge: { nonce: "nonce-2", timestamp: "2026-06-05T00:00:00.000Z" }, + deviceId: "device-1", + deviceKey: new Uint8Array([1, 2, 3, 4]), + }).signature, + ); + }); + + it("waits for connect.challenge and times out with redacted details", async () => { + await expect( + waitForOpenClawChallenge({ + receive: () => + Promise.resolve({ + type: "connect.challenge", + nonce: "nonce-1", + timestamp: "2026-06-05T00:00:00.000Z", + }), + timeoutMs: 50, + redactedGatewayUrl: "wss://gateway.example.test/path", + }), + ).resolves.toEqual({ nonce: "nonce-1", timestamp: "2026-06-05T00:00:00.000Z" }); + + await expect( + waitForOpenClawChallenge({ + receive: () => new Promise(() => undefined), + timeoutMs: 1, + redactedGatewayUrl: "wss://gateway.example.test/path", + }), + ).rejects.toThrow("wss://gateway.example.test/path"); + await expect( + waitForOpenClawChallenge({ + receive: () => new Promise(() => undefined), + timeoutMs: 1, + redactedGatewayUrl: "wss://gateway.example.test/path?token=secret", + }), + ).rejects.not.toThrow(/token=secret/); + }); + + it("identifies auth failure frames that require paired-token clearing", () => { + expect(isOpenClawAuthFailureFrame({ type: "connect.error", code: "unauthorized" })).toBe(true); + expect(isOpenClawAuthFailureFrame({ type: "error", message: "authentication failed" })).toBe( + true, + ); + expect(isOpenClawAuthFailureFrame({ type: "error", message: "network unavailable" })).toBe( + false, + ); + }); + + it("validates required gateway chat methods while allowing absent method lists for probing", () => { + expect(validateOpenClawMethodSupport(undefined)).toEqual({ supported: true, missing: [] }); + expect(validateOpenClawMethodSupport([...OPENCLAW_REQUIRED_METHODS, "other.method"])).toEqual({ + supported: true, + missing: [], + }); + expect(validateOpenClawMethodSupport(["chat.history", "chat.send"])).toEqual({ + supported: false, + missing: ["chat.abort"], + }); + }); + + it("builds chat request payloads with stable session and idempotency keys", () => { + expect(buildOpenClawHistoryRequest({ sessionKey: "jcode:thread-1" })).toEqual({ + method: "chat.history", + params: { sessionKey: "jcode:thread-1" }, + }); + expect(deriveOpenClawIdempotencyKey({ threadId: "thread-1", turnId: "turn-1" })).toBe( + "jcode:thread-1:turn-1", + ); + expect( + buildOpenClawSendRequest({ + sessionKey: "jcode:thread-1", + threadId: "thread-1", + turnId: "turn-1", + message: "hello", + }), + ).toEqual({ + method: "chat.send", + params: { + sessionKey: "jcode:thread-1", + message: "hello", + idempotencyKey: "jcode:thread-1:turn-1", + }, + }); + expect(buildOpenClawAbortRequest({ sessionKey: "jcode:thread-1", runId: "run-1" })).toEqual({ + method: "chat.abort", + params: { sessionKey: "jcode:thread-1", runId: "run-1" }, + }); + }); +}); diff --git a/apps/server/src/provider/openclawGatewayProtocol.ts b/apps/server/src/provider/openclawGatewayProtocol.ts new file mode 100644 index 00000000..8180e15f --- /dev/null +++ b/apps/server/src/provider/openclawGatewayProtocol.ts @@ -0,0 +1,246 @@ +import * as Crypto from "node:crypto"; + +export const OPENCLAW_MIN_PROTOCOL_VERSION = 4; +export const OPENCLAW_MAX_PROTOCOL_VERSION = 4; +export const OPENCLAW_CLIENT_ID = "gateway-client"; +export const OPENCLAW_CLIENT_MODE = "backend"; +export const OPENCLAW_CLIENT_DISPLAY_NAME = "JCode"; +export const OPENCLAW_SCOPES = ["operator.read", "operator.write"] as const; +export const OPENCLAW_REQUIRED_METHODS = ["chat.history", "chat.send", "chat.abort"] as const; + +export type OpenClawScope = (typeof OPENCLAW_SCOPES)[number]; +export type OpenClawRequiredMethod = (typeof OPENCLAW_REQUIRED_METHODS)[number]; + +export interface OpenClawAuthFrame { + readonly type: string; + readonly token?: string; + readonly password?: string; +} + +export interface OpenClawDeviceFrame { + readonly id: string; + readonly token?: string; +} + +export interface OpenClawConnectFrameInput { + readonly auth?: OpenClawAuthFrame; + readonly device?: OpenClawDeviceFrame; +} + +export interface OpenClawConnectFrame { + readonly type: "connect"; + readonly minProtocol: typeof OPENCLAW_MIN_PROTOCOL_VERSION; + readonly maxProtocol: typeof OPENCLAW_MAX_PROTOCOL_VERSION; + readonly client: { + readonly id: typeof OPENCLAW_CLIENT_ID; + readonly mode: typeof OPENCLAW_CLIENT_MODE; + readonly displayName: typeof OPENCLAW_CLIENT_DISPLAY_NAME; + }; + readonly role: "operator"; + readonly scopes: typeof OPENCLAW_SCOPES; + readonly auth?: OpenClawAuthFrame; + readonly device?: OpenClawDeviceFrame; +} + +export interface OpenClawChallenge { + readonly nonce: string; + readonly timestamp: string; +} + +export interface OpenClawChallengeResponseInput { + readonly challenge: OpenClawChallenge; + readonly deviceId: string; + readonly deviceKey: Uint8Array; +} + +export interface OpenClawChallengeResponse { + readonly type: "connect.challenge-response"; + readonly clientId: typeof OPENCLAW_CLIENT_ID; + readonly deviceId: string; + readonly nonce: string; + readonly timestamp: string; + readonly signature: string; +} + +export interface OpenClawMethodSupport { + readonly supported: boolean; + readonly missing: ReadonlyArray; +} + +export interface OpenClawRequest { + readonly method: TMethod; + readonly params: TParams; +} + +export class OpenClawProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = "OpenClawProtocolError"; + } +} + +function redactedProtocolDetail(value: string): string { + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return value.split("?")[0]?.split("#")[0] ?? "OpenClaw gateway"; + } +} + +function readStringField(value: unknown, field: string): string | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + const record = value as Record; + const fieldValue = record[field]; + return typeof fieldValue === "string" && fieldValue.trim().length > 0 ? fieldValue : undefined; +} + +export async function waitForOpenClawChallenge(input: { + readonly receive: () => Promise; + readonly timeoutMs: number; + readonly redactedGatewayUrl: string; +}): Promise { + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + reject( + new OpenClawProtocolError( + `Timed out waiting for OpenClaw connect.challenge from ${redactedProtocolDetail( + input.redactedGatewayUrl, + )}.`, + ), + ); + }, input.timeoutMs); + }); + + try { + const frame = await Promise.race([input.receive(), timeoutPromise]); + const type = readStringField(frame, "type"); + const nonce = readStringField(frame, "nonce"); + const timestamp = readStringField(frame, "timestamp"); + if (type !== "connect.challenge" || nonce === undefined || timestamp === undefined) { + throw new OpenClawProtocolError( + "OpenClaw gateway did not provide a valid connect.challenge frame.", + ); + } + return { nonce, timestamp }; + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } +} + +export function isOpenClawAuthFailureFrame(frame: unknown): boolean { + const code = readStringField(frame, "code") ?? ""; + const message = readStringField(frame, "message") ?? ""; + return /auth|unauth|forbid|token|credential/i.test(`${code} ${message}`); +} + +export function buildOpenClawConnectFrame( + input: OpenClawConnectFrameInput = {}, +): OpenClawConnectFrame { + return { + type: "connect", + minProtocol: OPENCLAW_MIN_PROTOCOL_VERSION, + maxProtocol: OPENCLAW_MAX_PROTOCOL_VERSION, + client: { + id: OPENCLAW_CLIENT_ID, + mode: OPENCLAW_CLIENT_MODE, + displayName: OPENCLAW_CLIENT_DISPLAY_NAME, + }, + role: "operator", + scopes: OPENCLAW_SCOPES, + ...(input.auth !== undefined ? { auth: input.auth } : {}), + ...(input.device !== undefined ? { device: input.device } : {}), + }; +} + +function challengeSigningPayload(input: OpenClawChallengeResponseInput): string { + return [ + OPENCLAW_CLIENT_ID, + OPENCLAW_CLIENT_MODE, + OPENCLAW_CLIENT_DISPLAY_NAME, + input.deviceId, + input.challenge.nonce, + input.challenge.timestamp, + ].join("\\n"); +} + +export function buildOpenClawChallengeResponse( + input: OpenClawChallengeResponseInput, +): OpenClawChallengeResponse { + const signature = Crypto.createHmac("sha256", Buffer.from(input.deviceKey)) + .update(challengeSigningPayload(input)) + .digest("base64url"); + return { + type: "connect.challenge-response", + clientId: OPENCLAW_CLIENT_ID, + deviceId: input.deviceId, + nonce: input.challenge.nonce, + timestamp: input.challenge.timestamp, + signature, + }; +} + +export function validateOpenClawMethodSupport( + advertisedMethods: ReadonlyArray | undefined, +): OpenClawMethodSupport { + if (advertisedMethods === undefined) { + return { supported: true, missing: [] }; + } + const advertised = new Set(advertisedMethods); + const missing = OPENCLAW_REQUIRED_METHODS.filter((method) => !advertised.has(method)); + return { supported: missing.length === 0, missing }; +} + +export function buildOpenClawHistoryRequest(input: { + readonly sessionKey: string; +}): OpenClawRequest<"chat.history", { readonly sessionKey: string }> { + return { method: "chat.history", params: { sessionKey: input.sessionKey } }; +} + +export function deriveOpenClawIdempotencyKey(input: { + readonly threadId: string; + readonly turnId: string; +}): string { + return `jcode:${input.threadId}:${input.turnId}`; +} + +export function buildOpenClawSendRequest(input: { + readonly sessionKey: string; + readonly threadId: string; + readonly turnId: string; + readonly message: string; +}): OpenClawRequest< + "chat.send", + { readonly sessionKey: string; readonly message: string; readonly idempotencyKey: string } +> { + return { + method: "chat.send", + params: { + sessionKey: input.sessionKey, + message: input.message, + idempotencyKey: deriveOpenClawIdempotencyKey(input), + }, + }; +} + +export function buildOpenClawAbortRequest(input: { + readonly sessionKey: string; + readonly runId?: string; +}): OpenClawRequest<"chat.abort", { readonly sessionKey: string; readonly runId?: string }> { + return { + method: "chat.abort", + params: { + sessionKey: input.sessionKey, + ...(input.runId !== undefined ? { runId: input.runId } : {}), + }, + }; +} diff --git a/apps/server/src/provider/openclawGatewayUrl.test.ts b/apps/server/src/provider/openclawGatewayUrl.test.ts new file mode 100644 index 00000000..ca653a6b --- /dev/null +++ b/apps/server/src/provider/openclawGatewayUrl.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeOpenClawGatewayUrl, redactOpenClawGatewayUrl } from "./openclawGatewayUrl"; + +describe("openclawGatewayUrl", () => { + it("normalizes HTTP(S) gateway URLs to WebSocket URLs", () => { + expect(normalizeOpenClawGatewayUrl("http://127.0.0.1:18789").websocketUrl).toBe( + "ws://127.0.0.1:18789/", + ); + expect(normalizeOpenClawGatewayUrl("https://gateway.example.test/openclaw").websocketUrl).toBe( + "wss://gateway.example.test/openclaw", + ); + }); + + it("allows insecure WebSocket only for loopback gateways by default", () => { + expect(normalizeOpenClawGatewayUrl("ws://localhost:18789").websocketUrl).toBe( + "ws://localhost:18789/", + ); + expect(normalizeOpenClawGatewayUrl("ws://[::1]:18789").websocketUrl).toBe("ws://[::1]:18789/"); + expect(normalizeOpenClawGatewayUrl("ws://127.42.0.1:18789").websocketUrl).toBe( + "ws://127.42.0.1:18789/", + ); + expect(() => normalizeOpenClawGatewayUrl("ws://127.evil.test:18789")).toThrow(/requires wss/i); + expect(() => normalizeOpenClawGatewayUrl("ws://gateway.example.test")).toThrow(/requires wss/i); + expect( + normalizeOpenClawGatewayUrl("ws://gateway.example.test", { allowInsecureRemote: true }) + .websocketUrl, + ).toBe("ws://gateway.example.test/"); + }); + + it("redacts URL userinfo, query, and fragment values", () => { + const normalized = normalizeOpenClawGatewayUrl( + "https://user:pass@gateway.example.test/path?token=secret#fragment", + ); + + expect(normalized.websocketUrl).toBe("wss://gateway.example.test/path"); + expect(normalized.redactedUrl).toBe("wss://gateway.example.test/path"); + expect(redactOpenClawGatewayUrl("ws://user:pass@127.0.0.1:18789/?token=secret")).toBe( + "ws://127.0.0.1:18789/", + ); + }); + + it("redacts sensitive URL parts from rejection details", () => { + expect(() => + normalizeOpenClawGatewayUrl("ws://user:pass@gateway.example.test/path?token=secret#hash"), + ).toThrow("ws://gateway.example.test/path"); + expect(() => + normalizeOpenClawGatewayUrl("ws://user:pass@gateway.example.test/path?token=secret#hash"), + ).not.toThrow(/user|pass|token=secret|hash/); + }); +}); diff --git a/apps/server/src/provider/openclawGatewayUrl.ts b/apps/server/src/provider/openclawGatewayUrl.ts new file mode 100644 index 00000000..308002d7 --- /dev/null +++ b/apps/server/src/provider/openclawGatewayUrl.ts @@ -0,0 +1,85 @@ +import * as Net from "node:net"; + +export interface OpenClawGatewayUrlOptions { + readonly allowInsecureRemote?: boolean; +} + +export interface NormalizedOpenClawGatewayUrl { + readonly websocketUrl: string; + readonly redactedUrl: string; + readonly isLoopback: boolean; +} + +export class OpenClawGatewayUrlError extends Error { + constructor(message: string) { + super(message); + this.name = "OpenClawGatewayUrlError"; + } +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (normalized === "localhost" || normalized === "::1") { + return true; + } + if (Net.isIP(normalized) !== 4) { + return false; + } + const firstOctet = Number(normalized.split(".")[0]); + return firstOctet === 127; +} + +function parseGatewayUrl(value: string): URL { + try { + return new URL(value.trim()); + } catch (cause) { + throw new OpenClawGatewayUrlError("OpenClaw gateway URL must be a valid URL."); + } +} + +function stripSensitiveUrlParts(url: URL): URL { + const clone = new URL(url.toString()); + clone.username = ""; + clone.password = ""; + clone.search = ""; + clone.hash = ""; + return clone; +} + +export function redactOpenClawGatewayUrl(value: string): string { + return stripSensitiveUrlParts(parseGatewayUrl(value)).toString(); +} + +export function normalizeOpenClawGatewayUrl( + value: string, + options: OpenClawGatewayUrlOptions = {}, +): NormalizedOpenClawGatewayUrl { + const url = stripSensitiveUrlParts(parseGatewayUrl(value)); + switch (url.protocol) { + case "http:": + url.protocol = "ws:"; + break; + case "https:": + url.protocol = "wss:"; + break; + case "ws:": + case "wss:": + break; + default: + throw new OpenClawGatewayUrlError("OpenClaw gateway URL must use http, https, ws, or wss."); + } + + const isLoopback = isLoopbackHostname(url.hostname); + if (url.protocol === "ws:" && !isLoopback && options.allowInsecureRemote !== true) { + throw new OpenClawGatewayUrlError( + `OpenClaw gateway URL requires wss:// for non-loopback hosts: ${url.toString()}`, + ); + } + + const websocketUrl = url.toString(); + return { + websocketUrl, + redactedUrl: websocketUrl, + isLoopback, + }; +} diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 4ba24a48..5debf4c4 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -105,6 +105,13 @@ describe("providerStatusCache", () => { it("keeps provider ordering stable for transport consumers", () => { expect( orderProviderStatuses([ + { + provider: "openclaw", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-04-15T10:05:00.000Z", + }, { provider: "gemini", status: "ready", @@ -151,6 +158,13 @@ describe("providerStatusCache", () => { authStatus: "authenticated", checkedAt: "2026-04-15T10:02:00.000Z", }, + { + provider: "openclaw", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-04-15T10:05:00.000Z", + }, ]); }); }); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index dbbb32fc..db37365c 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -16,6 +16,7 @@ const PROVIDER_STATUS_CACHE_IDS = [ "gemini", "kilo", "opencode", + "openclaw", "pi", ] as const satisfies ReadonlyArray; diff --git a/apps/server/src/provider/runtimeLayer.ts b/apps/server/src/provider/runtimeLayer.ts index 0be02899..2b3584ea 100644 --- a/apps/server/src/provider/runtimeLayer.ts +++ b/apps/server/src/provider/runtimeLayer.ts @@ -3,6 +3,8 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { ServerConfig } from "../config"; +import { ServerSecretStore } from "../auth/Services/ServerSecretStore"; +import { ServerSettingsService } from "../serverSettings"; import { AnalyticsService } from "../telemetry/Services/AnalyticsService"; import { ProviderUnsupportedError } from "./Errors"; import { makeClaudeAdapterLive } from "./Layers/ClaudeAdapter"; @@ -11,6 +13,7 @@ import { makeCursorAdapterLive } from "./Layers/CursorAdapter"; import { makeEventNdjsonLogger } from "./Layers/EventNdjsonLogger"; import { makeGeminiAdapterLive } from "./Layers/GeminiAdapter"; import { makeKiloAdapterLive, makeOpenCodeAdapterLive } from "./Layers/OpenCodeAdapter"; +import { makeOpenClawAdapterLive } from "./Layers/OpenClawAdapter"; import { makePiAdapterLive } from "./Layers/PiAdapter"; import { ProviderAdapterRegistryLive } from "./Layers/ProviderAdapterRegistry"; import { ProviderDiscoveryServiceLive } from "./Layers/ProviderDiscoveryService"; @@ -29,6 +32,8 @@ export function makeServerProviderLayer(): Layer.Layer< | ServerConfig | FileSystem.FileSystem | AnalyticsService + | ServerSecretStore + | ServerSettingsService | ChildProcessSpawner.ChildProcessSpawner > { return Effect.gen(function* () { @@ -55,6 +60,7 @@ export function makeServerProviderLayer(): Layer.Layer< const openCodeAdapterLayer = makeOpenCodeAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const openClawAdapterLayer = makeOpenClawAdapterLive(); const kiloAdapterLayer = makeKiloAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); @@ -73,6 +79,7 @@ export function makeServerProviderLayer(): Layer.Layer< Layer.provide(geminiAdapterLayer), Layer.provide(kiloAdapterLayer), Layer.provide(openCodeAdapterLayer), + Layer.provide(openClawAdapterLayer), Layer.provide(piAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); From 58b8350137504f36f78cfd4107c462cd7a7d6dfa Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 5 Jun 2026 21:33:25 -0400 Subject: [PATCH 04/14] feat(server): manage OpenClaw secrets and health --- .../provider/Layers/ProviderHealth.test.ts | 239 +++++++++++++++++- .../src/provider/Layers/ProviderHealth.ts | 233 ++++++++++++++++- .../src/provider/openclawSecretUpdate.ts | 39 +++ .../src/provider/openclawSecrets.test.ts | 171 +++++++++++++ apps/server/src/provider/openclawSecrets.ts | 121 +++++++++ apps/server/src/serverSettings.test.ts | 202 ++++++++++++++- apps/server/src/serverSettings.ts | 99 +++++++- apps/server/src/wsRpc.ts | 17 ++ 8 files changed, 1106 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/provider/openclawSecretUpdate.ts create mode 100644 apps/server/src/provider/openclawSecrets.test.ts create mode 100644 apps/server/src/provider/openclawSecrets.ts diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index bd22c266..f8407848 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -10,6 +10,7 @@ import { checkCodexProviderStatus, checkCursorProviderStatus, checkOpenCodeProviderStatus, + checkOpenClawProviderStatus, hasCustomModelProvider, isExternalOpenCodeRuntimeActive, makeCheckClaudeProviderStatus, @@ -17,6 +18,7 @@ import { makeCheckCursorProviderStatus, makeCheckKiloProviderStatus, makeCheckOpenCodeProviderStatus, + makeCheckOpenClawProviderStatus, parseAuthStatusFromOutput, parseClaudeAuthStatusFromOutput, ProviderHealthLive, @@ -24,12 +26,33 @@ import { } from "./ProviderHealth"; import { ServerConfig } from "../../config"; import { ServerSettingsService } from "../../serverSettings"; +import { ServerSecretStoreLive } from "../../auth/Layers/ServerSecretStore"; +import { ServerSecretStore } from "../../auth/Services/ServerSecretStore"; +import { setOpenClawToken } from "../openclawSecrets"; import { ProviderHealth } from "../Services/ProviderHealth"; // ── Test helpers ──────────────────────────────────────────────────── const encoder = new TextEncoder(); +const openClawSecretLayer = ServerSecretStoreLive.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "jcode-provider-health-openclaw-test-", + }), + ), + Layer.provide(NodeServices.layer), +); + +const openClawTokenSecretLayer = Layer.effect( + ServerSecretStore, + Effect.gen(function* () { + const store = yield* ServerSecretStore; + yield* setOpenClawToken("token-secret"); + return store; + }), +).pipe(Layer.provide(openClawSecretLayer)); + function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -413,28 +436,28 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { Effect.gen(function* () { yield* withTempCodexHome(); assert.strictEqual(yield* readCodexConfigModelProvider, undefined); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("returns undefined when config has no model_provider key", () => Effect.gen(function* () { yield* withTempCodexHome('model = "gpt-5-codex"\n'); assert.strictEqual(yield* readCodexConfigModelProvider, undefined); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("returns the provider when model_provider is set at top level", () => Effect.gen(function* () { yield* withTempCodexHome('model = "gpt-5-codex"\nmodel_provider = "portkey"\n'); assert.strictEqual(yield* readCodexConfigModelProvider, "portkey"); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("returns openai when model_provider is openai", () => Effect.gen(function* () { yield* withTempCodexHome('model_provider = "openai"\n'); assert.strictEqual(yield* readCodexConfigModelProvider, "openai"); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("ignores model_provider inside section headers", () => @@ -450,7 +473,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ].join("\n"), ); assert.strictEqual(yield* readCodexConfigModelProvider, undefined); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("handles comments and whitespace", () => @@ -466,14 +489,14 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ].join("\n"), ); assert.strictEqual(yield* readCodexConfigModelProvider, "azure"); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("handles single-quoted values in TOML", () => Effect.gen(function* () { yield* withTempCodexHome("model_provider = 'mistral'\n"); assert.strictEqual(yield* readCodexConfigModelProvider, "mistral"); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); }); @@ -484,14 +507,14 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { Effect.gen(function* () { yield* withTempCodexHome(); assert.strictEqual(yield* hasCustomModelProvider, false); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("returns false when model_provider is not set", () => Effect.gen(function* () { yield* withTempCodexHome('model = "gpt-5-codex"\n'); assert.strictEqual(yield* hasCustomModelProvider, false); - }), + }).pipe(Effect.provide(openClawSecretLayer)), ); it.effect("returns false when model_provider is openai", () => @@ -733,6 +756,203 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { ); }); + describe("checkOpenClawProviderStatus", () => { + it.effect("reports unconfigured when no gateway URL is set", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus({ + enabled: true, + gatewayUrl: "", + authMode: "none", + hasSecret: false, + paired: false, + }); + assert.strictEqual(status.provider, "openclaw"); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.match(status.message ?? "", /gateway URL is not configured/); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("reports ready when the gateway probe succeeds", () => + Effect.gen(function* () { + let capturedAuth: unknown; + const status = yield* makeCheckOpenClawProviderStatus( + { + enabled: true, + gatewayUrl: "https://gateway.example.test/path?token=must-not-leak", + authMode: "token", + hasSecret: true, + paired: false, + }, + { + probe: (input) => { + capturedAuth = input.auth; + return Effect.succeed({ methods: ["chat.history", "chat.send", "chat.abort"] }); + }, + }, + ); + assert.strictEqual(status.provider, "openclaw"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "authenticated"); + assert.strictEqual(status.authType, "token"); + assert.deepStrictEqual(capturedAuth, { type: "token", token: "token-secret" }); + assert.strictEqual(/must-not-leak/.test(status.message ?? ""), false); + }).pipe(Effect.provide(openClawTokenSecretLayer)), + ); + + it.effect("does not report available when live gateway probing is unavailable", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus({ + enabled: true, + gatewayUrl: "https://gateway.example.test/path?token=must-not-leak", + authMode: "none", + hasSecret: false, + paired: false, + }); + assert.strictEqual(status.provider, "openclaw"); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.match(status.message ?? "", /probing is not configured/); + assert.strictEqual(/must-not-leak/.test(status.message ?? ""), false); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("live wrapper attempts the default gateway probe", () => + Effect.gen(function* () { + const status = yield* checkOpenClawProviderStatus({ + enabled: true, + gatewayUrl: "https://127.0.0.1:1/path?token=must-not-leak", + authMode: "none", + hasSecret: false, + paired: false, + }); + assert.strictEqual(status.provider, "openclaw"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.match(status.message ?? "", /gateway is unreachable/); + assert.strictEqual(/probing is not configured/.test(status.message ?? ""), false); + assert.strictEqual(/must-not-leak/.test(status.message ?? ""), false); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("reports pairing needed when device mode is not paired", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus({ + enabled: true, + gatewayUrl: "https://gateway.example.test", + authMode: "device", + hasSecret: false, + paired: false, + }); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.match(status.message ?? "", /pairing is required/); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("reports unauthenticated when token mode has no stored secret", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus({ + enabled: true, + gatewayUrl: "https://gateway.example.test", + authMode: "token", + hasSecret: false, + paired: false, + }); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.match(status.message ?? "", /token secret is not configured/); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("reports unreachable when the gateway probe fails", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus( + { + enabled: true, + gatewayUrl: "https://user:pass@gateway.example.test/path?token=must-not-leak", + authMode: "none", + hasSecret: false, + paired: false, + }, + { probe: () => Effect.fail(new Error("connection refused token=must-not-leak")) }, + ); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.match(status.message ?? "", /unreachable/); + assert.strictEqual(/must-not-leak|user:pass/.test(status.message ?? ""), false); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("reports unsupported when required chat methods are missing", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus( + { + enabled: true, + gatewayUrl: "https://gateway.example.test", + authMode: "none", + hasSecret: false, + paired: false, + }, + { probe: () => Effect.succeed({ methods: ["chat.history"] }) }, + ); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "authenticated"); + assert.match(status.message ?? "", /chat\.send/); + assert.match(status.message ?? "", /chat\.abort/); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("reports protocol mismatch when the gateway protocol is outside v1 support", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus( + { + enabled: true, + gatewayUrl: "https://gateway.example.test", + authMode: "none", + hasSecret: false, + paired: false, + }, + { + probe: () => + Effect.succeed({ + methods: ["chat.history", "chat.send", "chat.abort"], + protocolVersion: 5, + }), + }, + ); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.match(status.message ?? "", /protocol/i); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + + it.effect("rejects public insecure WebSocket gateway URLs", () => + Effect.gen(function* () { + const status = yield* makeCheckOpenClawProviderStatus({ + enabled: true, + gatewayUrl: "ws://gateway.example.test/path?token=must-not-leak", + authMode: "none", + hasSecret: false, + paired: false, + }); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.match(status.message ?? "", /requires wss/); + assert.strictEqual(/must-not-leak/.test(status.message ?? ""), false); + }).pipe(Effect.provide(openClawSecretLayer)), + ); + }); + describe("isExternalOpenCodeRuntimeActive", () => { it("does not treat the default OpenCode CLI runtime as external", () => { assert.strictEqual(isExternalOpenCodeRuntimeActive(DEFAULT_SERVER_SETTINGS), false); @@ -917,6 +1137,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { Effect.provide( ServerConfig.layerTest(process.cwd(), { prefix: "jcode-provider-health-" }), ), + Effect.provide(openClawSecretLayer), Effect.provide( mockSpawnerLayer((args, command, options) => { calls.push({ command, args, shell: options.shell }); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 47a27719..7f9ac59a 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -11,6 +11,7 @@ import * as OS from "node:os"; import * as nodePath from "node:path"; import type { + OpenClawServerProviderSettings, ProviderKind, ServerSettings, ServerProviderAuthStatus, @@ -51,8 +52,29 @@ import { } from "../codexCliVersion"; import { ServerConfig } from "../../config"; import { ServerSettingsService } from "../../serverSettings"; +import { ServerSecretStore } from "../../auth/Services/ServerSecretStore"; import { isWindowsShellCommandMissingResult } from "../../shell-command-detection"; import { normalizeGeminiCapabilityProbeResult, probeGeminiCapabilities } from "../geminiAcpProbe"; +import { + buildOpenClawChallengeResponse, + type OpenClawAuthFrame, + type OpenClawChallenge, + type OpenClawChallengeResponse, + type OpenClawDeviceFrame, + OPENCLAW_MAX_PROTOCOL_VERSION, + OPENCLAW_MIN_PROTOCOL_VERSION, + validateOpenClawMethodSupport, +} from "../openclawGatewayProtocol"; +import { + defaultOpenClawHealthProbeClient, + type OpenClawGatewayConnectInput, +} from "../openclawGatewayClient"; +import { normalizeOpenClawGatewayUrl } from "../openclawGatewayUrl"; +import { + deriveOpenClawDeviceId, + getOpenClawSecret, + getOpenClawSecretBytes, +} from "../openclawSecrets"; import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; import { orderProviderStatuses, @@ -79,6 +101,7 @@ const CURSOR_PROVIDER = "cursor" as const; const GEMINI_PROVIDER = "gemini" as const; const KILO_PROVIDER = "kilo" as const; const OPENCODE_PROVIDER = "opencode" as const; +const OPENCLAW_PROVIDER = "openclaw" as const; const PI_PROVIDER = "pi" as const; type ProviderStatuses = ReadonlyArray; @@ -89,6 +112,7 @@ const PROVIDERS = [ GEMINI_PROVIDER, KILO_PROVIDER, OPENCODE_PROVIDER, + OPENCLAW_PROVIDER, PI_PROVIDER, ] as const satisfies ReadonlyArray; @@ -1377,6 +1401,199 @@ export const makeCheckKiloProviderStatus = ( export const checkKiloProviderStatus = makeCheckKiloProviderStatus(); +export interface OpenClawHealthProbeResult { + readonly methods?: ReadonlyArray; + readonly protocolVersion?: number; +} + +export interface OpenClawHealthProbeClient { + readonly probe: ( + input: OpenClawGatewayConnectInput, + ) => Effect.Effect; +} + +const resolveOpenClawHealthProbeAuth = ( + authType: OpenClawServerProviderSettings["authMode"], +): Effect.Effect< + Pick, + unknown, + ServerSecretStore +> => + Effect.gen(function* () { + if (authType === "token") { + const token = yield* getOpenClawSecret("token"); + return token !== null ? { auth: { type: "token", token } } : {}; + } + if (authType === "password") { + const password = yield* getOpenClawSecret("password"); + return password !== null ? { auth: { type: "password", password } } : {}; + } + if (authType === "device") { + const deviceKey = yield* getOpenClawSecretBytes("deviceKey"); + if (deviceKey === null) { + return {}; + } + const deviceToken = yield* getOpenClawSecret("deviceToken"); + const deviceId = deriveOpenClawDeviceId(deviceKey); + return { + device: { + id: deviceId, + ...(deviceToken !== null ? { token: deviceToken } : {}), + }, + respondToChallenge: (challenge: OpenClawChallenge): OpenClawChallengeResponse => + buildOpenClawChallengeResponse({ + challenge, + deviceId, + deviceKey, + }), + }; + } + return {}; + }); + +export const makeCheckOpenClawProviderStatus = ( + settings: OpenClawServerProviderSettings, + probeClient?: OpenClawHealthProbeClient, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + const gatewayUrl = settings.gatewayUrl.trim(); + if (gatewayUrl.length === 0) { + return { + provider: OPENCLAW_PROVIDER, + status: "warning" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: "OpenClaw gateway URL is not configured.", + } satisfies ServerProviderStatus; + } + + let normalized: ReturnType; + try { + normalized = normalizeOpenClawGatewayUrl(gatewayUrl); + } catch (cause) { + return { + provider: OPENCLAW_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: cause instanceof Error ? cause.message : "OpenClaw gateway URL is invalid.", + } satisfies ServerProviderStatus; + } + const authType = settings.authMode; + if ((authType === "token" || authType === "password") && !settings.hasSecret) { + return { + provider: OPENCLAW_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unauthenticated" as const, + authType, + checkedAt, + message: `OpenClaw ${authType} secret is not configured.`, + } satisfies ServerProviderStatus; + } + if (authType === "device" && !settings.paired) { + return { + provider: OPENCLAW_PROVIDER, + status: "warning" as const, + available: false, + authStatus: "unauthenticated" as const, + authType, + checkedAt, + message: "OpenClaw device pairing is required before the gateway can be used.", + } satisfies ServerProviderStatus; + } + + if (!probeClient) { + return { + provider: OPENCLAW_PROVIDER, + status: "warning" as const, + available: false, + authStatus: authType === "none" ? ("unknown" as const) : ("authenticated" as const), + ...(authType !== "none" ? { authType } : {}), + checkedAt, + message: `OpenClaw gateway is configured at ${normalized.redactedUrl}. Live gateway probing is not configured.`, + } satisfies ServerProviderStatus; + } + + const probeAuthResult = yield* resolveOpenClawHealthProbeAuth(authType).pipe(Effect.result); + if (Result.isFailure(probeAuthResult)) { + return { + provider: OPENCLAW_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + ...(authType !== "none" ? { authType } : {}), + checkedAt, + message: "OpenClaw auth secret could not be read.", + } satisfies ServerProviderStatus; + } + const probeAuth = probeAuthResult.success; + const probe = yield* probeClient + .probe({ + websocketUrl: normalized.websocketUrl, + redactedGatewayUrl: normalized.redactedUrl, + ...probeAuth, + }) + .pipe(Effect.result); + if (Result.isFailure(probe)) { + return { + provider: OPENCLAW_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + ...(authType !== "none" ? { authType } : {}), + checkedAt, + message: `OpenClaw gateway is unreachable at ${normalized.redactedUrl}.`, + } satisfies ServerProviderStatus; + } + + const probeResult = probe.success; + if ( + probeResult.protocolVersion !== undefined && + (probeResult.protocolVersion < OPENCLAW_MIN_PROTOCOL_VERSION || + probeResult.protocolVersion > OPENCLAW_MAX_PROTOCOL_VERSION) + ) { + return { + provider: OPENCLAW_PROVIDER, + status: "warning" as const, + available: false, + authStatus: "unknown" as const, + ...(authType !== "none" ? { authType } : {}), + checkedAt, + message: `OpenClaw gateway protocol mismatch. JCode supports protocol v${OPENCLAW_MIN_PROTOCOL_VERSION}.`, + } satisfies ServerProviderStatus; + } + + const support = validateOpenClawMethodSupport(probeResult.methods); + if (!support.supported) { + return { + provider: OPENCLAW_PROVIDER, + status: "warning" as const, + available: false, + authStatus: "authenticated" as const, + ...(authType !== "none" ? { authType } : {}), + checkedAt, + message: `OpenClaw gateway is missing required methods: ${support.missing.join(", ")}.`, + } satisfies ServerProviderStatus; + } + + return { + provider: OPENCLAW_PROVIDER, + status: "ready" as const, + available: true, + authStatus: "authenticated" as const, + ...(authType !== "none" ? { authType } : {}), + checkedAt, + message: `OpenClaw gateway is ready at ${normalized.redactedUrl}.`, + } satisfies ServerProviderStatus; + }); + +export const checkOpenClawProviderStatus = (settings: OpenClawServerProviderSettings) => + makeCheckOpenClawProviderStatus(settings, defaultOpenClawHealthProbeClient); + // ── Pi health check ───────────────────────────────────────────── export const checkPiProviderStatus = ( @@ -1542,6 +1759,7 @@ export const ProviderHealthLive = Layer.effect( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* ServerConfig; const serverSettings = yield* ServerSettingsService; + const serverSecretStore = yield* ServerSecretStore; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, @@ -1643,6 +1861,15 @@ export const ProviderHealthLive = Layer.effect( updateLockKey: null, }); } + if (provider === "openclaw") { + return makeProviderMaintenanceCapabilities({ + provider, + packageName: null, + updateExecutable: null, + updateArgs: [], + updateLockKey: null, + }); + } const definition = PACKAGE_MANAGED_PROVIDER_UPDATES[provider]; if (!definition) { return makeProviderMaintenanceCapabilities({ @@ -1653,8 +1880,9 @@ export const ProviderHealthLive = Layer.effect( updateLockKey: null, }); } + const binaryPath = getProviderBinaryPath(provider, settings) || null; return yield* resolveProviderMaintenanceCapabilitiesEffect(definition, { - binaryPath: getProviderBinaryPath(provider, settings), + binaryPath, env: process.env, platform: process.platform, }).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); @@ -1745,6 +1973,9 @@ export const ProviderHealthLive = Layer.effect( makeCheckGeminiProviderStatus(settings.providers.gemini.binaryPath), makeCheckKiloProviderStatus(settings.providers.kilo.binaryPath), makeCheckOpenCodeProviderStatus(settings.providers.opencode.binaryPath), + checkOpenClawProviderStatus(settings.providers.openclaw).pipe( + Effect.provideService(ServerSecretStore, serverSecretStore), + ), checkPiProviderStatus( settings.providers.pi.agentDir, settings.providers.pi.binaryPath, diff --git a/apps/server/src/provider/openclawSecretUpdate.ts b/apps/server/src/provider/openclawSecretUpdate.ts new file mode 100644 index 00000000..7d2bb4c0 --- /dev/null +++ b/apps/server/src/provider/openclawSecretUpdate.ts @@ -0,0 +1,39 @@ +import type { ServerUpdateOpenClawSecretsInput } from "@jcode/contracts"; +import { Effect } from "effect"; + +import { ServerSecretStore, type SecretStoreError } from "../auth/Services/ServerSecretStore"; +import { + clearOpenClawDeviceIdentity, + clearOpenClawPairedToken, + clearOpenClawPassword, + clearOpenClawToken, + readOpenClawSecretMetadata, + rotateOpenClawDeviceKey, + setOpenClawPairedToken, + setOpenClawPassword, + setOpenClawToken, + type OpenClawSecretMetadata, +} from "./openclawSecrets"; + +export const applyOpenClawSecretUpdate = ( + input: ServerUpdateOpenClawSecretsInput, +): Effect.Effect => + Effect.gen(function* () { + if (input.token !== undefined) { + yield* input.token === null ? clearOpenClawToken : setOpenClawToken(input.token); + } + if (input.password !== undefined) { + yield* input.password === null ? clearOpenClawPassword : setOpenClawPassword(input.password); + } + if (input.clearDeviceIdentity === true) { + yield* clearOpenClawDeviceIdentity; + } else if (input.rotateDeviceKey === true) { + yield* rotateOpenClawDeviceKey; + } + if (input.deviceToken !== undefined) { + yield* input.deviceToken === null + ? clearOpenClawPairedToken + : setOpenClawPairedToken(input.deviceToken); + } + return yield* readOpenClawSecretMetadata; + }); diff --git a/apps/server/src/provider/openclawSecrets.test.ts b/apps/server/src/provider/openclawSecrets.test.ts new file mode 100644 index 00000000..10e3d20e --- /dev/null +++ b/apps/server/src/provider/openclawSecrets.test.ts @@ -0,0 +1,171 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, Layer } from "effect"; +import { describe, expect, it } from "vitest"; + +import { ServerSecretStore, type SecretStoreError } from "../auth/Services/ServerSecretStore"; +import { ServerSecretStoreLive } from "../auth/Layers/ServerSecretStore"; +import { ServerConfig } from "../config"; +import { applyOpenClawSecretUpdate } from "./openclawSecretUpdate"; +import { + OPENCLAW_SECRET_NAMES, + clearOpenClawAuthSecrets, + clearOpenClawDeviceIdentity, + clearOpenClawPairedToken, + deriveOpenClawDeviceId, + getOpenClawSecretBytes, + getOpenClawSecret, + readOpenClawSecretMetadata, + rotateOpenClawDeviceKey, + setOpenClawPassword, + setOpenClawPairedToken, + setOpenClawToken, +} from "./openclawSecrets"; + +const makeLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "jcode-openclaw-secrets-test-", + }), + ), + Layer.provide(NodeServices.layer), + ); + +const runWithSecretStore = (effect: Effect.Effect) => + effect.pipe(Effect.provide(makeLayer()), Effect.scoped, Effect.runPromise); + +describe("openclawSecrets", () => { + it("stores text secrets as UTF-8 bytes under deterministic names", async () => { + await runWithSecretStore( + Effect.gen(function* () { + const store = yield* ServerSecretStore; + yield* setOpenClawToken("token-secret"); + yield* setOpenClawPassword("pass-✓"); + + expect(Array.from((yield* store.get(OPENCLAW_SECRET_NAMES.token)) ?? [])).toEqual( + Array.from(new TextEncoder().encode("token-secret")), + ); + expect(Array.from((yield* store.get(OPENCLAW_SECRET_NAMES.password)) ?? [])).toEqual( + Array.from(new TextEncoder().encode("pass-✓")), + ); + }), + ); + }); + + it("stores auth secrets while exposing only redacted metadata", async () => { + await runWithSecretStore( + Effect.gen(function* () { + yield* setOpenClawToken("token-secret"); + yield* setOpenClawPassword("password-secret"); + + const metadata = yield* readOpenClawSecretMetadata; + + expect(metadata).toEqual({ + hasToken: true, + hasPassword: true, + hasDeviceKey: false, + hasDeviceToken: false, + paired: false, + }); + expect(Object.values(metadata)).not.toContain("token-secret"); + expect(yield* getOpenClawSecret("token")).toBe("token-secret"); + }), + ); + }); + + it("clears token and password secrets without touching device identity", async () => { + await runWithSecretStore( + Effect.gen(function* () { + yield* setOpenClawToken("token-secret"); + yield* setOpenClawPassword("password-secret"); + yield* rotateOpenClawDeviceKey; + yield* clearOpenClawAuthSecrets; + + const metadata = yield* readOpenClawSecretMetadata; + + expect(metadata.hasToken).toBe(false); + expect(metadata.hasPassword).toBe(false); + expect(metadata.hasDeviceKey).toBe(true); + }), + ); + }); + + it("rotates and clears device identity plus stale paired tokens", async () => { + await runWithSecretStore( + Effect.gen(function* () { + const firstKey = yield* rotateOpenClawDeviceKey; + yield* setOpenClawPairedToken("paired-token"); + const secondKey = yield* rotateOpenClawDeviceKey; + + let metadata = yield* readOpenClawSecretMetadata; + expect(Array.from(secondKey)).not.toEqual(Array.from(firstKey)); + expect(metadata.hasDeviceKey).toBe(true); + expect(metadata.hasDeviceToken).toBe(false); + expect(metadata.paired).toBe(false); + + yield* setOpenClawPairedToken("paired-token"); + yield* clearOpenClawPairedToken; + metadata = yield* readOpenClawSecretMetadata; + expect(metadata.hasDeviceToken).toBe(false); + expect(metadata.paired).toBe(false); + + yield* clearOpenClawDeviceIdentity; + metadata = yield* readOpenClawSecretMetadata; + expect(metadata.hasDeviceKey).toBe(false); + expect(metadata.hasDeviceToken).toBe(false); + }), + ); + }); + + it("reads device keys as binary bytes and derives a stable non-secret device id", async () => { + await runWithSecretStore( + Effect.gen(function* () { + const key = yield* rotateOpenClawDeviceKey; + const storedKey = yield* getOpenClawSecretBytes("deviceKey"); + + expect(storedKey).not.toBeNull(); + expect(Array.from(storedKey ?? [])).toEqual(Array.from(key)); + expect(yield* getOpenClawSecret("deviceKey")).not.toBe(deriveOpenClawDeviceId(key)); + expect(deriveOpenClawDeviceId(key)).toBe(deriveOpenClawDeviceId(key)); + }), + ); + }); + + it("applies secret updates while returning only metadata", async () => { + await runWithSecretStore( + Effect.gen(function* () { + let metadata = yield* applyOpenClawSecretUpdate({ + token: "token-secret", + password: "password-secret", + rotateDeviceKey: true, + deviceToken: "paired-token", + }); + + expect(metadata).toEqual({ + hasToken: true, + hasPassword: true, + hasDeviceKey: true, + hasDeviceToken: true, + paired: true, + }); + expect(Object.values(metadata)).not.toContain("token-secret"); + expect(yield* getOpenClawSecret("token")).toBe("token-secret"); + expect(yield* getOpenClawSecret("password")).toBe("password-secret"); + + metadata = yield* applyOpenClawSecretUpdate({ + token: null, + password: null, + clearDeviceIdentity: true, + }); + + expect(metadata).toEqual({ + hasToken: false, + hasPassword: false, + hasDeviceKey: false, + hasDeviceToken: false, + paired: false, + }); + }), + ); + }); +}); diff --git a/apps/server/src/provider/openclawSecrets.ts b/apps/server/src/provider/openclawSecrets.ts new file mode 100644 index 00000000..0dc64161 --- /dev/null +++ b/apps/server/src/provider/openclawSecrets.ts @@ -0,0 +1,121 @@ +import * as Crypto from "node:crypto"; + +import { Effect } from "effect"; + +import { ServerSecretStore, type SecretStoreError } from "../auth/Services/ServerSecretStore"; + +export const OPENCLAW_SECRET_NAMES = { + token: "provider.openclaw.token", + password: "provider.openclaw.password", + deviceKey: "provider.openclaw.device-key", + deviceToken: "provider.openclaw.device-token", +} as const; + +export type OpenClawSecretKind = keyof typeof OPENCLAW_SECRET_NAMES; + +export interface OpenClawSecretMetadata { + readonly hasToken: boolean; + readonly hasPassword: boolean; + readonly hasDeviceKey: boolean; + readonly hasDeviceToken: boolean; + readonly paired: boolean; +} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +function encodeSecret(value: string): Uint8Array { + return textEncoder.encode(value); +} + +function decodeSecret(value: Uint8Array): string { + return textDecoder.decode(value); +} + +export const getOpenClawSecret = ( + kind: OpenClawSecretKind, +): Effect.Effect => + Effect.gen(function* () { + const store = yield* ServerSecretStore; + const value = yield* store.get(OPENCLAW_SECRET_NAMES[kind]); + return value ? decodeSecret(value) : null; + }); + +export const getOpenClawSecretBytes = ( + kind: OpenClawSecretKind, +): Effect.Effect => + Effect.gen(function* () { + const store = yield* ServerSecretStore; + return yield* store.get(OPENCLAW_SECRET_NAMES[kind]); + }); + +export function deriveOpenClawDeviceId(deviceKey: Uint8Array): string { + const digest = Crypto.createHash("sha256").update(deviceKey).digest("base64url"); + return `jcode:${digest}`; +} + +const setOpenClawTextSecret = (kind: OpenClawSecretKind, value: string) => + Effect.gen(function* () { + const store = yield* ServerSecretStore; + yield* store.set(OPENCLAW_SECRET_NAMES[kind], encodeSecret(value)); + }); + +const removeOpenClawSecret = (kind: OpenClawSecretKind) => + Effect.gen(function* () { + const store = yield* ServerSecretStore; + yield* store.remove(OPENCLAW_SECRET_NAMES[kind]); + }); + +export const setOpenClawToken = (value: string) => setOpenClawTextSecret("token", value); + +export const setOpenClawPassword = (value: string) => setOpenClawTextSecret("password", value); + +export const clearOpenClawToken = removeOpenClawSecret("token"); + +export const clearOpenClawPassword = removeOpenClawSecret("password"); + +export const setOpenClawPairedToken = (value: string) => + setOpenClawTextSecret("deviceToken", value); + +export const clearOpenClawAuthSecrets = Effect.all([ + clearOpenClawToken, + clearOpenClawPassword, +]).pipe(Effect.asVoid); + +export const clearOpenClawPairedToken = removeOpenClawSecret("deviceToken"); + +export const clearOpenClawDeviceIdentity = Effect.all([ + removeOpenClawSecret("deviceKey"), + removeOpenClawSecret("deviceToken"), +]).pipe(Effect.asVoid); + +export const rotateOpenClawDeviceKey: Effect.Effect< + Uint8Array, + SecretStoreError, + ServerSecretStore +> = Effect.gen(function* () { + const store = yield* ServerSecretStore; + const key = Uint8Array.from(Crypto.randomBytes(32)); + yield* store.set(OPENCLAW_SECRET_NAMES.deviceKey, key); + yield* store.remove(OPENCLAW_SECRET_NAMES.deviceToken); + return key; +}); + +export const readOpenClawSecretMetadata: Effect.Effect< + OpenClawSecretMetadata, + SecretStoreError, + ServerSecretStore +> = Effect.gen(function* () { + const store = yield* ServerSecretStore; + const token = yield* store.get(OPENCLAW_SECRET_NAMES.token); + const password = yield* store.get(OPENCLAW_SECRET_NAMES.password); + const deviceKey = yield* store.get(OPENCLAW_SECRET_NAMES.deviceKey); + const deviceToken = yield* store.get(OPENCLAW_SECRET_NAMES.deviceToken); + return { + hasToken: token !== null, + hasPassword: password !== null, + hasDeviceKey: deviceKey !== null, + hasDeviceToken: deviceToken !== null, + paired: deviceToken !== null, + }; +}); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index fc98aa0d..62877a32 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,18 +1,36 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_MODEL_BY_PROVIDER } from "@jcode/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, type ServerSettingsPatch } from "@jcode/contracts"; import { Effect, FileSystem, Layer } from "effect"; import { describe, expect, it } from "vitest"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore"; +import { ServerSecretStore } from "./auth/Services/ServerSecretStore"; import { ServerConfig } from "./config"; +import { + readOpenClawSecretMetadata, + rotateOpenClawDeviceKey, + setOpenClawPairedToken, + setOpenClawPassword, + setOpenClawToken, +} from "./provider/openclawSecrets"; import { ServerSettingsLive, ServerSettingsService } from "./serverSettings"; const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "jcode-settings-test-", }).pipe(Layer.provide(NodeServices.layer)); const makeTestLayer = Layer.merge(NodeServices.layer, serverConfigLayer); -const testLayer = Layer.merge(makeTestLayer, ServerSettingsLive.pipe(Layer.provide(makeTestLayer))); +const secretStoreLayer = ServerSecretStoreLive.pipe(Layer.provide(makeTestLayer)); +const serviceDependenciesLayer = Layer.merge(makeTestLayer, secretStoreLayer); +const testLayer = Layer.merge( + serviceDependenciesLayer, + ServerSettingsLive.pipe(Layer.provide(serviceDependenciesLayer)), +); const runWithSettings = ( - effect: Effect.Effect, + effect: Effect.Effect< + A, + E, + ServerSettingsService | ServerConfig | FileSystem.FileSystem | ServerSecretStore + >, ) => Effect.runPromise(effect.pipe(Effect.provide(testLayer)) as Effect.Effect); describe("ServerSettingsService", () => { @@ -66,6 +84,52 @@ describe("ServerSettingsService", () => { }); }); + it("persists only non-secret OpenClaw settings metadata", async () => { + const result = await runWithSettings( + Effect.gen(function* () { + const service = yield* ServerSettingsService; + const { settingsPath } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + yield* service.start; + + const patch = { + providers: { + openclaw: { + gatewayUrl: "ws://user:pass@127.0.0.1:18789/path?token=secret#fragment", + authMode: "token", + hasSecret: true, + paired: false, + token: "token-secret", + password: "password-secret", + }, + }, + } as unknown as ServerSettingsPatch; + + const updated = yield* service.updateSettings(patch); + const raw = yield* fs.readFileString(settingsPath); + return { updated, raw, parsed: JSON.parse(raw) as unknown }; + }), + ); + + expect(result.updated.providers.openclaw).toEqual({ + enabled: true, + gatewayUrl: "ws://127.0.0.1:18789/path", + authMode: "token", + hasSecret: false, + paired: false, + }); + expect(result.raw).not.toContain("token-secret"); + expect(result.raw).not.toContain("password-secret"); + expect(result.parsed).toMatchObject({ + providers: { + openclaw: { + gatewayUrl: "ws://127.0.0.1:18789/path", + authMode: "token", + }, + }, + }); + }); + it("resolves text generation selection away from disabled providers", async () => { const settings = await Effect.runPromise( Effect.gen(function* () { @@ -90,6 +154,138 @@ describe("ServerSettingsService", () => { expect(settings.textGenerationModelSelection.model).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); }); + it("persists sanitized OpenClaw gateway URLs", async () => { + const result = await runWithSettings( + Effect.gen(function* () { + const service = yield* ServerSettingsService; + const { settingsPath } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + yield* service.start; + + const updated = yield* service.updateSettings({ + providers: { + openclaw: { + gatewayUrl: "https://user:secret@gateway.example.test/path?token=must-not-leak#hash", + }, + }, + }); + const raw = yield* fs.readFileString(settingsPath); + return { updated, raw }; + }), + ); + + expect(result.updated.providers.openclaw.gatewayUrl).toBe("https://gateway.example.test/path"); + expect(result.raw).toContain("https://gateway.example.test/path"); + expect(result.raw).not.toContain("user"); + expect(result.raw).not.toContain("secret"); + expect(result.raw).not.toContain("must-not-leak"); + }); + + it("sanitizes legacy credential-bearing OpenClaw gateway URLs when loading settings", async () => { + const settings = await runWithSettings( + Effect.gen(function* () { + const service = yield* ServerSettingsService; + const { settingsPath } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(settingsPath.slice(0, settingsPath.lastIndexOf("/")), { + recursive: true, + }); + yield* fs.writeFileString( + settingsPath, + `${JSON.stringify({ + providers: { + openclaw: { + gatewayUrl: "ws://user:pass@gateway.example.test/path?token=must-not-leak#fragment", + authMode: "token", + hasSecret: true, + paired: true, + }, + }, + })}\n`, + ); + + yield* service.start; + return yield* service.getSettings; + }), + ); + + expect(settings.providers.openclaw).toEqual({ + enabled: true, + gatewayUrl: "ws://gateway.example.test/path", + authMode: "token", + hasSecret: false, + paired: false, + }); + }); + + it("clears OpenClaw secrets and metadata when the gateway URL changes", async () => { + const result = await runWithSettings( + Effect.gen(function* () { + const service = yield* ServerSettingsService; + yield* service.start; + + yield* service.updateSettings({ + providers: { openclaw: { gatewayUrl: "https://gateway.example.test/path" } }, + }); + yield* setOpenClawToken("token-secret"); + yield* setOpenClawPassword("password-secret"); + yield* rotateOpenClawDeviceKey; + yield* setOpenClawPairedToken("device-token-secret"); + + yield* service.updateOpenClawSecretMetadata({ hasSecret: true, paired: true }); + const settings = yield* service.updateSettings({ + providers: { openclaw: { gatewayUrl: "https://other-gateway.example.test/path" } }, + }); + + const metadata = yield* readOpenClawSecretMetadata; + return { metadata, settings }; + }), + ); + + expect(result.metadata).toEqual({ + hasToken: false, + hasPassword: false, + hasDeviceKey: false, + hasDeviceToken: false, + paired: false, + }); + expect(result.settings.providers.openclaw.hasSecret).toBe(false); + expect(result.settings.providers.openclaw.paired).toBe(false); + }); + + it("rehydrates OpenClaw secret metadata from the server secret store on startup", async () => { + const settings = await runWithSettings( + Effect.gen(function* () { + yield* setOpenClawToken("token-secret"); + yield* rotateOpenClawDeviceKey; + yield* setOpenClawPairedToken("device-token-secret"); + + const service = yield* ServerSettingsService; + yield* service.start; + return yield* service.getSettings; + }), + ); + + expect(settings.providers.openclaw.hasSecret).toBe(true); + expect(settings.providers.openclaw.paired).toBe(true); + }); + + it("persists server-owned OpenClaw secret metadata", async () => { + const settings = await runWithSettings( + Effect.gen(function* () { + const service = yield* ServerSettingsService; + yield* service.start; + return yield* service.updateOpenClawSecretMetadata({ + hasSecret: true, + paired: true, + }); + }), + ); + + expect(settings.providers.openclaw.hasSecret).toBe(true); + expect(settings.providers.openclaw.paired).toBe(true); + }); + it("persists OpenCode runtime profiles", async () => { const settings = await runWithSettings( Effect.gen(function* () { diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index deacf863..6cfa59d5 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -31,7 +31,13 @@ import { Stream, } from "effect"; import * as Semaphore from "effect/Semaphore"; +import { ServerSecretStore } from "./auth/Services/ServerSecretStore"; import { ServerConfig } from "./config"; +import { + clearOpenClawAuthSecrets, + clearOpenClawDeviceIdentity, + readOpenClawSecretMetadata, +} from "./provider/openclawSecrets"; export interface ServerSettingsShape { readonly start: Effect.Effect; @@ -40,6 +46,10 @@ export interface ServerSettingsShape { readonly updateSettings: ( patch: ServerSettingsPatch, ) => Effect.Effect; + readonly updateOpenClawSecretMetadata: (metadata: { + readonly hasSecret: boolean; + readonly paired: boolean; + }) => Effect.Effect; readonly streamChanges: Stream.Stream; } @@ -71,6 +81,15 @@ export class ServerSettingsService extends ServiceMap.Service< Effect.tap(emitChange), Effect.map(resolveTextGenerationProvider), ), + updateOpenClawSecretMetadata: (metadata) => + Ref.get(currentSettingsRef).pipe( + Effect.map((currentSettings) => + withOpenClawSecretMetadata(currentSettings, metadata), + ), + Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), + Effect.tap(emitChange), + Effect.map(resolveTextGenerationProvider), + ), get streamChanges() { return Stream.fromPubSub(changesPubSub).pipe(Stream.map(resolveTextGenerationProvider)); }, @@ -107,6 +126,22 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings }; } +function withOpenClawSecretMetadata( + settings: ServerSettings, + metadata: { readonly hasSecret: boolean; readonly paired: boolean }, +): ServerSettings { + return { + ...settings, + providers: { + ...settings.providers, + openclaw: { + ...settings.providers.openclaw, + ...metadata, + }, + }, + }; +} + function normalizeSettings( settingsPath: string, current: ServerSettings, @@ -143,6 +178,7 @@ function decodeSettingsFromJson(settingsPath: string, raw: string) { const makeServerSettings = Effect.gen(function* () { const { settingsPath } = yield* ServerConfig; + const secretStore = yield* ServerSecretStore; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const writeSemaphore = yield* Semaphore.make(1); @@ -187,7 +223,7 @@ const makeServerSettings = Effect.gen(function* () { }); return DEFAULT_SERVER_SETTINGS; } - return decoded.value; + return applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, decoded.value as ServerSettingsPatch); }); const writeSettingsAtomically = (settings: ServerSettings) => { @@ -208,6 +244,27 @@ const makeServerSettings = Effect.gen(function* () { ); }; + const clearOpenClawSecretsIfGatewayChanged = ( + current: ServerSettings, + next: ServerSettings, + ): Effect.Effect< + { + readonly hasSecret: boolean; + readonly paired: boolean; + } | null, + unknown + > => { + if (current.providers.openclaw.gatewayUrl === next.providers.openclaw.gatewayUrl) { + return Effect.succeed(null); + } + return Effect.all([clearOpenClawAuthSecrets, clearOpenClawDeviceIdentity], { + discard: true, + }).pipe( + Effect.as({ hasSecret: false, paired: false }), + Effect.provideService(ServerSecretStore, secretStore), + ); + }; + const start = Effect.gen(function* () { const shouldStart = yield* Ref.modify(startedRef, (started) => [!started, true]); if (!shouldStart) { @@ -225,7 +282,22 @@ const makeServerSettings = Effect.gen(function* () { }), ), ); - const settings = yield* loadSettingsFromDisk; + const loadedSettings = yield* loadSettingsFromDisk; + const metadata = yield* readOpenClawSecretMetadata.pipe( + Effect.provideService(ServerSecretStore, secretStore), + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to read OpenClaw secret metadata", + cause, + }), + ), + ); + const settings = withOpenClawSecretMetadata(loadedSettings, { + hasSecret: metadata.hasToken || metadata.hasPassword, + paired: metadata.paired, + }); yield* Ref.set(settingsRef, settings); }); @@ -247,6 +319,29 @@ const makeServerSettings = Effect.gen(function* () { Effect.gen(function* () { const current = yield* Ref.get(settingsRef); const next = yield* normalizeSettings(settingsPath, current, patch); + const metadata = yield* clearOpenClawSecretsIfGatewayChanged(current, next).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to clear OpenClaw secrets after gateway URL change", + cause, + }), + ), + ); + const nextWithMetadata = + metadata !== null ? withOpenClawSecretMetadata(next, metadata) : next; + yield* writeSettingsAtomically(nextWithMetadata); + yield* Ref.set(settingsRef, nextWithMetadata); + yield* emitChange(nextWithMetadata); + return resolveTextGenerationProvider(nextWithMetadata); + }), + ), + updateOpenClawSecretMetadata: (metadata) => + writeSemaphore.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(settingsRef); + const next = withOpenClawSecretMetadata(current, metadata); yield* writeSettingsAtomically(next); yield* Ref.set(settingsRef, next); yield* emitChange(next); diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index f3f32757..4bd4fa28 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -39,6 +39,7 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { authErrorResponse, makeEffectAuthRequest } from "./auth/http"; import { BootstrapCredentialService } from "./auth/Services/BootstrapCredentialService"; import { ServerAuth } from "./auth/Services/ServerAuth"; +import { ServerSecretStore } from "./auth/Services/ServerSecretStore"; import { SessionCredentialService } from "./auth/Services/SessionCredentialService"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; import { ServerConfig } from "./config"; @@ -61,6 +62,7 @@ import { OpenCodeRuntimeLive, } from "./provider/opencodeRuntime"; import { checkOpenCodeRuntimeHealth } from "./provider/openCodeRuntimeHealth"; +import { applyOpenClawSecretUpdate } from "./provider/openclawSecretUpdate"; import { getProviderUsageSnapshot } from "./providerUsageSnapshot"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; import { ServerLifecycleEvents } from "./serverLifecycleEvents"; @@ -254,6 +256,7 @@ export const makeWsRpcLayer = () => const runtimeStartup = yield* ServerRuntimeStartup; const serverEnvironment = yield* ServerEnvironment; const serverSettings = yield* ServerSettingsService; + const serverSecretStore = yield* ServerSecretStore; const sessionCredentials = yield* SessionCredentialService; const terminalManager = yield* TerminalManager; const workspaceEntries = yield* WorkspaceEntries; @@ -649,6 +652,20 @@ export const makeWsRpcLayer = () => rpcEffect(serverSettings.getSettings, "Failed to load server settings"), [WS_METHODS.serverUpdateSettings]: (input) => rpcEffect(serverSettings.updateSettings(input), "Failed to update server settings"), + [WS_METHODS.serverUpdateOpenClawSecrets]: (input) => + rpcEffect( + Effect.gen(function* () { + const metadata = yield* applyOpenClawSecretUpdate(input).pipe( + Effect.provideService(ServerSecretStore, serverSecretStore), + ); + yield* serverSettings.updateOpenClawSecretMetadata({ + hasSecret: metadata.hasToken || metadata.hasPassword, + paired: metadata.paired, + }); + return metadata; + }), + "Failed to update OpenClaw secrets", + ), [WS_METHODS.serverRefreshProviders]: () => rpcEffect( providerHealth.refresh.pipe(Effect.map((providers) => ({ providers }))), From 5568ff5cf63d0825c15ee29afca72e0c16b3c502 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 5 Jun 2026 21:33:40 -0400 Subject: [PATCH 05/14] feat(web): wire OpenClaw provider UX --- apps/web/src/appSettings.test.ts | 129 +++- apps/web/src/appSettings.ts | 53 +- apps/web/src/components/ChatView.tsx | 33 +- apps/web/src/components/PluginLibrary.tsx | 5 + apps/web/src/components/Sidebar.tsx | 6 +- .../SkillLibrarySettingsPanel.test.ts | 56 ++ .../components/SkillLibrarySettingsPanel.tsx | 70 ++- .../CompactComposerControlsMenu.browser.tsx | 16 +- .../chat/ProviderModelPicker.browser.tsx | 1 + .../components/chat/ProviderModelPicker.tsx | 2 + .../components/chat/TraitsPicker.browser.tsx | 2 + .../chat/composerProviderRegistry.tsx | 29 +- apps/web/src/composerDraftStore.test.ts | 43 +- apps/web/src/composerDraftStore.ts | 44 +- apps/web/src/hooks/useHandleNewThread.ts | 6 +- apps/web/src/lib/threadHandoff.test.ts | 35 ++ apps/web/src/lib/threadHandoff.ts | 10 +- apps/web/src/providerModelOptions.ts | 8 + apps/web/src/providerOrdering.test.ts | 46 +- apps/web/src/providerOrdering.ts | 2 + .../routes/-_chat.settings.install.test.ts | 32 + apps/web/src/routes/_chat.settings.tsx | 585 ++++++++++++++++-- apps/web/src/session-logic.test.ts | 7 + apps/web/src/session-logic.ts | 1 + apps/web/src/store.test.ts | 38 ++ apps/web/src/store.ts | 5 +- apps/web/src/wsNativeApi.test.ts | 7 + apps/web/src/wsNativeApi.ts | 2 + 28 files changed, 1125 insertions(+), 148 deletions(-) create mode 100644 apps/web/src/components/SkillLibrarySettingsPanel.test.ts diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index b84012af..5532b84c 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -143,6 +143,18 @@ describe("getGitTextGenerationModelOptions", () => { isCustom: true, }); }); + + it("does not add OpenClaw gateway to git-writing model options", () => { + const options = getGitTextGenerationModelOptions({ + customCodexModels: [], + customKiloModels: [], + customOpenCodeModels: [], + textGenerationModel: "gateway", + textGenerationProvider: "openclaw", + }); + + expect(options.some((option) => option.provider === "openclaw")).toBe(false); + }); }); describe("resolveAppModelSelection", () => { @@ -157,6 +169,7 @@ describe("resolveAppModelSelection", () => { gemini: [], kilo: [], opencode: [], + openclaw: [], pi: [], }, "galapagos-alpha", @@ -168,7 +181,16 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: [], claudeAgent: [], cursor: [], gemini: [], kilo: [], opencode: [], pi: [] }, + { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + kilo: [], + opencode: [], + openclaw: [], + pi: [], + }, "", ), ).toBe("gpt-5.5"); @@ -178,7 +200,16 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: [], claudeAgent: [], cursor: [], gemini: [], kilo: [], opencode: [], pi: [] }, + { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + kilo: [], + opencode: [], + openclaw: [], + pi: [], + }, "GPT-5.3 Codex", ), ).toBe("gpt-5.3-codex"); @@ -188,7 +219,16 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "claudeAgent", - { codex: [], claudeAgent: [], cursor: [], gemini: [], kilo: [], opencode: [], pi: [] }, + { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + kilo: [], + opencode: [], + openclaw: [], + pi: [], + }, "sonnet", ), ).toBe("claude-sonnet-4-6"); @@ -198,7 +238,16 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: [], claudeAgent: [], cursor: [], gemini: [], kilo: [], opencode: [], pi: [] }, + { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + kilo: [], + opencode: [], + openclaw: [], + pi: [], + }, "custom/selected-model", ), ).toBe("custom/selected-model"); @@ -298,6 +347,13 @@ describe("server-backed app settings", () => { activeRuntimeProfileId: "", customModels: [], }, + openclaw: { + enabled: true, + gatewayUrl: "ws://127.0.0.1:18789", + authMode: "device", + hasSecret: true, + paired: false, + }, pi: { enabled: true, binaryPath: "pi", agentDir: "", customModels: [] }, }, textGenerationModelSelection: { provider: "codex", model: "gpt-5.4" }, @@ -305,6 +361,10 @@ describe("server-backed app settings", () => { ), ).toMatchObject({ addProjectBaseDirectory: "/home/jay/code", + openClawAuthMode: "device", + openClawGatewayUrl: "ws://127.0.0.1:18789", + openClawHasSecret: true, + openClawPaired: false, }); }); @@ -341,6 +401,13 @@ describe("server-backed app settings", () => { activeRuntimeProfileId: "", customModels: [], }, + openclaw: { + enabled: true, + gatewayUrl: "", + authMode: "none", + hasSecret: false, + paired: false, + }, pi: { enabled: true, binaryPath: "pi", agentDir: "", customModels: [] }, }, textGenerationModelSelection: { provider: "codex", model: "gpt-5.4" }, @@ -354,6 +421,24 @@ describe("server-backed app settings", () => { appSettingsPatchToServerSettingsPatch({ addProjectBaseDirectory: "/home/jay/code" }), ).toEqual({ addProjectBaseDirectory: "/home/jay/code" }); }); + + it("does not map OpenClaw server-derived metadata to server settings patches", () => { + expect( + appSettingsPatchToServerSettingsPatch({ + openClawGatewayUrl: "https://gateway.example.test", + openClawAuthMode: "token", + openClawHasSecret: true, + openClawPaired: false, + }), + ).toEqual({ + providers: { + openclaw: { + gatewayUrl: "https://gateway.example.test", + authMode: "token", + }, + }, + }); + }); }); describe("provider-specific custom models", () => { @@ -381,6 +466,7 @@ describe("getProviderStartOptions", () => { openCodeBinaryPath: "", openCodeServerPassword: "", openCodeServerUrl: "", + openClawGatewayUrl: "ws://127.0.0.1:18789", piAgentDir: "", piBinaryPath: "", }), @@ -402,6 +488,29 @@ describe("getProviderStartOptions", () => { }); }); + it("does not include only OpenClaw gateway URLs in provider start options", () => { + expect( + getProviderStartOptions({ + claudeBinaryPath: "", + codexBinaryPath: "", + codexHomePath: "", + codexLaunchArgs: "", + cursorApiEndpoint: "", + cursorBinaryPath: "", + geminiBinaryPath: "", + kiloBinaryPath: "", + kiloServerPassword: "", + kiloServerUrl: "", + openCodeBinaryPath: "", + openCodeServerPassword: "", + openCodeServerUrl: "", + openClawGatewayUrl: "https://gateway.example.test", + piAgentDir: "", + piBinaryPath: "", + }), + ).toBeUndefined(); + }); + it("returns undefined when no provider overrides are configured", () => { expect( getProviderStartOptions({ @@ -418,6 +527,7 @@ describe("getProviderStartOptions", () => { openCodeBinaryPath: "", openCodeServerPassword: "", openCodeServerUrl: "", + openClawGatewayUrl: "", piAgentDir: "", piBinaryPath: "", }), @@ -436,7 +546,7 @@ describe("provider-indexed custom model settings", () => { customPiModels: ["anthropic/custom-pi"], } as const; - it("exports one provider config per provider", () => { + it("exports custom model configs only for providers that support custom models", () => { expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider)).toEqual([ "codex", "claudeAgent", @@ -446,6 +556,9 @@ describe("provider-indexed custom model settings", () => { "opencode", "pi", ]); + expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider as string)).not.toContain( + "openclaw", + ); }); it("reads custom models for each provider", () => { @@ -530,6 +643,7 @@ describe("provider-indexed custom model settings", () => { gemini: ["gemini/custom-flash"], kilo: ["kilo/kilo-auto/free"], opencode: ["openrouter/gpt-oss-120b"], + openclaw: [], pi: ["anthropic/custom-pi"], }); }); @@ -604,6 +718,7 @@ describe("provider-indexed custom model settings", () => { expect( modelOptionsByProvider.opencode.filter((option) => option.slug === "openrouter/gpt-oss-120b"), ).toHaveLength(1); + expect(modelOptionsByProvider.openclaw).toEqual([]); expect( modelOptionsByProvider.pi.filter((option) => option.slug === "anthropic/custom-pi"), ).toHaveLength(1); @@ -642,6 +757,10 @@ describe("AppSettingsSchema", () => { customKiloModels: [], customOpenCodeModels: [], customPiModels: [], + openClawAuthMode: "none", + openClawGatewayUrl: "", + openClawHasSecret: false, + openClawPaired: false, addProjectBaseDirectory: "", }); }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index a0d38acf..b4c79c60 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -4,6 +4,7 @@ import { Option, Schema } from "effect"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL, DEFAULT_SERVER_SETTINGS, + OpenClawAuthMode, TrimmedNonEmptyString, ProviderKind, type ProviderStartOptions, @@ -30,6 +31,7 @@ import { serverQueryKeys, serverSettingsQueryOptions } from "./lib/serverReactQu const APP_SETTINGS_STORAGE_KEY = "jcode:app-settings:v1"; const SERVER_SETTINGS_MIGRATION_STORAGE_KEY = "t3code:server-settings-migrated:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; +const GIT_TEXT_GENERATION_PROVIDERS = new Set(["codex", "kilo", "opencode"]); export const MAX_CUSTOM_MODEL_LENGTH = 256; export const MIN_CHAT_FONT_SIZE_PX = 11; export const MAX_CHAT_FONT_SIZE_PX = 18; @@ -60,8 +62,9 @@ type CustomModelSettingsKey = | "customKiloModels" | "customOpenCodeModels" | "customPiModels"; +export type CustomModelProviderKind = Exclude; export type ProviderCustomModelConfig = { - provider: ProviderKind; + provider: CustomModelProviderKind; settingsKey: CustomModelSettingsKey; defaultSettingsKey: CustomModelSettingsKey; title: string; @@ -77,6 +80,7 @@ const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record gemini: new Set(getModelOptions("gemini").map((option) => option.slug)), kilo: new Set(getModelOptions("kilo").map((option) => option.slug)), opencode: new Set(getModelOptions("opencode").map((option) => option.slug)), + openclaw: new Set(["gateway"]), pi: new Set(getModelOptions("pi").map((option) => option.slug)), }; @@ -116,6 +120,11 @@ export const AppSettingsSchema = Schema.Struct({ openCodeServerPassword: Schema.String.check(Schema.isMaxLength(4096)).pipe( withDefaults(() => ""), ), + openClawGatewayUrl: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + openClawEnabled: Schema.Boolean.pipe(withDefaults(() => true)), + openClawAuthMode: OpenClawAuthMode.pipe(withDefaults(() => "none" as const)), + openClawHasSecret: Schema.Boolean.pipe(withDefaults(() => false)), + openClawPaired: Schema.Boolean.pipe(withDefaults(() => false)), defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), confirmThreadArchive: Schema.Boolean.pipe(withDefaults(() => false)), @@ -172,7 +181,7 @@ export interface AppModelOption extends ProviderModelOption { const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); let serverSettingsMigrationInFlight = false; -const PROVIDER_CUSTOM_MODEL_CONFIG: Record = { +const PROVIDER_CUSTOM_MODEL_CONFIG: Record = { codex: { provider: "codex", settingsKey: "customCodexModels", @@ -312,6 +321,11 @@ export function serverSettingsToAppSettings(settings: ServerSettings): Partial, - provider: ProviderKind, + provider: CustomModelProviderKind, ): readonly string[] { return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]; } export function getDefaultCustomModelsForProvider( defaults: Pick, - provider: ProviderKind, + provider: CustomModelProviderKind, ): readonly string[] { return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey]; } export function patchCustomModels( - provider: ProviderKind, + provider: CustomModelProviderKind, models: string[], ): Partial> { return { @@ -553,6 +583,7 @@ export function getCustomModelsByProvider( gemini: getCustomModelsForProvider(settings, "gemini"), kilo: getCustomModelsForProvider(settings, "kilo"), opencode: getCustomModelsForProvider(settings, "opencode"), + openclaw: [], pi: getCustomModelsForProvider(settings, "pi"), }; } @@ -636,7 +667,11 @@ export function getGitTextGenerationModelOptions( const selectedProvider = settings.textGenerationProvider ?? resolveTextGenerationProvider(selectedModel !== undefined ? { model: selectedModel } : {}); - if (selectedModel && !seen.has(`${selectedProvider}:${selectedModel}`)) { + if ( + selectedModel && + GIT_TEXT_GENERATION_PROVIDERS.has(selectedProvider) && + !seen.has(`${selectedProvider}:${selectedModel}`) + ) { deduped.push({ provider: selectedProvider, slug: selectedModel, @@ -671,6 +706,7 @@ export function getCustomModelOptionsByProvider( gemini: getAppModelOptions("gemini", customModelsByProvider.gemini), kilo: getAppModelOptions("kilo", customModelsByProvider.kilo), opencode: getAppModelOptions("opencode", customModelsByProvider.opencode), + openclaw: [], pi: getAppModelOptions("pi", customModelsByProvider.pi), }; } @@ -691,6 +727,7 @@ export function getProviderStartOptions( | "openCodeBinaryPath" | "openCodeServerPassword" | "openCodeServerUrl" + | "openClawGatewayUrl" | "piAgentDir" | "piBinaryPath" >, @@ -786,6 +823,8 @@ export function getCustomBinaryPathForProvider( return settings.kiloBinaryPath; case "opencode": return settings.openCodeBinaryPath; + case "openclaw": + return ""; case "pi": return settings.piBinaryPath; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 720694cf..10627d73 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -371,6 +371,7 @@ import { buildNextProviderOptions, formatProviderModelOptionName, type ProviderModelOption, + type ProviderOptions, } from "../providerModelOptions"; import { isDuplicateProjectCreateError, @@ -465,6 +466,8 @@ function getProviderStartOptionsCustomBinaryPath( return normalizeCustomBinaryPath(providerOptions?.cursor?.binaryPath); case "pi": return normalizeCustomBinaryPath(providerOptions?.pi?.binaryPath); + case "openclaw": + return null; } } @@ -481,6 +484,14 @@ function getProviderHealthBannerDismissalKey(status: ServerProviderStatus | null ].join("\u001f"); } +function getModelSelectionOptions( + selection: ModelSelection | null | undefined, +): ProviderOptions | undefined { + return selection !== null && selection !== undefined && "options" in selection + ? (selection.options as ProviderOptions | undefined) + : undefined; +} + function getRateLimitBannerDismissalKey( status: RateLimitStatus | null, threadId: Thread["id"] | null, @@ -1434,6 +1445,7 @@ export default function ChatView({ gemini: resolveHint("gemini"), kilo: resolveHint("kilo"), opencode: resolveHint("opencode"), + openclaw: resolveHint("openclaw"), pi: resolveHint("pi"), }; }, [ @@ -1569,6 +1581,7 @@ export default function ChatView({ customModelsByProvider.opencode, composerModelHintByProvider.opencode, ), + openclaw: getAppModelOptions("openclaw", [], composerModelHintByProvider.openclaw), pi: getAppModelOptions("pi", customModelsByProvider.pi, composerModelHintByProvider.pi), }; const result: Record< @@ -1586,6 +1599,7 @@ export default function ChatView({ gemini: geminiModelsQuery.data, kilo: kiloDynamicModelsQuery.data, opencode: openCodeDynamicModelsQuery.data, + openclaw: undefined, pi: piDynamicModelsQuery.data, }; @@ -1646,6 +1660,7 @@ export default function ChatView({ gemini: geminiModelsQuery.data?.models ?? [], kilo: kiloDynamicModelsQuery.data?.models ?? [], opencode: openCodeDynamicModelsQuery.data?.models ?? [], + openclaw: [], pi: piDynamicModelsQuery.data?.models ?? [], }), [ @@ -1665,6 +1680,7 @@ export default function ChatView({ gemini: geminiModelsQuery, kilo: kiloDynamicModelsQuery, opencode: openCodeDynamicModelsQuery, + openclaw: undefined, pi: piDynamicModelsQuery, } as const; const selectedRuntimeModel = useMemo( @@ -2520,7 +2536,8 @@ export default function ChatView({ [selectedModel, selectedProvider], ); const supportsFastSlashCommand = selectedModelCaps.supportsFastMode; - const currentProviderModelOptions = composerModelOptions?.[selectedProvider]; + const currentProviderModelOptions = + selectedProvider === "openclaw" ? undefined : composerModelOptions?.[selectedProvider]; const fastModeEnabled = supportsFastSlashCommand && (currentProviderModelOptions as { fastMode?: boolean } | undefined)?.fastMode === true; @@ -3856,8 +3873,8 @@ export default function ChatView({ input.modelSelection !== undefined && (input.modelSelection.model !== serverThread.modelSelection.model || input.modelSelection.provider !== serverThread.modelSelection.provider || - JSON.stringify(input.modelSelection.options ?? null) !== - JSON.stringify(serverThread.modelSelection.options ?? null)) + JSON.stringify(getModelSelectionOptions(input.modelSelection) ?? null) !== + JSON.stringify(getModelSelectionOptions(serverThread.modelSelection) ?? null)) ) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", @@ -5681,7 +5698,7 @@ export default function ChatView({ : selectedModelForSend || targetProjectDefaultModelSelectionForSend?.model || DEFAULT_MODEL_BY_PROVIDER.codex, - selectedModelSelectionForSend.options, + getModelSelectionOptions(selectedModelSelectionForSend), ); if (isLocalDraftThread) { @@ -6494,10 +6511,7 @@ export default function ChatView({ return; } const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); - const nextModelSelection: ModelSelection = { - provider, - model: resolvedModel, - }; + const nextModelSelection: ModelSelection = buildModelSelection(provider, resolvedModel); setComposerDraftModelSelection(activeThread.id, nextModelSelection); if (provider === "cursor" && !showExpandedCursorModelVariants) { setComposerDraftProviderModelOptions(activeThread.id, provider, undefined, { @@ -6535,7 +6549,8 @@ export default function ChatView({ }, [scheduleComposerFocus, setPrompt], ); - const selectedProviderModelOptions = composerModelOptions?.[selectedProvider]; + const selectedProviderModelOptions = + selectedProvider === "openclaw" ? undefined : composerModelOptions?.[selectedProvider]; const composerTraitSelection = getComposerTraitSelection( selectedProvider, selectedModel, diff --git a/apps/web/src/components/PluginLibrary.tsx b/apps/web/src/components/PluginLibrary.tsx index acb1837f..7d8c1168 100644 --- a/apps/web/src/components/PluginLibrary.tsx +++ b/apps/web/src/components/PluginLibrary.tsx @@ -84,6 +84,7 @@ const PROVIDER_ICON: Record gemini: Gemini, kilo: KiloIcon, opencode: OpenCodeIcon, + openclaw: PlugIcon, pi: PiIcon, }; const PROVIDER_DISCOVERY_ORDER: ReadonlyArray = [ @@ -417,6 +418,10 @@ export function PluginLibrary() { plugins: supportsPluginDiscovery(openCodeCapabilitiesQuery.data), skills: supportsSkillDiscovery(openCodeCapabilitiesQuery.data), }, + openclaw: { + plugins: false, + skills: false, + }, pi: { plugins: supportsPluginDiscovery(piCapabilitiesQuery.data), skills: supportsSkillDiscovery(piCapabilitiesQuery.data), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 78677cd3..5b46d12c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -118,6 +118,7 @@ import { dispatchThreadRename } from "../lib/threadRename"; import { quotePosixShellArgument } from "../lib/shellQuote"; import { DEFAULT_THREAD_TERMINAL_ID, type SidebarThreadSummary, type Thread } from "../types"; import { shouldRenderTerminalWorkspace } from "./ChatView.logic"; +import { buildModelSelection } from "../providerModelOptions"; import { ClaudeAI, CursorIcon, Gemini, KiloIcon, OpenAI, OpenCodeIcon, PiIcon } from "./Icons"; import { AppNavigationButtons } from "./AppNavigationButtons"; import { SidebarHeaderNavigationControls } from "./SidebarHeaderNavigationControls"; @@ -2178,10 +2179,7 @@ export default function Sidebar() { activeProject.defaultModelSelection?.provider === provider ? activeProject.defaultModelSelection : providerDefaultModel - ? { - provider, - model: providerDefaultModel, - } + ? buildModelSelection(provider, providerDefaultModel) : null; if (!modelSelection) { throw new Error("Select a Pi model before importing a Pi thread."); diff --git a/apps/web/src/components/SkillLibrarySettingsPanel.test.ts b/apps/web/src/components/SkillLibrarySettingsPanel.test.ts new file mode 100644 index 00000000..d777873d --- /dev/null +++ b/apps/web/src/components/SkillLibrarySettingsPanel.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + buildSkillLibraryProviderQueryMap, + buildSkillLibraryProviderStatusMap, +} from "./SkillLibrarySettingsPanel"; + +describe("SkillLibrarySettingsPanel provider maps", () => { + it("does not map OpenClaw to Pi skill queries", () => { + const skillQueries = buildSkillLibraryProviderQueryMap({ + codex: "codex-skills", + claudeAgent: "claude-skills", + cursor: "cursor-skills", + gemini: "gemini-skills", + kilo: "kilo-skills", + opencode: "opencode-skills", + pi: "pi-skills", + }); + + expect(Object.keys(skillQueries)).toEqual([ + "codex", + "claudeAgent", + "cursor", + "gemini", + "kilo", + "opencode", + "pi", + ]); + expect("openclaw" in skillQueries).toBe(false); + expect(skillQueries.pi).toBe("pi-skills"); + }); + + it("does not map OpenClaw to Pi provider status", () => { + const providerStatus = buildSkillLibraryProviderStatusMap({ + codex: { capability: "codex-capability", skills: "codex-skills" }, + claudeAgent: { capability: "claude-capability", skills: "claude-skills" }, + cursor: { capability: "cursor-capability", skills: "cursor-skills" }, + gemini: { capability: "gemini-capability", skills: "gemini-skills" }, + kilo: { capability: "kilo-capability", skills: "kilo-skills" }, + opencode: { capability: "opencode-capability", skills: "opencode-skills" }, + pi: { capability: "pi-capability", skills: "pi-skills" }, + }); + + expect(Object.keys(providerStatus)).toEqual([ + "codex", + "claudeAgent", + "cursor", + "gemini", + "kilo", + "opencode", + "pi", + ]); + expect("openclaw" in providerStatus).toBe(false); + expect(providerStatus.pi).toEqual({ capability: "pi-capability", skills: "pi-skills" }); + }); +}); diff --git a/apps/web/src/components/SkillLibrarySettingsPanel.tsx b/apps/web/src/components/SkillLibrarySettingsPanel.tsx index d6694b6e..1ed82cbf 100644 --- a/apps/web/src/components/SkillLibrarySettingsPanel.tsx +++ b/apps/web/src/components/SkillLibrarySettingsPanel.tsx @@ -25,7 +25,23 @@ import { createFirstProjectSelector } from "../storeSelectors"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; -const PROVIDERS: readonly ProviderKind[] = [ +type SkillLibraryDiscoveryProvider = Exclude; + +type SkillLibraryDiscoveryProviderMap = Record; + +export function buildSkillLibraryProviderQueryMap( + queries: SkillLibraryDiscoveryProviderMap, +): SkillLibraryDiscoveryProviderMap { + return queries; +} + +export function buildSkillLibraryProviderStatusMap( + statuses: SkillLibraryDiscoveryProviderMap, +): SkillLibraryDiscoveryProviderMap { + return statuses; +} + +const PROVIDERS: readonly SkillLibraryDiscoveryProvider[] = [ "codex", "claudeAgent", "cursor", @@ -35,6 +51,12 @@ const PROVIDERS: readonly ProviderKind[] = [ "pi", ]; +function isSkillLibraryDiscoveryProvider( + provider: SkillLibraryProviderFilter, +): provider is SkillLibraryDiscoveryProvider { + return provider !== "all" && provider !== "openclaw"; +} + const MAX_COLLAPSED_PROVIDER_ROWS = 48; function getSkillTitle(row: SkillLibraryRow): string { @@ -120,6 +142,7 @@ export function SkillLibrarySettingsPanel() { gemini: supportsSkillDiscovery(geminiCapabilitiesQuery.data), kilo: supportsSkillDiscovery(kiloCapabilitiesQuery.data), opencode: supportsSkillDiscovery(openCodeCapabilitiesQuery.data), + openclaw: false, pi: supportsSkillDiscovery(piCapabilitiesQuery.data), }), [ @@ -193,15 +216,16 @@ export function SkillLibrarySettingsPanel() { ); const skillQueries = useMemo( - () => ({ - codex: codexSkillsQuery, - claudeAgent: claudeSkillsQuery, - cursor: cursorSkillsQuery, - gemini: geminiSkillsQuery, - kilo: kiloSkillsQuery, - opencode: openCodeSkillsQuery, - pi: piSkillsQuery, - }), + () => + buildSkillLibraryProviderQueryMap({ + codex: codexSkillsQuery, + claudeAgent: claudeSkillsQuery, + cursor: cursorSkillsQuery, + gemini: geminiSkillsQuery, + kilo: kiloSkillsQuery, + opencode: openCodeSkillsQuery, + pi: piSkillsQuery, + }), [ claudeSkillsQuery, codexSkillsQuery, @@ -236,7 +260,12 @@ export function SkillLibrarySettingsPanel() { ); const groupedFilteredRows = useMemo( () => - (providerFilter === "all" ? PROVIDERS : [providerFilter]).map((provider) => ({ + (providerFilter === "all" + ? PROVIDERS + : isSkillLibraryDiscoveryProvider(providerFilter) + ? [providerFilter] + : [] + ).map((provider) => ({ provider, rows: filteredRows.filter((row) => row.provider === provider), })), @@ -261,15 +290,16 @@ export function SkillLibrarySettingsPanel() { const isSkillLoading = activeProviders.some((provider) => skillQueries[provider].isLoading); const providerStatus = useMemo( - () => ({ - codex: { capability: codexCapabilitiesQuery, skills: codexSkillsQuery }, - claudeAgent: { capability: claudeCapabilitiesQuery, skills: claudeSkillsQuery }, - cursor: { capability: cursorCapabilitiesQuery, skills: cursorSkillsQuery }, - gemini: { capability: geminiCapabilitiesQuery, skills: geminiSkillsQuery }, - kilo: { capability: kiloCapabilitiesQuery, skills: kiloSkillsQuery }, - opencode: { capability: openCodeCapabilitiesQuery, skills: openCodeSkillsQuery }, - pi: { capability: piCapabilitiesQuery, skills: piSkillsQuery }, - }), + () => + buildSkillLibraryProviderStatusMap({ + codex: { capability: codexCapabilitiesQuery, skills: codexSkillsQuery }, + claudeAgent: { capability: claudeCapabilitiesQuery, skills: claudeSkillsQuery }, + cursor: { capability: cursorCapabilitiesQuery, skills: cursorSkillsQuery }, + gemini: { capability: geminiCapabilitiesQuery, skills: geminiSkillsQuery }, + kilo: { capability: kiloCapabilitiesQuery, skills: kiloSkillsQuery }, + opencode: { capability: openCodeCapabilitiesQuery, skills: openCodeSkillsQuery }, + pi: { capability: piCapabilitiesQuery, skills: piSkillsQuery }, + }), [ claudeCapabilitiesQuery, claudeSkillsQuery, diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 5a3df7d1..bd7e6484 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; +import { buildModelSelection } from "../../providerModelOptions"; import { TraitsMenuContent } from "./TraitsPicker"; import { useComposerDraftStore } from "../../composerDraftStore"; @@ -17,12 +18,12 @@ async function mountMenu(props?: { prompt?: string; }) { const threadId = ThreadId.makeUnsafe("thread-compact-menu"); - const provider = props?.modelSelection?.provider ?? "claudeAgent"; + const modelSelection = props?.modelSelection; + const provider = modelSelection?.provider ?? "claudeAgent"; const draftsByThreadId = {} as ReturnType< typeof useComposerDraftStore.getState >["draftsByThreadId"]; - const model = - props?.modelSelection?.model ?? getDefaultModel(provider) ?? getDefaultModel("codex"); + const model = modelSelection?.model ?? getDefaultModel(provider) ?? getDefaultModel("codex"); draftsByThreadId[threadId] = { prompt: props?.prompt ?? "", @@ -33,11 +34,11 @@ async function mountMenu(props?: { terminalContexts: [], queuedTurns: [], modelSelectionByProvider: { - [provider]: { + [provider]: buildModelSelection( provider, model, - ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), - }, + modelSelection && "options" in modelSelection ? modelSelection.options : undefined, + ), }, activeProvider: provider, runtimeMode: null, @@ -51,7 +52,8 @@ async function mountMenu(props?: { const host = document.createElement("div"); document.body.append(host); const onPromptChange = vi.fn(); - const providerOptions = props?.modelSelection?.options; + const providerOptions = + modelSelection && "options" in modelSelection ? modelSelection.options : undefined; const screen = await render( = { gemini: Gemini, kilo: KiloIcon, opencode: OpenCodeIcon, + openclaw: PlugIcon, pi: PiIcon, }; diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 90943dea..7094648d 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -46,6 +46,7 @@ function ClaudeTraitsPickerHarness(props: { gemini: [], kilo: [], opencode: [], + openclaw: [], pi: [], }, }); @@ -575,6 +576,7 @@ function OpenCodeTraitsPickerHarness(props: { gemini: [], kilo: [], opencode: [], + openclaw: [], pi: [], }, }); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index e87c3822..216193df 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -30,6 +30,8 @@ import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; import { getComposerTraitSelection, hasVisibleComposerTraitControls } from "./composerTraits"; import { getRuntimeAwareModelCapabilities } from "./runtimeModelCapabilities"; +type ComposerProviderModelOptions = ProviderModelOptions[keyof ProviderModelOptions]; + export type ComposerProviderStateInput = { provider: ProviderKind; model: ModelSlug; @@ -41,7 +43,7 @@ export type ComposerProviderStateInput = { export type ComposerProviderState = { provider: ProviderKind; promptEffort: string | null; - modelOptionsForDispatch: ProviderModelOptions[ProviderKind] | undefined; + modelOptionsForDispatch: ComposerProviderModelOptions | undefined; composerFrameClassName?: string; composerSurfaceClassName?: string; modelPickerIconClassName?: string; @@ -53,7 +55,7 @@ type ProviderTraitRenderInput = { runtimeModel?: ProviderModelDescriptor | undefined; runtimeModels?: ReadonlyArray | null | undefined; runtimeAgents?: ReadonlyArray | null | undefined; - modelOptions: ProviderModelOptions[ProviderKind] | undefined; + modelOptions: ComposerProviderModelOptions | undefined; prompt: string; includeFastMode?: boolean; onPromptChange: (prompt: string) => void; @@ -121,7 +123,7 @@ function getProviderStateFromCapabilities( const caps = getRuntimeAwareModelCapabilities({ provider, model, runtimeModel }); let rawEffort: string | null = null; - let normalizedOptions: ProviderModelOptions[ProviderKind] | undefined; + let normalizedOptions: ComposerProviderModelOptions | undefined; switch (provider) { case "codex": { @@ -240,6 +242,18 @@ function getProviderStateFromCapabilities( }; } +function getOpenClawProviderState(input: ComposerProviderStateInput): ComposerProviderState { + return { + provider: input.provider, + promptEffort: null, + modelOptionsForDispatch: undefined, + }; +} + +function renderNoTraits(): ReactNode { + return null; +} + const composerProviderRegistry: Record = { codex: { getState: (input) => getProviderStateFromCapabilities(input), @@ -271,6 +285,11 @@ const composerProviderRegistry: Record = { renderTraitsMenuContent: (input) => renderTraitsMenuContentForProvider("opencode", input), renderTraitsPicker: (input) => renderTraitsPickerForProvider("opencode", input), }, + openclaw: { + getState: getOpenClawProviderState, + renderTraitsMenuContent: renderNoTraits, + renderTraitsPicker: renderNoTraits, + }, pi: { getState: (input) => getProviderStateFromCapabilities(input), renderTraitsMenuContent: (input) => renderTraitsMenuContentForProvider("pi", input), @@ -289,7 +308,7 @@ export function renderProviderTraitsMenuContent(input: { runtimeModel?: ProviderModelDescriptor | undefined; runtimeModels?: ReadonlyArray | null | undefined; runtimeAgents?: ReadonlyArray | null | undefined; - modelOptions: ProviderModelOptions[ProviderKind] | undefined; + modelOptions: ComposerProviderModelOptions | undefined; prompt: string; includeFastMode?: boolean; onPromptChange: (prompt: string) => void; @@ -321,7 +340,7 @@ export function renderProviderTraitsPicker(input: { runtimeModel?: ProviderModelDescriptor | undefined; runtimeModels?: ReadonlyArray | null | undefined; runtimeAgents?: ReadonlyArray | null | undefined; - modelOptions: ProviderModelOptions[ProviderKind] | undefined; + modelOptions: ComposerProviderModelOptions | undefined; prompt: string; includeFastMode?: boolean; open?: boolean; diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 2d489294..0ee5196e 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -128,7 +128,7 @@ function resetComposerDraftStore() { function modelSelection( provider: ModelSelection["provider"], model: string, - options?: ModelSelection["options"], + options?: ProviderModelOptions[keyof ProviderModelOptions], ): ModelSelection { return { provider, @@ -137,6 +137,12 @@ function modelSelection( } as ModelSelection; } +function modelSelectionOptions(selection: ModelSelection | null | undefined) { + return selection !== null && selection !== undefined && "options" in selection + ? selection.options + : undefined; +} + function providerModelOptions(options: ProviderModelOptions): ProviderModelOptions { return options; } @@ -162,6 +168,21 @@ describe("resolvePreferredComposerModelSelection", () => { }), ); }); + + it("preserves persisted OpenClaw gateway selections", () => { + expect( + resolvePreferredComposerModelSelection({ + draft: { + modelSelectionByProvider: { + openclaw: modelSelection("openclaw", "gateway"), + }, + activeProvider: "openclaw", + }, + threadModelSelection: modelSelection("codex", "gpt-5"), + projectModelSelection: null, + }), + ).toEqual(modelSelection("openclaw", "gateway")); + }); }); describe("composerDraftStore addImages", () => { @@ -1054,8 +1075,12 @@ describe("composerDraftStore modelSelection", () => { store.setModelOptions(threadId, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; - expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" }); - expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); + expect(modelSelectionOptions(draft?.modelSelectionByProvider.codex)).toEqual({ + reasoningEffort: "xhigh", + }); + expect(modelSelectionOptions(draft?.modelSelectionByProvider.claudeAgent)).toEqual({ + effort: "max", + }); }); it("preserves other provider options when switching the active model selection", () => { @@ -1075,7 +1100,9 @@ describe("composerDraftStore modelSelection", () => { expect(draft?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); - expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ fastMode: true }); + expect(modelSelectionOptions(draft?.modelSelectionByProvider.codex)).toEqual({ + fastMode: true, + }); expect(draft?.activeProvider).toBe("claudeAgent"); }); @@ -1118,6 +1145,7 @@ describe("composerDraftStore modelSelection", () => { gemini: [], kilo: [], opencode: [], + openclaw: [], pi: [], }, availableModelOptionsByProvider: { @@ -1144,6 +1172,7 @@ describe("composerDraftStore modelSelection", () => { gemini: [], kilo: [], opencode: [], + openclaw: [], pi: [], }, availableModelOptionsByProvider: { @@ -1175,6 +1204,7 @@ describe("composerDraftStore modelSelection", () => { gemini: [], kilo: [], opencode: [], + openclaw: [], pi: [], }, availableModelOptionsByProvider: { @@ -1206,6 +1236,7 @@ describe("composerDraftStore modelSelection", () => { gemini: [], kilo: [], opencode: [], + openclaw: [], pi: [], }, availableModelOptionsByProvider: { @@ -1457,7 +1488,9 @@ describe("composerDraftStore provider-scoped option updates", () => { expect(draft?.modelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }), ); - expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); + expect(modelSelectionOptions(draft?.modelSelectionByProvider.claudeAgent)).toEqual({ + effort: "max", + }); expect(draft?.activeProvider).toBe("codex"); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 02618d99..6b88d688 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -50,6 +50,7 @@ import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; export const COMPOSER_DRAFT_STORAGE_KEY = "jcode:composer-drafts:v1"; const COMPOSER_DRAFT_STORAGE_VERSION = 4; +type ProviderModelOptionValue = ProviderModelOptions[keyof ProviderModelOptions]; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; const DraftThreadEntryPointSchema = Schema.Literals(["chat", "terminal"]); @@ -696,6 +697,7 @@ function normalizeProviderKind(value: unknown): ProviderKind | null { value === "gemini" || value === "kilo" || value === "opencode" || + value === "openclaw" || value === "pi" ? value : null; @@ -712,7 +714,7 @@ function trimStringOrUndefined(value: unknown): string | undefined { function makeModelSelection( provider: ProviderKind, model: string, - options?: ProviderModelOptions[ProviderKind], + options?: ProviderModelOptionValue, ): ModelSelection { switch (provider) { case "codex": @@ -773,9 +775,24 @@ function makeModelSelection( ? { options: options as Extract["options"] } : {}), }; + case "openclaw": + return { provider, model: "gateway" }; } } +function getModelSelectionOptions( + selection: ModelSelection | null, +): ProviderModelOptionValue | undefined { + return selection !== null && "options" in selection ? selection.options : undefined; +} + +function getProviderModelOptions( + modelOptions: ProviderModelOptions | null | undefined, + provider: ProviderKind, +): ProviderModelOptionValue | undefined { + return provider === "openclaw" ? undefined : modelOptions?.[provider]; +} + function normalizeProviderModelOptions( value: unknown, provider?: ProviderKind | null, @@ -1036,7 +1053,7 @@ function legacySyncModelSelectionOptions( if (modelSelection === null) { return null; } - const options = modelOptions?.[modelSelection.provider]; + const options = getProviderModelOptions(modelOptions, modelSelection.provider); return makeModelSelection(modelSelection.provider, modelSelection.model, options); } @@ -1044,13 +1061,17 @@ function legacyMergeModelSelectionIntoProviderModelOptions( modelSelection: ModelSelection | null, currentModelOptions: ProviderModelOptions | null | undefined, ): ProviderModelOptions | null { - if (modelSelection?.options === undefined) { + if (modelSelection === null) { + return normalizeProviderModelOptions(currentModelOptions); + } + const selectionOptions = getModelSelectionOptions(modelSelection); + if (!modelSelection || selectionOptions === undefined) { return normalizeProviderModelOptions(currentModelOptions); } return legacyReplaceProviderModelOptions( normalizeProviderModelOptions(currentModelOptions), modelSelection.provider, - modelSelection.options, + selectionOptions, ); } @@ -1215,8 +1236,10 @@ export function resolvePreferredComposerModelSelection(input: { (input.projectModelSelection?.provider === preferredProvider ? input.projectModelSelection : null) ?? { - provider: preferredProvider === "pi" ? "codex" : preferredProvider, - model: getDefaultModel(preferredProvider === "pi" ? "codex" : preferredProvider), + ...makeModelSelection( + preferredProvider === "pi" ? "codex" : preferredProvider, + getDefaultModel(preferredProvider === "pi" ? "codex" : preferredProvider) ?? "gateway", + ), } ); } @@ -2596,10 +2619,11 @@ export const useComposerDraftStore = create()( for (const [provider, selection] of Object.entries(stickyMap)) { if (selection) { const current = nextMap[provider as ProviderKind]; - nextMap[provider as ProviderKind] = { - ...selection, - model: current?.model ?? selection.model, - }; + nextMap[provider as ProviderKind] = makeModelSelection( + selection.provider, + current?.model ?? selection.model, + "options" in selection ? selection.options : undefined, + ); } } if ( diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 28e0b988..c795f9bd 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -3,6 +3,7 @@ import { getDefaultModel } from "@jcode/shared/model"; import { useNavigate } from "@tanstack/react-router"; import { useCallback } from "react"; import { useAppSettings } from "../appSettings"; +import { buildModelSelection } from "../providerModelOptions"; import { type ComposerThreadDraftState, type DraftThreadState, @@ -47,10 +48,7 @@ export function useHandleNewThread() { if (!defaultModel) { return; } - setModelSelection(threadId, { - provider: options.provider, - model: defaultModel, - }); + setModelSelection(threadId, buildModelSelection(options.provider, defaultModel)); }; const restoreComposerDraft = ( threadId: ThreadId, diff --git a/apps/web/src/lib/threadHandoff.test.ts b/apps/web/src/lib/threadHandoff.test.ts index e656afbd..3ab1c9dd 100644 --- a/apps/web/src/lib/threadHandoff.test.ts +++ b/apps/web/src/lib/threadHandoff.test.ts @@ -13,6 +13,7 @@ describe("threadHandoff", () => { "gemini", "kilo", "opencode", + "openclaw", "pi", ]); expect(resolveAvailableHandoffTargetProviders("claudeAgent")).toEqual([ @@ -21,6 +22,7 @@ describe("threadHandoff", () => { "gemini", "kilo", "opencode", + "openclaw", "pi", ]); expect(resolveAvailableHandoffTargetProviders("cursor")).toEqual([ @@ -29,6 +31,7 @@ describe("threadHandoff", () => { "gemini", "kilo", "opencode", + "openclaw", "pi", ]); expect(resolveAvailableHandoffTargetProviders("gemini")).toEqual([ @@ -37,6 +40,7 @@ describe("threadHandoff", () => { "cursor", "kilo", "opencode", + "openclaw", "pi", ]); expect(resolveAvailableHandoffTargetProviders("kilo")).toEqual([ @@ -45,6 +49,7 @@ describe("threadHandoff", () => { "cursor", "gemini", "opencode", + "openclaw", "pi", ]); expect(resolveAvailableHandoffTargetProviders("opencode")).toEqual([ @@ -53,6 +58,16 @@ describe("threadHandoff", () => { "cursor", "gemini", "kilo", + "openclaw", + "pi", + ]); + expect(resolveAvailableHandoffTargetProviders("openclaw")).toEqual([ + "codex", + "claudeAgent", + "cursor", + "gemini", + "kilo", + "opencode", "pi", ]); expect(resolveAvailableHandoffTargetProviders("pi")).toEqual([ @@ -62,6 +77,7 @@ describe("threadHandoff", () => { "gemini", "kilo", "opencode", + "openclaw", ]); }); @@ -109,4 +125,23 @@ describe("threadHandoff", () => { model: "gpt-5.5", }); }); + + it("falls back to the fixed OpenClaw gateway model for handoff targets", () => { + expect( + resolveThreadHandoffModelSelection({ + sourceThread: { + modelSelection: { + provider: "gemini", + model: "gemini-2.5-pro", + }, + }, + targetProvider: "openclaw", + projectDefaultModelSelection: null, + stickyModelSelectionByProvider: {}, + }), + ).toEqual({ + provider: "openclaw", + model: "gateway", + }); + }); }); diff --git a/apps/web/src/lib/threadHandoff.ts b/apps/web/src/lib/threadHandoff.ts index 4801bbb3..da26ec38 100644 --- a/apps/web/src/lib/threadHandoff.ts +++ b/apps/web/src/lib/threadHandoff.ts @@ -8,6 +8,7 @@ import { type ThreadHandoffImportedMessage, } from "@jcode/contracts"; import { getDefaultModel } from "@jcode/shared/model"; +import { buildModelSelection } from "../providerModelOptions"; import { type Thread } from "../types"; import { stripEmbeddedAssistantSelections } from "./assistantSelections"; import { randomUUID } from "./utils"; @@ -19,6 +20,7 @@ const HANDOFF_PROVIDER_ORDER: ReadonlyArray = [ "gemini", "kilo", "opencode", + "openclaw", "pi", ]; const IMPORTABLE_THREAD_ACTIVITY_KINDS = new Set([ @@ -163,10 +165,10 @@ export function resolveThreadHandoffModelSelection(input: { } const defaultModel = getDefaultModel(input.targetProvider); if (!defaultModel) { + if (input.targetProvider === "openclaw") { + return buildModelSelection("openclaw", "gateway"); + } throw new Error("Select a Pi model before handing off to Pi."); } - return { - provider: input.targetProvider, - model: defaultModel, - }; + return buildModelSelection(input.targetProvider, defaultModel); } diff --git a/apps/web/src/providerModelOptions.ts b/apps/web/src/providerModelOptions.ts index 0b3bfbf0..49d7b312 100644 --- a/apps/web/src/providerModelOptions.ts +++ b/apps/web/src/providerModelOptions.ts @@ -12,6 +12,7 @@ import type { ModelSelection, OpenCodeModelOptions, OpenCodeModelSelection, + OpenClawModelOptions, PiModelOptions, PiModelSelection, ProviderKind, @@ -230,6 +231,11 @@ export function buildModelSelection( model: string, options?: PiModelOptions | null | undefined, ): PiModelSelection; +export function buildModelSelection( + provider: "openclaw", + model: string, + options?: OpenClawModelOptions | null | undefined, +): Extract; export function buildModelSelection( provider: ProviderKind, model: string, @@ -297,5 +303,7 @@ export function buildModelSelection( options: options as PiModelOptions, } : { provider, model }; + case "openclaw": + return { provider, model: "gateway" }; } } diff --git a/apps/web/src/providerOrdering.test.ts b/apps/web/src/providerOrdering.test.ts index 38226984..9cf969e1 100644 --- a/apps/web/src/providerOrdering.test.ts +++ b/apps/web/src/providerOrdering.test.ts @@ -1,6 +1,50 @@ import { describe, expect, it } from "vitest"; -import { filterVisibleProviderItems } from "./providerOrdering"; +import { + DEFAULT_PROVIDER_ORDER, + filterVisibleProviderItems, + normalizeHiddenProviders, + normalizeProviderOrder, +} from "./providerOrdering"; + +describe("DEFAULT_PROVIDER_ORDER", () => { + it("orders OpenClaw after OpenCode and before Pi", () => { + expect(DEFAULT_PROVIDER_ORDER).toEqual([ + "codex", + "claudeAgent", + "cursor", + "gemini", + "kilo", + "opencode", + "openclaw", + "pi", + ]); + }); +}); + +describe("normalizeProviderOrder", () => { + it("keeps OpenClaw and Pi in normalized provider order", () => { + expect(normalizeProviderOrder(["opencode", "openclaw", "unknown", "pi", "openclaw"])).toEqual([ + "opencode", + "openclaw", + "pi", + "codex", + "claudeAgent", + "cursor", + "gemini", + "kilo", + ]); + }); +}); + +describe("normalizeHiddenProviders", () => { + it("keeps OpenClaw and Pi as valid hidden providers", () => { + expect(normalizeHiddenProviders(["openclaw", "pi", "unknown", "openclaw"])).toEqual([ + "openclaw", + "pi", + ]); + }); +}); describe("filterVisibleProviderItems", () => { it("removes hidden providers while preserving visible provider order", () => { diff --git a/apps/web/src/providerOrdering.ts b/apps/web/src/providerOrdering.ts index d56e3a2e..cb6fa728 100644 --- a/apps/web/src/providerOrdering.ts +++ b/apps/web/src/providerOrdering.ts @@ -12,6 +12,8 @@ export const DEFAULT_PROVIDER_ORDER: readonly ProviderKind[] = [ "gemini", "kilo", "opencode", + "openclaw", + "pi", ]; const PROVIDER_KIND_SET: ReadonlySet = new Set(DEFAULT_PROVIDER_ORDER); diff --git a/apps/web/src/routes/-_chat.settings.install.test.ts b/apps/web/src/routes/-_chat.settings.install.test.ts index bda49c02..94d754c0 100644 --- a/apps/web/src/routes/-_chat.settings.install.test.ts +++ b/apps/web/src/routes/-_chat.settings.install.test.ts @@ -2,6 +2,10 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; const settingsRouteSource = readFileSync(new URL("./_chat.settings.tsx", import.meta.url), "utf8"); +const defaultProviderSectionSource = settingsRouteSource.slice( + settingsRouteSource.indexOf('title="Default provider"'), + settingsRouteSource.indexOf('title="New threads"'), +); describe("settings install provider contracts", () => { it("keeps Codex launch arguments wired into install settings", () => { @@ -14,4 +18,32 @@ describe("settings install provider contracts", () => { expect(settingsRouteSource).toContain("value={codexLaunchArgs}"); expect(settingsRouteSource).toContain("codexLaunchArgs: event.target.value"); }); + + it("keeps OpenClaw gateway settings non-secret in install settings", () => { + expect(settingsRouteSource).toContain('provider: "openclaw"'); + expect(settingsRouteSource).toContain('gatewayUrlKey: "openClawGatewayUrl"'); + expect(settingsRouteSource).toContain('authModeKey: "openClawAuthMode"'); + expect(settingsRouteSource).toContain('aria-label="Enable OpenClaw gateway"'); + expect(settingsRouteSource).toContain('aria-label="OpenClaw gateway URL"'); + expect(settingsRouteSource).toContain("openClawGatewayUrl: event.target.value"); + expect(settingsRouteSource).toContain("openClawAuthMode: value"); + expect(settingsRouteSource).not.toContain("openClawSecret"); + }); +}); + +describe("settings default provider contracts", () => { + it("keeps OpenClaw accepted and visible in the default provider select", () => { + expect(defaultProviderSectionSource).toContain('value !== "openclaw"'); + expect(defaultProviderSectionSource).toContain('settings.defaultProvider === "openclaw"'); + expect(defaultProviderSectionSource).toContain(''); + expect(defaultProviderSectionSource.indexOf('value="kilo"')).toBeLessThan( + defaultProviderSectionSource.indexOf('value="opencode"'), + ); + expect(defaultProviderSectionSource.indexOf('value="opencode"')).toBeLessThan( + defaultProviderSectionSource.indexOf('value="openclaw"'), + ); + expect(defaultProviderSectionSource.indexOf('value="openclaw"')).toBeLessThan( + defaultProviderSectionSource.indexOf('value="pi"'), + ); + }); }); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index c97b3b35..b3bd46e1 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -7,6 +7,7 @@ import { PROVIDER_DISPLAY_NAMES, type ProviderKind, type ServerProviderStatus, + type ServerUpdateOpenClawSecretsInput, type ThreadId, DEFAULT_GIT_TEXT_GENERATION_MODEL, } from "@jcode/contracts"; @@ -37,6 +38,7 @@ import { MAX_CUSTOM_MODEL_LENGTH, MIN_CHAT_FONT_SIZE_PX, MODEL_PROVIDER_SETTINGS, + type CustomModelProviderKind, normalizeChatFontSizePx, patchCustomModels, useAppSettings, @@ -165,9 +167,9 @@ type InstallProviderSettings = { label: string; href: string; }>; - binaryPathKey: InstallBinarySettingsKey; - binaryPlaceholder: string; - binaryDescription: ReactNode; + binaryPathKey?: InstallBinarySettingsKey; + binaryPlaceholder?: string; + binaryDescription?: ReactNode; homePathKey?: "codexHomePath"; homePlaceholder?: string; homeDescription?: ReactNode; @@ -183,6 +185,10 @@ type InstallProviderSettings = { serverPasswordKey?: "kiloServerPassword" | "openCodeServerPassword"; serverPasswordPlaceholder?: string; serverPasswordDescription?: ReactNode; + gatewayUrlKey?: "openClawGatewayUrl"; + gatewayUrlPlaceholder?: string; + gatewayUrlDescription?: ReactNode; + authModeKey?: "openClawAuthMode"; agentDirKey?: "piAgentDir"; agentDirPlaceholder?: string; agentDirDescription?: ReactNode; @@ -195,6 +201,8 @@ const PROVIDER_VISIBILITY_OPTIONS: ReadonlyArray<{ provider: ProviderKind; title { provider: "gemini", title: PROVIDER_DISPLAY_NAMES.gemini }, { provider: "kilo", title: PROVIDER_DISPLAY_NAMES.kilo }, { provider: "opencode", title: PROVIDER_DISPLAY_NAMES.opencode }, + { provider: "openclaw", title: PROVIDER_DISPLAY_NAMES.openclaw }, + { provider: "pi", title: PROVIDER_DISPLAY_NAMES.pi }, ]; // Pure helper kept at module scope so the toggle handler stays trivial and the @@ -231,7 +239,7 @@ function SortableProviderVisibilityRow(props: { transition, }} className={cn( - "flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-[var(--color-background-elevated-secondary)]/40 px-3 py-2.5", + "flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-(--color-background-elevated-secondary)/40 px-3 py-2.5", isDragging && "z-10 opacity-80 shadow-lg", )} > @@ -239,7 +247,7 @@ function SortableProviderVisibilityRow(props: { + + + + + ) : openClawAuthMode === "password" ? ( +
+
+
+
+ OpenClaw password +
+
+ {openClawHasSecret + ? "A gateway secret is saved in native storage." + : "No gateway secret is saved."} +
+
+
+
+ +
+ + +
+
+
+ ) : openClawAuthMode === "device" ? ( +
+
+
+ OpenClaw device identity +
+
+ {openClawPaired + ? "Device identity is paired." + : "Device identity is not paired."} +
+
+
+ +
+ + +
+
+
+ ) : ( +
+ No OpenClaw gateway secret is required for this auth mode. +
+ )} + {openClawCredentialError ? ( +
+ {openClawCredentialError} +
+ ) : null} + {openClawCredentialStatus ? ( +
+ {openClawCredentialStatus} +
+ ) : null} + + ) : null} {providerSettings.homePathKey ? (