From f6c3baac4a660b24f6934f43c3726f6039b7d5c8 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 11 May 2026 03:55:33 -0500 Subject: [PATCH] Remove OpenClaw integration code - Delete OpenClaw skills, plans, gateway code, and persistence layers - Remove OpenClaw provider wiring from server, web, and contracts - Update docs and tests to match the trimmed provider set --- .agents/skills/openclaw-ghsa/SKILL.md | 45 - .agents/skills/openclaw-pr-merge/SKILL.md | 50 - .agents/skills/openclaw-pr-ops/SKILL.md | 65 - .agents/skills/openclaw-pr-prepare/SKILL.md | 58 - .agents/skills/openclaw-pr-review/SKILL.md | 58 - .plans/11-openclaw-provider-rollout.md | 254 ---- .plans/README.md | 1 - apps/server/src/doctor.ts | 1 - apps/server/src/index.ts | 34 - apps/server/src/main.test.ts | 32 - apps/server/src/main.ts | 2 - .../server/src/openclaw/GatewayClient.test.ts | 123 -- apps/server/src/openclaw/GatewayClient.ts | 580 -------- apps/server/src/openclaw/deviceAuth.ts | 82 -- apps/server/src/openclaw/protocol.ts | 155 --- apps/server/src/openclaw/sessionIdentity.ts | 34 - apps/server/src/openclawGatewayTest.test.ts | 267 ---- apps/server/src/openclawGatewayTest.ts | 623 --------- .../Layers/OpenclawGatewayConfig.ts | 482 ------- apps/server/src/persistence/Migrations.ts | 2 - .../020_SmeConversationProviderAuth.ts | 1 - .../Migrations/021_OpenclawGatewayConfig.ts | 23 - .../Migrations/024_OpenclawGatewayConfig.ts | 23 - .../025_CanonicalizeModelSelections.ts | 2 - ...027_CanonicalizeModelSelectionsBackfill.ts | 1 - .../Services/OpenclawGatewayConfig.ts | 72 - .../src/prReview/Layers/RepoReviewConfig.ts | 14 +- .../server/src/prReview/localProfiles.test.ts | 12 +- .../src/provider/Layers/OpenClawAdapter.ts | 1168 ----------------- .../provider/Layers/OpenClawGatewayClient.ts | 940 ------------- .../Layers/ProviderAdapterRegistry.test.ts | 21 +- .../Layers/ProviderAdapterRegistry.ts | 2 - .../provider/Layers/ProviderHealth.test.ts | 16 - .../src/provider/Layers/ProviderHealth.ts | 182 --- .../src/provider/Services/OpenClawAdapter.ts | 30 - apps/server/src/provider/providerCatalog.ts | 1 - apps/server/src/serverLayers.ts | 16 +- apps/server/src/skills/SkillService.ts | 1 - apps/server/src/wsServer.ts | 20 +- apps/server/src/wsServer/routes/server.ts | 48 - apps/web/src/appSettings.test.ts | 4 - apps/web/src/appSettings.ts | 32 +- apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/components/Icons.tsx | 9 - .../components/chat/ProviderModelPicker.tsx | 46 +- .../chat/composerProviderRegistry.tsx | 16 - .../chat/providerStatusPresentation.ts | 1 - apps/web/src/components/home/home-utils.ts | 4 +- .../settings/ProviderCapabilityMatrix.tsx | 17 +- .../settings/SettingsRouteContext.tsx | 8 +- apps/web/src/lib/projectChat.ts | 2 - apps/web/src/lib/providerAvailability.test.ts | 63 +- apps/web/src/lib/providerAvailability.ts | 12 - apps/web/src/lib/remoteFolderPicker.ts | 6 - apps/web/src/lib/serverReactQuery.ts | 12 - .../src/lib/settingsProviderMetadata.test.ts | 10 +- apps/web/src/lib/settingsProviderMetadata.tsx | 10 - apps/web/src/providerModels.ts | 8 +- .../src/routes/_chat.project.$projectId.tsx | 3 +- apps/web/src/routes/_chat.settings.index.tsx | 330 +---- apps/web/src/session-logic.test.ts | 1 - apps/web/src/session-logic.ts | 1 - apps/web/src/store.ts | 8 +- apps/web/src/wsNativeApi.ts | 7 - .../private-maintainer-profiles.md | 20 +- docs/product-scope.md | 2 - packages/contracts/src/ipc.ts | 13 - packages/contracts/src/model.ts | 14 - packages/contracts/src/orchestration.ts | 23 +- packages/contracts/src/providerRuntime.ts | 3 - packages/contracts/src/server.ts | 95 -- packages/contracts/src/skillCatalog.ts | 1 - packages/contracts/src/ws.ts | 18 - packages/shared/src/model.ts | 21 +- packages/shared/src/modelSelection.ts | 2 - packages/shared/src/skillCatalog.ts | 18 - .../docs/openclaw-docs/SKILL.md | 30 - plan-lm-studio-support.md | 8 +- scripts/prepare-release.ts | 2 +- 79 files changed, 58 insertions(+), 6367 deletions(-) delete mode 100644 .agents/skills/openclaw-ghsa/SKILL.md delete mode 100644 .agents/skills/openclaw-pr-merge/SKILL.md delete mode 100644 .agents/skills/openclaw-pr-ops/SKILL.md delete mode 100644 .agents/skills/openclaw-pr-prepare/SKILL.md delete mode 100644 .agents/skills/openclaw-pr-review/SKILL.md delete mode 100644 .plans/11-openclaw-provider-rollout.md delete mode 100644 apps/server/src/openclaw/GatewayClient.test.ts delete mode 100644 apps/server/src/openclaw/GatewayClient.ts delete mode 100644 apps/server/src/openclaw/deviceAuth.ts delete mode 100644 apps/server/src/openclaw/protocol.ts delete mode 100644 apps/server/src/openclaw/sessionIdentity.ts delete mode 100644 apps/server/src/openclawGatewayTest.test.ts delete mode 100644 apps/server/src/openclawGatewayTest.ts delete mode 100644 apps/server/src/persistence/Layers/OpenclawGatewayConfig.ts delete mode 100644 apps/server/src/persistence/Migrations/021_OpenclawGatewayConfig.ts delete mode 100644 apps/server/src/persistence/Migrations/024_OpenclawGatewayConfig.ts delete mode 100644 apps/server/src/persistence/Services/OpenclawGatewayConfig.ts delete mode 100644 apps/server/src/provider/Layers/OpenClawAdapter.ts delete mode 100644 apps/server/src/provider/Layers/OpenClawGatewayClient.ts delete mode 100644 apps/server/src/provider/Services/OpenClawAdapter.ts delete mode 100644 packages/shared/src/skills-catalog/docs/openclaw-docs/SKILL.md diff --git a/.agents/skills/openclaw-ghsa/SKILL.md b/.agents/skills/openclaw-ghsa/SKILL.md deleted file mode 100644 index d4d6968d7..000000000 --- a/.agents/skills/openclaw-ghsa/SKILL.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: openclaw-ghsa -description: Handle OpenClaw GHSA work with speed-first, low-noise maintainer coordination and direct-to-main judgment. -version: 1.0.0 -author: OK Code -tags: - - openclaw - - maintainer - - ghsa - - security -tools: - - terminal - - filesystem - - git -triggers: - - use when the user asks about OpenClaw GHSA handling - - use when the user asks to coordinate a security fix - - use when the user mentions maintainer-security-ops - - use when the user asks for a fast security workflow ---- - -# OpenClaw GHSA - -Use this skill for maintainer-facing security fixes and coordination. -Security work is treated differently from normal PR flow. - -## Source of truth - -- `openclaw/maintainers/security/README.md` -- `openclaw/openclaw/SECURITY.md` -- `openclaw/maintainers/README.md` - -## Rules - -- Speed first. -- Usually go directly to `main` instead of opening a normal PR. -- Keep public metadata vague while the fix rolls out. -- Keep real discussion in maintainer channels, not in GHSA comments. -- Only the designated owner should make GHSA state changes. - -## Coordination - -- Post the GHSA link in `maintainer-security-ops` when you pick it up. -- Mark it complete or update the coordination thread when the fix lands. -- Ask for help early if the scope or exploit path is unclear. diff --git a/.agents/skills/openclaw-pr-merge/SKILL.md b/.agents/skills/openclaw-pr-merge/SKILL.md deleted file mode 100644 index 7066d2adb..000000000 --- a/.agents/skills/openclaw-pr-merge/SKILL.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: openclaw-pr-merge -description: Perform deterministic OpenClaw PR merge, verify merged state, and clean up after landing. -version: 1.0.0 -author: OK Code -tags: - - openclaw - - maintainer - - pr-merge - - git -tools: - - terminal - - filesystem - - git -triggers: - - use when the user asks to merge an OpenClaw PR - - use when the user says merge-pr - - use when the user wants the PR landed and cleaned up - - use when the user asks for a deterministic squash merge flow ---- - -# OpenClaw PR Merge - -Use this skill only after review and prepare are complete. -The goal is a deterministic landing with verification, attribution, and cleanup. - -## Source of truth - -- `openclaw/maintainers/.agents/skills/PR_WORKFLOW.md` -- Repo-local policy in the target repo, especially `AGENTS.md` - -## Merge rules - -- Merge only when findings are resolved and checks are green. -- Prefer deterministic squash merge flow with explicit subject/body. -- Verify the PR ends in `MERGED` state. -- Do not use auto-merge to bypass maintainer judgment. - -## After merge - -- Leave a PR comment that explains what was merged and include the SHAs. -- Clean up the PR worktree. -- Run contributor attribution updates when a new contributor landed and the repo - policy requires it. - -## Go / no-go - -- Required checks are green or intentionally absent. -- Branch is not behind `main` in a way that matters for the merge. -- Review and prep artifacts exist and are consistent. diff --git a/.agents/skills/openclaw-pr-ops/SKILL.md b/.agents/skills/openclaw-pr-ops/SKILL.md deleted file mode 100644 index e438d4c77..000000000 --- a/.agents/skills/openclaw-pr-ops/SKILL.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -name: openclaw-pr-ops -description: Queue, claim, hand off, and record OpenClaw PR maintainer work using the pr-ops layer. -version: 1.0.0 -author: OK Code -tags: - - openclaw - - maintainer - - pr-ops - - pull-requests -tools: - - terminal - - filesystem - - git -triggers: - - use when the user asks to plan or queue PRs for OpenClaw - - use when the user asks for the next PR to review - - use when the user needs a Codex/Claude PR handoff prompt - - use when the user asks to record merge, close, or defer decisions - - use when the user mentions pr-ops, claims, queue, or stats ---- - -# OpenClaw PR Ops - -Use this skill for the maintainer queue layer in the `openclaw/maintainers` repo. -The goal is to pick the next useful PR, prepare the reviewer handoff, and record -the final decision without doing GitHub write actions in pr-ops. - -## Source of truth - -- `openclaw/maintainers/README.md` -- `openclaw/maintainers/.agents/skills/PR_WORKFLOW.md` - -## Core rules - -- Keep the queue dedupe-first. -- Prefer claim-aware selection when multiple maintainers or agents are active. -- `pr-ops` plans and tracks work; the reviewer agent in `openclaw/openclaw` - performs review, merge, and close actions. -- Do not merge or close PRs directly from this layer. - -## Workflow - -1. Refresh the queue with `scripts/pr-plan`. -2. Select the next item with `scripts/pr-next`. -3. Generate the reviewer prompt with `scripts/pr-handoff --tool codex`. -4. After the reviewer finishes, persist the outcome with - `scripts/pr-decide --decision --pr `. -5. Check progress with `scripts/pr-stats`. - -## Required handoff content - -- Representative PR and URL -- Origin PR when the item is part of a cluster -- Cluster members and pending members -- Queue lane and rationale -- Policy flags, if any -- Explicit boundary: the reviewer agent does GitHub actions; pr-ops records state - -## Decision rules - -- `merge` for the PR that actually landed. -- `close_duplicate` for cluster duplicates that are now redundant. -- `close_not_planned` when the PR is not part of the current plan. -- `defer` only when the PR needs more time or a broader dependency is unresolved. diff --git a/.agents/skills/openclaw-pr-prepare/SKILL.md b/.agents/skills/openclaw-pr-prepare/SKILL.md deleted file mode 100644 index 12d04b5f4..000000000 --- a/.agents/skills/openclaw-pr-prepare/SKILL.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -name: openclaw-pr-prepare -description: Fix OpenClaw PR findings on the PR head branch, run gates, and make the branch ready for merge. -version: 1.0.0 -author: OK Code -tags: - - openclaw - - maintainer - - pr-prepare - - implementation -tools: - - terminal - - filesystem - - git -triggers: - - use when the user asks to prepare an OpenClaw PR for merge - - use when the user asks to fix review findings on a PR - - use when the user says prepare-pr - - use when the user wants the PR head branch updated and gated ---- - -# OpenClaw PR Prepare - -Use this skill after review findings exist and the PR needs implementation work. -The job is to make the PR merge-ready on its head branch, not to merge it. - -## Source of truth - -- `openclaw/maintainers/.agents/skills/PR_WORKFLOW.md` -- Repo-local policy in the target repo, especially `AGENTS.md` - -## Working rules - -- Start from the PR head branch. -- Fix blocker and important findings first. -- Reuse existing logic where possible instead of adding parallel code paths. -- Keep types strict and boundaries validated. -- Prefer root-cause fixes over local patches. - -## Gates - -- Run the repo-local gate set before declaring ready. -- In OpenClaw, default to `pnpm build`, `pnpm check`, and `pnpm test` - unless the repo-local policy explicitly allows a docs-only exception. -- Treat unrelated baseline failures as background noise only when they are - reproduced on `origin/main` and are clearly not caused by the PR. - -## Commit hygiene - -- Use concise, action-oriented commit subjects. -- Keep changes grouped by concern. -- Add changelog or docs updates when repo policy requires them. - -## Exit criteria - -- Findings resolved or explicitly deferred with reason. -- Verification run and recorded. -- Branch is ready for `/merge-pr`. diff --git a/.agents/skills/openclaw-pr-review/SKILL.md b/.agents/skills/openclaw-pr-review/SKILL.md deleted file mode 100644 index 1720b7bc4..000000000 --- a/.agents/skills/openclaw-pr-review/SKILL.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -name: openclaw-pr-review -description: Review OpenClaw PRs for correctness, scope, tests, docs, and security before any fixes are made. -version: 1.0.0 -author: OK Code -tags: - - openclaw - - maintainer - - pr-review - - code-review -tools: - - terminal - - filesystem - - git -triggers: - - use when the user asks to review an OpenClaw PR - - use when the user wants findings before fixing code - - use when the user says review-pr or pr review - - use when the user asks for review-only, not implementation ---- - -# OpenClaw PR Review - -Use this skill to review a PR without changing code. -The output should be a clear recommendation plus actionable findings. - -## Source of truth - -- `openclaw/maintainers/.agents/skills/PR_WORKFLOW.md` -- `openclaw/maintainers/README.md` -- Repo-local policy in the target repo, especially `AGENTS.md` - -## Review mode - -- Stay on review-only paths. -- Prefer `gh pr view` and `gh pr diff` over ad hoc exploration. -- Do not switch branches or mutate the target codebase during review. - -## What to check - -- Does the PR solve a real problem? -- Is the implementation the best scoped fix? -- Are tests meaningful and sufficient? -- Are docs, changelog, and user-facing notes updated when required? -- Are there correctness, security, or trust-boundary issues? - -## Output shape - -- Recommendation: `ready`, `needs work`, `needs discussion`, or `close` -- Findings ordered by severity -- Test coverage and validation gaps -- Any follow-up questions or required assumptions - -## Stop conditions - -- Do not approve behavior you cannot verify. -- Stop if the problem statement is unclear or unconfirmed. -- Escalate if the fix would require broad architecture changes outside the PR scope. diff --git a/.plans/11-openclaw-provider-rollout.md b/.plans/11-openclaw-provider-rollout.md deleted file mode 100644 index a2df349a5..000000000 --- a/.plans/11-openclaw-provider-rollout.md +++ /dev/null @@ -1,254 +0,0 @@ -# Plan: Roll Out OpenClaw as an OK Code Provider - -## Summary - -Add **OpenClaw** to OK Code as a third provider runtime alongside `codex` and `claudeAgent`. - -This should be implemented as a **provider integration**, not as a one-off model entry. OpenClaw would own model routing and execution, while OK Code continues to own thread UX, provider selection, health reporting, and session orchestration. - -## Goals - -- Let users select **OpenClaw** from the provider/model picker. -- Support a practical MVP quickly without blocking on full provider parity. -- Preserve OK Code's current provider architecture instead of adding ad-hoc special cases. -- Keep the rollout safe by phasing higher-risk features after the basic session loop works. - -## Non-goals (initial rollout) - -- Full OpenClaw tool/event parity on day one. -- Perfect rollback/history parity before an MVP exists. -- Replacing existing Codex or Claude provider flows. -- Supporting every OpenClaw capability in the first release. - -## Recommended rollout shape - -- **Phase 0:** architecture + contracts groundwork -- **Phase 1:** UI + config plumbing -- **Phase 2:** MVP OpenClaw adapter (usable provider) -- **Phase 3:** richer session/history/approval parity -- **Phase 4:** polish, observability, and hardening - ---- - -## Phase 0 — Architecture and contract groundwork - -### Objective - -Define OpenClaw as a first-class provider in contracts and app state without yet making it fully executable. - -### Checklist - -- [ ] Add `openclaw` to `ProviderKind`. -- [ ] Decide whether OpenClaw-specific options live under `providerOptions.openclaw` or a new dedicated config object. -- [ ] Define initial OpenClaw provider start options, likely including: - - [ ] gateway/base URL - - [ ] auth token or password reference - - [ ] default agent/session mode - - [ ] optional model override -- [ ] Add OpenClaw model option strategy: - - [ ] start with static built-ins **or** - - [ ] start with manual custom model slugs only **or** - - [ ] fetch dynamic models later as a follow-up -- [ ] Set a clear default for session model switching support (`in-session`, `restart-session`, or `unsupported`). -- [ ] Decide MVP behavior for unsupported provider features: - - [ ] rollback - - [ ] thread snapshot reads - - [ ] approval routing - - [ ] structured user input - -### Likely files - -- `packages/contracts/src/orchestration.ts` -- `packages/contracts/src/model.ts` -- `packages/contracts/src/provider.ts` -- `packages/contracts/src/server.ts` - -### Exit criteria - -- `openclaw` exists in contracts/types without breaking existing providers. -- MVP capability decisions are documented and not left implicit. - ---- - -## Phase 1 — UI and settings plumbing - -### Objective - -Make OpenClaw visible and selectable in the OK Code UI, even if execution is still stubbed or gated behind availability checks. - -### Checklist - -- [ ] Add OpenClaw to provider picker options. -- [ ] Add an icon/label treatment for OpenClaw in the model picker. -- [ ] Update provider-specific app settings and custom model handling. -- [ ] Add settings UI for OpenClaw connection details. -- [ ] Add provider install/configuration copy in settings. -- [ ] Extend provider health banner/status display to recognize OpenClaw. -- [ ] Ensure draft persistence/store logic can serialize OpenClaw provider selections. -- [ ] Ensure thread/session display can show `providerName: "openclaw"` cleanly. - -### Likely files - -- `apps/web/src/session-logic.ts` -- `apps/web/src/components/chat/ProviderModelPicker.tsx` -- `apps/web/src/routes/_chat.settings.tsx` -- `apps/web/src/appSettings.ts` -- `apps/web/src/composerDraftStore.ts` -- `apps/web/src/store.ts` - -### Notes - -For MVP, it is acceptable for the UI to show OpenClaw only when: -- the provider is configured, or -- a feature flag is enabled. - -### Exit criteria - -- Users can select OpenClaw in the UI. -- App state and settings persist the choice correctly. -- Existing Codex/Claude picker behavior remains unchanged. - ---- - -## Phase 2 — MVP OpenClaw adapter - -### Objective - -Ship a usable OpenClaw-backed provider that supports the core thread loop: -**start session → send turn → stream output → stop session**. - -### Checklist - -- [ ] Implement `OpenClawAdapter` using the existing `ProviderAdapter` contract. -- [ ] Register the adapter in the provider registry. -- [ ] Wire the adapter into server layers. -- [ ] Map OK Code thread lifecycle to OpenClaw session lifecycle. -- [ ] Implement `startSession`. -- [ ] Implement `sendTurn`. -- [ ] Implement `interruptTurn` if OpenClaw supports it cleanly; otherwise define MVP fallback behavior. -- [ ] Implement `stopSession`. -- [ ] Implement `listSessions`. -- [ ] Stream assistant output back into OK Code's canonical provider event stream. -- [ ] Normalize OpenClaw errors into OK Code provider errors. -- [ ] Add provider health probing for OpenClaw reachability/authentication. -- [ ] Gate the provider as unavailable when config or auth is missing. - -### Likely files - -- `apps/server/src/provider/Layers/OpenClawAdapter.ts` -- `apps/server/src/provider/Services/OpenClawAdapter.ts` -- `apps/server/src/provider/Layers/ProviderAdapterRegistry.ts` -- `apps/server/src/serverLayers.ts` -- `apps/server/src/provider/Layers/ProviderHealth.ts` - -### MVP behavioral constraints - -- [ ] One OpenClaw session per OK Code thread. -- [ ] Basic text streaming required. -- [ ] Rich tool/request parity optional. -- [ ] Rollback may be unsupported initially if surfaced honestly. -- [ ] Thread readback may be shallow or best-effort in MVP. - -### Exit criteria - -- A user can select OpenClaw and successfully complete a normal prompt/response cycle. -- Streaming feels native enough for regular use. -- Health/auth failures are visible and understandable. - ---- - -## Phase 3 — Session, history, and approval parity - -### Objective - -Reduce the behavioral gap between OpenClaw and the native Codex/Claude integrations. - -### Checklist - -- [ ] Implement `readThread` with meaningful history reconstruction. -- [ ] Implement `rollbackThread`, or explicitly model why rollback is unsupported. -- [ ] Improve resume/reconnect behavior for long-lived OpenClaw sessions. -- [ ] Decide how approval requests are surfaced: - - [ ] OpenClaw-native approvals only - - [ ] translated into OK Code approvals - - [ ] hybrid with clear ownership -- [ ] Support structured user-input prompts if OpenClaw exposes them. -- [ ] Improve event translation for tools, progress states, and status updates. -- [ ] Preserve provider refs/IDs where useful for resumability and debugging. - -### Design questions to resolve - -- [ ] Does OK Code own approvals, or does OpenClaw own approvals? -- [ ] How should rollback behave if OpenClaw sessions are not trivially rewindable? -- [ ] Should OpenClaw session state be persisted directly, indirectly, or only via transcript replay? - -### Exit criteria - -- OpenClaw threads behave predictably across refreshes, resumes, and longer conversations. -- Missing parity is minimal and intentional rather than accidental. - ---- - -## Phase 4 — Polish, dynamic models, and hardening - -### Objective - -Make OpenClaw feel like a polished built-in provider rather than a basic bridge. - -### Checklist - -- [ ] Add dynamic model discovery from OpenClaw if available. -- [ ] Support richer provider-specific model options where meaningful. -- [ ] Improve install/setup guidance in settings. -- [ ] Add better telemetry/logging around OpenClaw session failures. -- [ ] Add browser/manual test coverage for picker/settings flows. -- [ ] Add adapter-focused unit tests and integration tests. -- [ ] Add resilience for network interruptions and transient gateway failures. -- [ ] Add clearer recovery messaging for auth/config/session mismatch errors. - -### Exit criteria - -- Setup is understandable. -- Errors are debuggable. -- OpenClaw feels like a normal provider choice inside OK Code. - ---- - -## Cross-cutting risks - -- OpenClaw session semantics may not map perfectly onto OK Code's provider assumptions. -- Approval handling may become confusing if both systems try to own the same UX. -- Rollback/history parity may require deeper transcript/state mapping than the MVP needs. -- Provider model discovery may be dynamic in OpenClaw while OK Code currently prefers static provider model catalogs. - -## Cross-cutting decisions to keep explicit - -- [ ] Is OpenClaw primarily a **remote runtime** or a **thin bridge** in MVP? -- [ ] Are OpenClaw models static, custom-only, or dynamically discovered in the first release? -- [ ] Is rollback required for launch, best-effort, or explicitly unsupported? -- [ ] Who owns approval UX in the integrated flow? - -## Suggested order of implementation - -1. Phase 0 contracts -2. Phase 1 picker/settings plumbing -3. Phase 2 MVP adapter + health checks -4. Manual smoke testing -5. Phase 3 parity work -6. Phase 4 polish/hardening - -## Validation checklist - -- [ ] Typecheck passes across contracts, web, and server. -- [ ] Existing Codex and Claude flows still work. -- [ ] OpenClaw shows correct provider health in settings/chat. -- [ ] New OpenClaw thread can start, stream, and stop successfully. -- [ ] Misconfigured OpenClaw setup fails with a useful message. -- [ ] Re-opening the app preserves provider selection and model choice. - -## Done criteria - -- OpenClaw is selectable as a provider in OK Code. -- A normal thread can run end-to-end through OpenClaw. -- Provider health and configuration are understandable. -- Advanced parity gaps are documented and tracked rather than hidden. diff --git a/.plans/README.md b/.plans/README.md index 07fb7f108..e940729c0 100644 --- a/.plans/README.md +++ b/.plans/README.md @@ -13,7 +13,6 @@ 9. `09-event-state-test-expansion.md` - Tests for renderer event handling 10. `10-unify-process-session-abstraction.md` - Unified runtime-session interface 11. `11-effect.md` - Effect.js phased migration *(in progress)* -12. `11-openclaw-provider-rollout.md` - OpenClaw provider rollout 13. `13-provider-service-integration-tests.md` - ProviderService integration tests 14. `14-server-authoritative-event-sourcing-cleanup.md` - Server-authoritative event sourcing 15. `16-pr89-review-remediation-phases.md` - PR #89 remediation strategy *(in progress)* diff --git a/apps/server/src/doctor.ts b/apps/server/src/doctor.ts index 1456032b6..fb2174581 100644 --- a/apps/server/src/doctor.ts +++ b/apps/server/src/doctor.ts @@ -35,7 +35,6 @@ const PROVIDER_LABELS: Record = { claudeAgent: "Claude Code", copilot: "GitHub Copilot", gemini: "Gemini CLI", - openclaw: "OpenClaw", }; function printStatus(status: ServerProviderStatus): void { diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 43c4b80db..4abc32460 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -10,45 +10,11 @@ import { version } from "../package.json" with { type: "json" }; import { ServerLive } from "./wsServer"; import { NetService } from "@okcode/shared/Net"; import { FetchHttpClient } from "effect/unstable/http"; -import { OpenclawGatewayConfig } from "./persistence/Services/OpenclawGatewayConfig"; const RuntimeLayer = Layer.empty.pipe( Layer.provideMerge(CliConfig.layer), Layer.provideMerge(ServerLive), Layer.provideMerge(OpenLive), - Layer.provideMerge( - Layer.succeed(OpenclawGatewayConfig, { - getSummary: () => - Effect.succeed({ - gatewayUrl: null, - hasSharedSecret: false, - deviceId: null, - devicePublicKey: null, - deviceFingerprint: null, - hasDeviceToken: false, - deviceTokenRole: null, - deviceTokenScopes: [], - updatedAt: null, - }), - getStored: () => Effect.succeed(null), - save: () => Effect.die("unexpected openclaw save"), - resolveForConnect: () => Effect.succeed(null), - saveDeviceToken: () => Effect.void, - clearDeviceToken: () => Effect.void, - resetDeviceState: () => - Effect.succeed({ - gatewayUrl: null, - hasSharedSecret: false, - deviceId: null, - devicePublicKey: null, - deviceFingerprint: null, - hasDeviceToken: false, - deviceTokenRole: null, - deviceTokenScopes: [], - updatedAt: null, - }), - }), - ), Layer.provideMerge(NetService.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(FetchHttpClient.layer), diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index b6d894c2c..8521f7ccb 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -15,7 +15,6 @@ import { NetService } from "@okcode/shared/Net"; import { CliConfig, okcodeCli, type CliConfigShape } from "./main"; import { ServerConfig, type ServerConfigShape } from "./config"; import { Open, type OpenShape } from "./open"; -import { OpenclawGatewayConfig } from "./persistence/Services/OpenclawGatewayConfig"; import { Server, type ServerShape } from "./wsServer"; const start = vi.fn(() => undefined); @@ -56,37 +55,6 @@ const testLayer = Layer.mergeAll( start: serverStart, stopSignal: Effect.void, } satisfies ServerShape), - Layer.succeed(OpenclawGatewayConfig, { - getSummary: () => - Effect.succeed({ - gatewayUrl: null, - hasSharedSecret: false, - deviceId: null, - devicePublicKey: null, - deviceFingerprint: null, - hasDeviceToken: false, - deviceTokenRole: null, - deviceTokenScopes: [], - updatedAt: null, - }), - getStored: () => Effect.succeed(null), - save: () => Effect.die("unexpected openclaw save"), - resolveForConnect: () => Effect.succeed(null), - saveDeviceToken: () => Effect.void, - clearDeviceToken: () => Effect.void, - resetDeviceState: () => - Effect.succeed({ - gatewayUrl: null, - hasSharedSecret: false, - deviceId: null, - devicePublicKey: null, - deviceFingerprint: null, - hasDeviceToken: false, - deviceTokenRole: null, - deviceTokenScopes: [], - updatedAt: null, - }), - }), Layer.succeed(Open, { openBrowser: (_target: string) => Effect.void, openInEditor: () => Effect.void, diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 986425347..f10cf55ff 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -20,7 +20,6 @@ import { import { fixPath, resolveBaseDir } from "./os-jank"; import { Open } from "./open"; import * as SqlitePersistence from "./persistence/Layers/Sqlite"; -import { OpenclawGatewayConfigLive } from "./persistence/Layers/OpenclawGatewayConfig"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; import { ProviderRuntimeEventFeedLive } from "./provider/Layers/ProviderRuntimeEventFeed"; @@ -197,7 +196,6 @@ const LayerLive = (input: CliInput) => Layer.provideMerge(makeServerRuntimeServicesLayer()), Layer.provideMerge(makeServerProviderLayer()), Layer.provideMerge(ProviderRuntimeEventFeedLive), - Layer.provideMerge(OpenclawGatewayConfigLive), Layer.provideMerge(ProviderHealthLive), Layer.provideMerge(SqlitePersistence.layerConfig), Layer.provideMerge(ServerLoggerLive), diff --git a/apps/server/src/openclaw/GatewayClient.test.ts b/apps/server/src/openclaw/GatewayClient.test.ts deleted file mode 100644 index f70767bef..000000000 --- a/apps/server/src/openclaw/GatewayClient.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { WebSocketServer, type WebSocket } from "ws"; - -import { generateOpenclawDeviceIdentity } from "./deviceAuth.ts"; -import { OpenclawGatewayClient } from "./GatewayClient.ts"; - -const servers = new Set(); - -type GatewayRequestFrame = { - type?: unknown; - id?: unknown; - method?: unknown; - params?: { - auth?: { - password?: unknown; - token?: unknown; - deviceToken?: unknown; - }; - }; -}; - -type GatewayAuthPayload = NonNullable["auth"]; - -afterEach(async () => { - await Promise.all( - [...servers].map( - (server) => - new Promise((resolve) => { - for (const client of server.clients) { - client.terminate(); - } - server.close(() => resolve()); - }), - ), - ); - servers.clear(); -}); - -async function createGatewayServer( - onConnection: (socket: WebSocket) => void, -): Promise<{ url: string }> { - const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); - servers.add(server); - await new Promise((resolve) => { - server.once("listening", () => resolve()); - }); - server.on("connection", onConnection); - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("Expected a TCP address for the test websocket server."); - } - return { url: `ws://127.0.0.1:${address.port}` }; -} - -function sendChallenge(socket: WebSocket): void { - socket.send( - JSON.stringify({ - type: "event", - event: "connect.challenge", - payload: { nonce: "nonce-123", ts: Date.now() }, - }), - ); -} - -describe("OpenclawGatewayClient", () => { - it("retries with auth.password when a gateway rejects token-style shared-secret auth", async () => { - const attemptedAuths: GatewayAuthPayload[] = []; - const gateway = await createGatewayServer((socket) => { - sendChallenge(socket); - socket.on("message", (data) => { - const message = JSON.parse(data.toString()) as GatewayRequestFrame; - if (message.type === "req" && message.method === "connect") { - attemptedAuths.push(message.params?.auth); - if (message.params?.auth?.password === "topsecret") { - socket.send( - JSON.stringify({ - type: "res", - id: message.id, - ok: true, - payload: { type: "hello-ok", protocol: 3 }, - }), - ); - return; - } - - socket.send( - JSON.stringify({ - type: "res", - id: message.id, - ok: false, - error: { - code: "INVALID_REQUEST", - message: "unauthorized: gateway password missing", - details: { - code: "AUTH_PASSWORD_MISSING", - reason: "provide gateway auth password", - recommendedNextStep: "update_auth_configuration", - }, - }, - }), - ); - } - }); - }); - - const connection = await OpenclawGatewayClient.connect({ - url: gateway.url, - identity: generateOpenclawDeviceIdentity(), - sharedSecret: "topsecret", - clientId: "okcode", - clientVersion: "test", - clientPlatform: "macos", - clientMode: "operator", - locale: "en-US", - userAgent: "okcode/test", - role: "operator", - scopes: ["operator.read", "operator.write"], - }); - - expect(attemptedAuths.some((auth) => auth?.password === "topsecret")).toBe(true); - await connection.client.close(); - }); -}); diff --git a/apps/server/src/openclaw/GatewayClient.ts b/apps/server/src/openclaw/GatewayClient.ts deleted file mode 100644 index bf709b2b7..000000000 --- a/apps/server/src/openclaw/GatewayClient.ts +++ /dev/null @@ -1,580 +0,0 @@ -import NodeWebSocket from "ws"; - -import type { OpenclawDeviceIdentity } from "./deviceAuth.ts"; -import { signOpenclawDeviceChallenge } from "./deviceAuth.ts"; -import { - assertRequiredMethods, - extractHelloMethods, - extractHelloPayload, - formatGatewayError, - OPENCLAW_OPERATOR_SCOPES, - OPENCLAW_PROTOCOL_VERSION, - parseGatewayError, - parseGatewayFrame, - readString, - type GatewayFrame, - type OpenclawHelloAuth, - type OpenclawHelloPayload, - type ParsedGatewayError, -} from "./protocol.ts"; - -const WS_CONNECT_TIMEOUT_MS = 10_000; -const REQUEST_TIMEOUT_MS = 30_000; - -export interface OpenclawGatewayClientOptions { - readonly url: string; - readonly identity: OpenclawDeviceIdentity; - readonly sharedSecret?: string; - readonly deviceToken?: string; - readonly deviceTokenRole?: string; - readonly deviceTokenScopes?: ReadonlyArray; - readonly clientId: string; - readonly clientVersion: string; - readonly clientPlatform: string; - readonly clientMode: string; - readonly locale: string; - readonly userAgent: string; - readonly role?: string; - readonly scopes?: ReadonlyArray; - readonly requiredMethods?: ReadonlyArray; -} - -export interface OpenclawGatewayConnectResult { - readonly hello: OpenclawHelloPayload | undefined; - readonly auth: OpenclawHelloAuth | undefined; - readonly methods: Set; - readonly usedStoredDeviceToken: boolean; -} - -interface PendingRequest { - readonly method: string; - readonly resolve: (payload: unknown) => void; - readonly reject: (error: unknown) => void; - readonly timeout: ReturnType; -} - -interface PendingEventWaiter { - readonly eventName: string; - readonly resolve: (payload: Record | undefined) => void; - readonly reject: (error: unknown) => void; - readonly timeout: ReturnType; -} - -export class OpenclawGatewayClientError extends Error { - readonly gatewayError: ParsedGatewayError | undefined; - readonly socketCloseCode: number | undefined; - readonly socketCloseReason: string | undefined; - - constructor( - message: string, - options?: { - readonly gatewayError?: ParsedGatewayError; - readonly socketCloseCode?: number; - readonly socketCloseReason?: string; - readonly cause?: unknown; - }, - ) { - super(message, options?.cause !== undefined ? { cause: options.cause } : undefined); - this.name = "OpenclawGatewayClientError"; - this.gatewayError = options?.gatewayError; - this.socketCloseCode = options?.socketCloseCode; - this.socketCloseReason = options?.socketCloseReason; - } -} - -function uniqueScopes(scopes: ReadonlyArray | undefined): string[] { - const values = new Set(); - for (const scope of scopes ?? []) { - const trimmed = scope.trim(); - if (trimmed.length > 0) { - values.add(trimmed); - } - } - return [...values]; -} - -function isPasswordAuthError(error: ParsedGatewayError | undefined): boolean { - return ( - error?.detailCode === "AUTH_PASSWORD_MISSING" || error?.detailCode === "AUTH_PASSWORD_MISMATCH" - ); -} - -function closeDetail(code: number | undefined, reason: string | undefined): string { - if (code === undefined) { - return ""; - } - return reason && reason.length > 0 ? ` (code ${code}: ${reason})` : ` (code ${code})`; -} - -function clientErrorOptions(input: { - readonly gatewayError: ParsedGatewayError | undefined; - readonly socketCloseCode: number | undefined; - readonly socketCloseReason: string | undefined; - readonly cause: unknown; -}) { - return { - ...(input.gatewayError !== undefined ? { gatewayError: input.gatewayError } : {}), - ...(input.socketCloseCode !== undefined ? { socketCloseCode: input.socketCloseCode } : {}), - ...(input.socketCloseReason !== undefined - ? { socketCloseReason: input.socketCloseReason } - : {}), - ...(input.cause !== undefined ? { cause: input.cause } : {}), - }; -} - -export class OpenclawGatewayClient { - static async connect(options: OpenclawGatewayClientOptions): Promise<{ - client: OpenclawGatewayClient; - connect: OpenclawGatewayConnectResult; - }> { - const client = new OpenclawGatewayClient(options); - try { - const connectResult = await client.connectInternal(); - return { client, connect: connectResult }; - } catch (error) { - await client.close(); - throw error; - } - } - - private readonly options: OpenclawGatewayClientOptions; - private ws: NodeWebSocket | null = null; - private nextRequestId = 1; - private closed = false; - private closeCode: number | undefined = undefined; - private closeReason: string | undefined = undefined; - private readonly pendingRequests = new Map(); - private readonly pendingEventWaiters = new Set(); - private readonly bufferedEvents: GatewayFrame[] = []; - private readonly eventListeners = new Set<(event: GatewayFrame) => void>(); - private readonly closeListeners = new Set<(error?: OpenclawGatewayClientError) => void>(); - - readonly methods = new Set(); - hello: OpenclawHelloPayload | undefined = undefined; - auth: OpenclawHelloAuth | undefined = undefined; - - private constructor(options: OpenclawGatewayClientOptions) { - this.options = options; - } - - onEvent(listener: (event: GatewayFrame) => void): () => void { - this.eventListeners.add(listener); - return () => { - this.eventListeners.delete(listener); - }; - } - - onClose(listener: (error?: OpenclawGatewayClientError) => void): () => void { - this.closeListeners.add(listener); - return () => { - this.closeListeners.delete(listener); - }; - } - - async request(method: string, params?: Record, timeoutMs = REQUEST_TIMEOUT_MS) { - const socket = this.ws; - if (!socket || socket.readyState !== NodeWebSocket.OPEN) { - throw new OpenclawGatewayClientError(`WebSocket is not open for request '${method}'.`, { - ...clientErrorOptions({ - gatewayError: undefined, - socketCloseCode: this.closeCode, - socketCloseReason: this.closeReason, - cause: undefined, - }), - }); - } - - const id = String(this.nextRequestId++); - const payload = JSON.stringify({ - type: "req", - id, - method, - ...(params !== undefined ? { params } : {}), - }); - - return await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.pendingRequests.delete(id); - reject( - new OpenclawGatewayClientError( - `Gateway request '${method}' timed out after ${timeoutMs}ms.`, - clientErrorOptions({ - gatewayError: undefined, - socketCloseCode: this.closeCode, - socketCloseReason: this.closeReason, - cause: undefined, - }), - ), - ); - }, timeoutMs); - - this.pendingRequests.set(id, { - method, - resolve, - reject, - timeout, - }); - - try { - socket.send(payload); - } catch (cause) { - clearTimeout(timeout); - this.pendingRequests.delete(id); - reject( - new OpenclawGatewayClientError(`Failed to send gateway request '${method}'.`, { - ...clientErrorOptions({ - gatewayError: undefined, - cause, - socketCloseCode: this.closeCode, - socketCloseReason: this.closeReason, - }), - }), - ); - } - }); - } - - async waitForEvent(eventName: string, timeoutMs = REQUEST_TIMEOUT_MS) { - const bufferedIndex = this.bufferedEvents.findIndex( - (event) => event.type === "event" && event.event === eventName, - ); - if (bufferedIndex >= 0) { - const [event] = this.bufferedEvents.splice(bufferedIndex, 1); - if (event) { - return this.framePayload(event); - } - } - - return await new Promise | undefined>((resolve, reject) => { - const timeout = setTimeout(() => { - this.pendingEventWaiters.delete(waiter); - reject( - new OpenclawGatewayClientError( - `Gateway event '${eventName}' timed out after ${timeoutMs}ms.`, - clientErrorOptions({ - gatewayError: undefined, - socketCloseCode: this.closeCode, - socketCloseReason: this.closeReason, - cause: undefined, - }), - ), - ); - }, timeoutMs); - - const waiter: PendingEventWaiter = { - eventName, - resolve: (payload) => { - clearTimeout(timeout); - resolve(payload); - }, - reject: (error) => { - clearTimeout(timeout); - reject(error); - }, - timeout, - }; - this.pendingEventWaiters.add(waiter); - }); - } - - async close(): Promise { - this.closed = true; - const socket = this.ws; - this.ws = null; - if (!socket) { - return; - } - if (socket.readyState === NodeWebSocket.CLOSED || socket.readyState === NodeWebSocket.CLOSING) { - return; - } - await new Promise((resolve) => { - socket.once("close", () => resolve()); - socket.close(); - }); - } - - private async connectInternal(): Promise { - const canUseStoredDeviceToken = - typeof this.options.deviceToken === "string" && this.options.deviceToken.length > 0; - - try { - return await this.performConnectAttempt("sharedToken"); - } catch (caughtError) { - let error = caughtError; - let parsedError = - error instanceof OpenclawGatewayClientError ? error.gatewayError : undefined; - - if (this.options.sharedSecret !== undefined && isPasswordAuthError(parsedError)) { - await this.closeCurrentSocket(); - try { - return await this.performConnectAttempt("sharedPassword"); - } catch (passwordError) { - error = passwordError; - parsedError = - passwordError instanceof OpenclawGatewayClientError - ? passwordError.gatewayError - : undefined; - } - } - - const shouldRetryWithDeviceToken = - canUseStoredDeviceToken && - parsedError?.canRetryWithDeviceToken === true && - this.options.sharedSecret !== undefined; - if (shouldRetryWithDeviceToken) { - await this.closeCurrentSocket(); - return await this.performConnectAttempt("deviceToken"); - } - - throw error; - } - } - - private async performConnectAttempt( - authMode: "sharedToken" | "sharedPassword" | "deviceToken", - ): Promise { - await this.openSocket(); - const challenge = await this.waitForEvent("connect.challenge"); - const nonce = readString(challenge?.nonce); - if (!nonce) { - throw new OpenclawGatewayClientError("Gateway challenge did not include a nonce."); - } - - const signedAt = - typeof challenge?.ts === "number" && Number.isFinite(challenge.ts) - ? challenge.ts - : Date.now(); - const role = this.options.role ?? "operator"; - const scopes = - authMode === "deviceToken" && uniqueScopes(this.options.deviceTokenScopes).length > 0 - ? uniqueScopes(this.options.deviceTokenScopes) - : uniqueScopes(this.options.scopes ?? OPENCLAW_OPERATOR_SCOPES); - const authToken = - authMode === "deviceToken" - ? (this.options.deviceToken ?? "") - : authMode === "sharedToken" - ? (this.options.sharedSecret ?? "") - : ""; - const authPassword = authMode === "sharedPassword" ? (this.options.sharedSecret ?? "") : ""; - const signedDevice = signOpenclawDeviceChallenge(this.options.identity, { - clientId: this.options.clientId, - clientMode: this.options.clientMode, - role, - scopes, - token: authToken, - nonce, - signedAt, - }); - - const helloPayload = await this.request("connect", { - minProtocol: OPENCLAW_PROTOCOL_VERSION, - maxProtocol: OPENCLAW_PROTOCOL_VERSION, - client: { - id: this.options.clientId, - version: this.options.clientVersion, - platform: this.options.clientPlatform, - mode: this.options.clientMode, - }, - role, - scopes, - caps: [], - commands: [], - permissions: {}, - ...(authMode === "sharedToken" && authToken.length > 0 ? { auth: { token: authToken } } : {}), - ...(authMode === "sharedPassword" && authPassword.length > 0 - ? { auth: { password: authPassword } } - : {}), - ...(authMode === "deviceToken" && authToken.length > 0 - ? { auth: { deviceToken: authToken } } - : {}), - locale: this.options.locale, - userAgent: this.options.userAgent, - device: signedDevice, - }); - - const hello = extractHelloPayload(helloPayload); - const methods = extractHelloMethods(hello); - if (this.options.requiredMethods && this.options.requiredMethods.length > 0) { - assertRequiredMethods(methods, this.options.requiredMethods); - } - - this.hello = hello; - this.auth = hello?.auth; - this.methods.clear(); - for (const method of methods) { - this.methods.add(method); - } - - return { - hello, - auth: hello?.auth, - methods, - usedStoredDeviceToken: authMode === "deviceToken", - }; - } - - private framePayload(frame: GatewayFrame): Record | undefined { - return typeof frame.payload === "object" && frame.payload !== null - ? (frame.payload as Record) - : undefined; - } - - private async openSocket(): Promise { - await this.closeCurrentSocket(); - this.closeCode = undefined; - this.closeReason = undefined; - this.closed = false; - - this.ws = await new Promise((resolve, reject) => { - const socket = new NodeWebSocket(this.options.url); - const timeout = setTimeout(() => { - socket.close(); - reject( - new OpenclawGatewayClientError( - `WebSocket connection to ${this.options.url} timed out after ${WS_CONNECT_TIMEOUT_MS}ms.`, - ), - ); - }, WS_CONNECT_TIMEOUT_MS); - - socket.on("open", () => { - clearTimeout(timeout); - resolve(socket); - }); - socket.on("error", (cause) => { - clearTimeout(timeout); - reject( - new OpenclawGatewayClientError( - `WebSocket connection to ${this.options.url} failed: ${cause instanceof Error ? cause.message : String(cause)}`, - clientErrorOptions({ - gatewayError: undefined, - socketCloseCode: undefined, - socketCloseReason: undefined, - cause, - }), - ), - ); - }); - this.attachSocketHandlers(socket); - }); - } - - private attachSocketHandlers(socket: NodeWebSocket) { - socket.on("message", (data) => { - const frame = parseGatewayFrame(data); - if (!frame) { - return; - } - - if (frame.type === "res" && frame.id !== undefined && frame.id !== null) { - const pending = this.pendingRequests.get(String(frame.id)); - if (!pending) { - return; - } - clearTimeout(pending.timeout); - this.pendingRequests.delete(String(frame.id)); - if (frame.ok === true) { - pending.resolve(frame.payload); - return; - } - const gatewayError = parseGatewayError(frame.error); - pending.reject( - new OpenclawGatewayClientError( - formatGatewayError(gatewayError), - clientErrorOptions({ - gatewayError, - socketCloseCode: this.closeCode, - socketCloseReason: this.closeReason, - cause: undefined, - }), - ), - ); - return; - } - - if (frame.type === "event" && typeof frame.event === "string") { - let matchedWaiter = false; - for (const waiter of this.pendingEventWaiters) { - if (waiter.eventName === frame.event) { - matchedWaiter = true; - this.pendingEventWaiters.delete(waiter); - waiter.resolve(this.framePayload(frame)); - } - } - if (!matchedWaiter) { - this.bufferedEvents.push(frame); - } - } - - for (const listener of this.eventListeners) { - listener(frame); - } - }); - - socket.on("close", (code, reasonBuffer) => { - this.closeCode = code; - const reason = reasonBuffer.toString("utf8"); - this.closeReason = reason.length > 0 ? reason : undefined; - const error = - this.closed || (code === 1000 && !this.closeReason) - ? undefined - : new OpenclawGatewayClientError( - `WebSocket closed before the gateway exchange completed${closeDetail(code, this.closeReason)}.`, - clientErrorOptions({ - gatewayError: undefined, - socketCloseCode: code, - socketCloseReason: this.closeReason, - cause: undefined, - }), - ); - - for (const [, pending] of this.pendingRequests) { - clearTimeout(pending.timeout); - pending.reject( - error ?? - new OpenclawGatewayClientError(`Gateway request '${pending.method}' was interrupted.`), - ); - } - this.pendingRequests.clear(); - - for (const waiter of this.pendingEventWaiters) { - clearTimeout(waiter.timeout); - waiter.reject( - error ?? - new OpenclawGatewayClientError( - `Gateway event '${waiter.eventName}' was interrupted.`, - clientErrorOptions({ - gatewayError: undefined, - socketCloseCode: code, - socketCloseReason: this.closeReason, - cause: undefined, - }), - ), - ); - } - this.pendingEventWaiters.clear(); - - for (const listener of this.closeListeners) { - listener(error); - } - }); - } - - private async closeCurrentSocket() { - if (!this.ws) { - return; - } - const socket = this.ws; - this.ws = null; - await new Promise((resolve) => { - if ( - socket.readyState === NodeWebSocket.CLOSED || - socket.readyState === NodeWebSocket.CLOSING - ) { - resolve(); - return; - } - socket.once("close", () => resolve()); - socket.close(); - }); - } -} diff --git a/apps/server/src/openclaw/deviceAuth.ts b/apps/server/src/openclaw/deviceAuth.ts deleted file mode 100644 index 0e8142188..000000000 --- a/apps/server/src/openclaw/deviceAuth.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { createHash, createPrivateKey, generateKeyPairSync, sign } from "node:crypto"; - -export interface OpenclawDeviceIdentity { - readonly deviceId: string; - readonly deviceFingerprint: string; - readonly publicKey: string; - readonly privateKeyPem: string; -} - -export interface OpenclawSignedDeviceIdentity { - readonly id: string; - readonly publicKey: string; - readonly signature: string; - readonly signedAt: number; - readonly nonce: string; -} - -export interface OpenclawDeviceSigningParams { - readonly clientId: string; - readonly clientMode: string; - readonly role: string; - readonly scopes: ReadonlyArray; - readonly token: string; - readonly nonce: string; - readonly signedAt: number; -} - -function toBase64Url(buffer: Buffer): string { - return buffer.toString("base64url"); -} - -function decodeBase64Url(value: string): Buffer { - return Buffer.from(value, "base64url"); -} - -export function generateOpenclawDeviceIdentity(): OpenclawDeviceIdentity { - const { publicKey, privateKey } = generateKeyPairSync("ed25519"); - const publicJwk = publicKey.export({ format: "jwk" }); - if (typeof publicJwk.x !== "string") { - throw new Error("Failed to export OpenClaw device public key."); - } - - const rawPublicKey = decodeBase64Url(publicJwk.x); - const fingerprint = createHash("sha256").update(rawPublicKey).digest("hex"); - - return { - deviceId: fingerprint, - deviceFingerprint: fingerprint, - publicKey: toBase64Url(rawPublicKey), - privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(), - }; -} - -export function signOpenclawDeviceChallenge( - identity: OpenclawDeviceIdentity, - params: OpenclawDeviceSigningParams, -): OpenclawSignedDeviceIdentity { - const payload = [ - "v2", - identity.deviceId, - params.clientId, - params.clientMode, - params.role, - [...params.scopes].join(","), - String(params.signedAt), - params.token, - params.nonce, - ].join("|"); - - const signature = sign( - null, - Buffer.from(payload, "utf8"), - createPrivateKey(identity.privateKeyPem), - ); - return { - id: identity.deviceId, - publicKey: identity.publicKey, - signature: toBase64Url(signature), - signedAt: params.signedAt, - nonce: params.nonce, - }; -} diff --git a/apps/server/src/openclaw/protocol.ts b/apps/server/src/openclaw/protocol.ts deleted file mode 100644 index 76755fc54..000000000 --- a/apps/server/src/openclaw/protocol.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type NodeWebSocket from "ws"; - -export const OPENCLAW_PROTOCOL_VERSION = 3; -export const OPENCLAW_OPERATOR_SCOPES = ["operator.read", "operator.write"] as const; - -export type GatewayFrame = { - type?: unknown; - id?: unknown; - ok?: unknown; - method?: unknown; - event?: unknown; - payload?: unknown; - error?: { - code?: unknown; - message?: unknown; - details?: unknown; - }; -}; - -export interface ParsedGatewayError { - readonly message: string; - readonly code: string | undefined; - readonly detailCode: string | undefined; - readonly detailReason: string | undefined; - readonly recommendedNextStep: string | undefined; - readonly canRetryWithDeviceToken: boolean | undefined; -} - -export interface OpenclawHelloAuth { - readonly deviceToken: string | undefined; - readonly role: string | undefined; - readonly scopes: ReadonlyArray; -} - -export interface OpenclawHelloPayload { - readonly type: string | undefined; - readonly protocol: number | undefined; - readonly auth: OpenclawHelloAuth | undefined; - readonly features: - | { - readonly methods: ReadonlyArray | undefined; - } - | undefined; -} - -export function bufferToString(data: NodeWebSocket.Data): string { - if (typeof data === "string") return data; - if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); - if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); - return data.toString("utf8"); -} - -export function parseGatewayFrame(data: NodeWebSocket.Data): GatewayFrame | null { - try { - const parsed = JSON.parse(bufferToString(data)); - if (typeof parsed === "object" && parsed !== null) { - return parsed as GatewayFrame; - } - } catch { - // Ignore non-JSON frames. - } - return null; -} - -export function readString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -export function readBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - -export function parseGatewayError(error: GatewayFrame["error"]): ParsedGatewayError { - const details = - typeof error?.details === "object" && error.details !== null - ? (error.details as Record) - : undefined; - return { - message: readString(error?.message) ?? "Gateway request failed.", - code: - typeof error?.code === "string" || typeof error?.code === "number" - ? String(error.code) - : undefined, - detailCode: readString(details?.code), - detailReason: readString(details?.reason), - recommendedNextStep: readString(details?.recommendedNextStep), - canRetryWithDeviceToken: readBoolean(details?.canRetryWithDeviceToken), - }; -} - -export function formatGatewayError(error: ParsedGatewayError): string { - const details = [ - error.code ? `code ${error.code}` : null, - error.detailCode ? `detail ${error.detailCode}` : null, - error.detailReason ? `reason ${error.detailReason}` : null, - error.recommendedNextStep ? `next ${error.recommendedNextStep}` : null, - error.canRetryWithDeviceToken ? "device-token retry available" : null, - ].filter((detail): detail is string => detail !== null); - return details.length > 0 ? `${error.message} (${details.join(", ")})` : error.message; -} - -export function extractHelloPayload(payload: unknown): OpenclawHelloPayload | undefined { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - return undefined; - } - - const record = payload as Record; - const authRecord = - record.auth && typeof record.auth === "object" && !Array.isArray(record.auth) - ? (record.auth as Record) - : undefined; - const featuresRecord = - record.features && typeof record.features === "object" && !Array.isArray(record.features) - ? (record.features as Record) - : undefined; - const methods = - Array.isArray(featuresRecord?.methods) && - featuresRecord?.methods.every((item) => typeof item === "string") - ? (featuresRecord.methods as string[]) - : undefined; - - const type = readString(record.type); - const protocol = typeof record.protocol === "number" ? record.protocol : undefined; - const deviceToken = readString(authRecord?.deviceToken); - const role = readString(authRecord?.role); - - return { - type, - protocol, - auth: authRecord - ? { - deviceToken, - role, - scopes: Array.isArray(authRecord.scopes) - ? authRecord.scopes.filter((scope): scope is string => typeof scope === "string") - : [], - } - : undefined, - features: methods ? { methods } : undefined, - }; -} - -export function extractHelloMethods(hello: OpenclawHelloPayload | undefined): Set { - return new Set(hello?.features?.methods ?? []); -} - -export function assertRequiredMethods( - methods: Set, - requiredMethods: ReadonlyArray, -): void { - const missing = requiredMethods.filter((method) => !methods.has(method)); - if (missing.length > 0) { - throw new Error(`Gateway is missing required methods: ${missing.join(", ")}`); - } -} diff --git a/apps/server/src/openclaw/sessionIdentity.ts b/apps/server/src/openclaw/sessionIdentity.ts deleted file mode 100644 index 04af2f73b..000000000 --- a/apps/server/src/openclaw/sessionIdentity.ts +++ /dev/null @@ -1,34 +0,0 @@ -export type OpenclawSessionIdentityKind = "sessionKey" | "key" | "sessionId" | "id"; - -export interface OpenclawSessionIdentity { - readonly kind: OpenclawSessionIdentityKind; - readonly value: string; -} - -const SESSION_IDENTITY_FIELDS: readonly OpenclawSessionIdentityKind[] = [ - "sessionKey", - "key", - "sessionId", - "id", -]; - -export function normalizeOpenclawSessionIdentity( - value: unknown, -): OpenclawSessionIdentity | undefined { - if (typeof value === "string" && value.trim().length > 0) { - return { kind: "sessionKey", value: value.trim() }; - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - - const record = value as Record; - for (const field of SESSION_IDENTITY_FIELDS) { - const candidate = record[field]; - if (typeof candidate === "string" && candidate.trim().length > 0) { - return { kind: field, value: candidate.trim() }; - } - } - - return undefined; -} diff --git a/apps/server/src/openclawGatewayTest.test.ts b/apps/server/src/openclawGatewayTest.test.ts deleted file mode 100644 index 88e82b1a3..000000000 --- a/apps/server/src/openclawGatewayTest.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { WebSocketServer, type WebSocket } from "ws"; - -import { OpenclawGatewayTestInternals, runOpenclawGatewayTest } from "./openclawGatewayTest.ts"; - -const servers = new Set(); - -type GatewayRequestFrame = { - type?: unknown; - id?: unknown; - method?: unknown; - params?: { - client?: { - id?: unknown; - displayName?: unknown; - mode?: unknown; - deviceFamily?: unknown; - }; - auth?: { - password?: unknown; - token?: unknown; - deviceToken?: unknown; - }; - device?: { - id?: unknown; - publicKey?: unknown; - signature?: unknown; - signedAt?: unknown; - nonce?: unknown; - }; - }; -}; - -function isBase64Url(value: unknown): value is string { - return typeof value === "string" && /^[A-Za-z0-9_-]+$/.test(value); -} - -afterEach(async () => { - await Promise.all( - [...servers].map( - (server) => - new Promise((resolve) => { - for (const client of server.clients) { - client.terminate(); - } - server.close(() => resolve()); - }), - ), - ); - servers.clear(); -}); - -async function createGatewayServer( - onConnection: (socket: WebSocket) => void, -): Promise<{ url: string }> { - const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); - servers.add(server); - await new Promise((resolve) => { - server.once("listening", () => resolve()); - }); - server.on("connection", onConnection); - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("Expected a TCP address for the test websocket server."); - } - return { url: `ws://127.0.0.1:${address.port}` }; -} - -function sendChallenge(socket: WebSocket): void { - socket.send( - JSON.stringify({ - type: "event", - event: "connect.challenge", - payload: { nonce: "nonce-123", ts: Date.now() }, - }), - ); -} - -describe("runOpenclawGatewayTest", () => { - it("captures Tailscale-oriented hints for modern handshake timeouts", () => { - const hostKind = OpenclawGatewayTestInternals.classifyGatewayHost("vals-mini.example.ts.net", [ - "100.90.12.34", - ]); - - expect(hostKind).toBe("tailscale"); - - const hints = OpenclawGatewayTestInternals.buildHints( - new URL("wss://vals-mini.example.ts.net"), - { - resolvedAddresses: ["100.90.12.34"], - hostKind, - healthStatus: "skip", - observedNotifications: ["connect.challenge"], - hints: [], - }, - "Gateway handshake", - "Gateway request 'connect' timed out after 10000ms.", - true, - ); - - expect(hints.some((hint) => hint.includes("Tailscale"))).toBe(true); - expect(hints.some((hint) => hint.includes("actual OpenClaw WebSocket gateway endpoint"))).toBe( - true, - ); - expect(hints.some((hint) => hint.includes("reverse proxy"))).toBe(true); - }); - - it("passes when the modern connect handshake succeeds", async () => { - let connectParams: GatewayRequestFrame["params"]; - - const gateway = await createGatewayServer((socket) => { - sendChallenge(socket); - socket.on("message", (data) => { - const message = JSON.parse(data.toString()) as GatewayRequestFrame; - if (message.type === "req" && message.method === "connect") { - connectParams = message.params; - socket.send( - JSON.stringify({ - type: "res", - id: message.id, - ok: true, - payload: { type: "hello-ok", protocol: 3 }, - }), - ); - } - }); - }); - - const result = await runOpenclawGatewayTest({ - gatewayUrl: gateway.url, - password: "topsecret", - }); - - expect(result.success).toBe(true); - expect(result.steps.find((step) => step.name === "WebSocket connect")?.status).toBe("pass"); - expect(result.steps.find((step) => step.name === "Gateway handshake")?.status).toBe("pass"); - expect(result.diagnostics?.observedNotifications).toContain("connect.challenge"); - - expect(connectParams?.client?.id).toBe("gateway-client"); - expect(connectParams?.client?.mode).toBe("backend"); - expect(connectParams?.client?.displayName).toBe("OK Code gateway test"); - expect(connectParams?.client?.deviceFamily).toBe("server"); - expect(connectParams?.auth?.token).toBe("topsecret"); - expect(connectParams?.auth?.password).toBeUndefined(); - expect(connectParams?.auth?.deviceToken).toBeUndefined(); - expect(connectParams?.device?.id).toMatch(/^[a-f0-9]{64}$/); - expect(connectParams?.device?.id).not.toMatch(/^device_/); - expect(isBase64Url(connectParams?.device?.publicKey)).toBe(true); - expect(String(connectParams?.device?.publicKey)).not.toContain("BEGIN"); - expect(isBase64Url(connectParams?.device?.signature)).toBe(true); - expect(connectParams?.device?.nonce).toBe("nonce-123"); - expect(typeof connectParams?.device?.signedAt).toBe("number"); - }); - - it("retries with password-style auth when the gateway requires auth.password", async () => { - const attemptedParams: GatewayRequestFrame["params"][] = []; - - const gateway = await createGatewayServer((socket) => { - sendChallenge(socket); - socket.on("message", (data) => { - const message = JSON.parse(data.toString()) as GatewayRequestFrame; - if (message.type === "req" && message.method === "connect") { - attemptedParams.push(message.params); - if (message.params?.auth?.password === "topsecret") { - socket.send( - JSON.stringify({ - type: "res", - id: message.id, - ok: true, - payload: { type: "hello-ok", protocol: 3 }, - }), - ); - return; - } - - socket.send( - JSON.stringify({ - type: "res", - id: message.id, - ok: false, - error: { - message: "unauthorized: gateway password missing", - details: { - code: "AUTH_PASSWORD_MISSING", - reason: "provide gateway auth password", - recommendedNextStep: "update_auth_configuration", - }, - }, - }), - ); - } - }); - }); - - const result = await runOpenclawGatewayTest({ - gatewayUrl: gateway.url, - password: "topsecret", - }); - - expect(result.success).toBe(true); - expect(attemptedParams.some((params) => params?.auth?.password === "topsecret")).toBe(true); - }); - - it("reports pairing-required detail codes from the connect handshake", async () => { - const gateway = await createGatewayServer((socket) => { - sendChallenge(socket); - socket.on("message", (data) => { - const message = JSON.parse(data.toString()) as GatewayRequestFrame; - if (message.type === "req" && message.method === "connect") { - socket.send( - JSON.stringify({ - type: "res", - id: message.id, - ok: false, - error: { - message: "device is not approved", - details: { - code: "PAIRING_REQUIRED", - reason: "pairing-required", - recommendedNextStep: "approve_device", - }, - }, - }), - ); - } - }); - }); - - const result = await runOpenclawGatewayTest({ - gatewayUrl: gateway.url, - password: "topsecret", - }); - - expect(result.success).toBe(false); - expect(result.steps.find((step) => step.name === "WebSocket connect")?.status).toBe("pass"); - - const handshakeStep = result.steps.find((step) => step.name === "Gateway handshake"); - expect(handshakeStep?.status).toBe("fail"); - expect(handshakeStep?.detail).toContain("PAIRING_REQUIRED"); - - expect(result.diagnostics?.gatewayErrorDetailCode).toBe("PAIRING_REQUIRED"); - expect(result.diagnostics?.gatewayErrorDetailReason).toBe("pairing-required"); - expect(result.diagnostics?.gatewayRecommendedNextStep).toBe("approve_device"); - expect(result.diagnostics?.hints.some((hint) => hint.includes("pairing approval"))).toBe(true); - }); - - it("adds a shared-secret hint for password-missing handshake errors", () => { - const hints = OpenclawGatewayTestInternals.buildHints( - new URL("wss://vals-mini.example.ts.net"), - { - resolvedAddresses: ["100.90.12.34"], - hostKind: "tailscale", - healthStatus: "pass", - observedNotifications: ["connect.challenge"], - hints: [], - gatewayErrorDetailCode: "AUTH_PASSWORD_MISSING", - }, - "Gateway handshake", - "unauthorized: gateway password missing (AUTH_PASSWORD_MISSING)", - false, - ); - - expect(hints.some((hint) => hint.includes("add the configured secret and test again"))).toBe( - true, - ); - }); -}); diff --git a/apps/server/src/openclawGatewayTest.ts b/apps/server/src/openclawGatewayTest.ts deleted file mode 100644 index faf174fdd..000000000 --- a/apps/server/src/openclawGatewayTest.ts +++ /dev/null @@ -1,623 +0,0 @@ -import { lookup } from "node:dns/promises"; -import { isIP } from "node:net"; - -import type { - TestOpenclawGatewayDiagnostics, - TestOpenclawGatewayHostKind, - TestOpenclawGatewayInput, - TestOpenclawGatewayResult, - TestOpenclawGatewayStep, - TestOpenclawGatewayStepStatus, -} from "@okcode/contracts"; -import { serverBuildInfo } from "./buildInfo.ts"; -import { - OPENCLAW_GATEWAY_CLIENT_IDS, - OPENCLAW_GATEWAY_CLIENT_MODES, - connectOpenClawGateway, -} from "./provider/Layers/OpenClawGatewayClient.ts"; - -const OPENCLAW_TEST_CONNECT_TIMEOUT_MS = 10_000; -const OPENCLAW_TEST_RPC_TIMEOUT_MS = 10_000; -const OPENCLAW_TEST_HEALTH_TIMEOUT_MS = 2_500; -const OPENCLAW_TEST_LOOKUP_TIMEOUT_MS = 1_500; -const MAX_CAPTURED_NOTIFICATIONS = 5; -const OPENCLAW_OPERATOR_SCOPES = ["operator.read", "operator.write"] as const; - -interface GatewayHealthProbe { - status: TestOpenclawGatewayStepStatus; - url?: string; - detail?: string; -} - -interface MutableGatewayDiagnostics { - normalizedUrl?: string; - host?: string; - pathname?: string; - hostKind?: TestOpenclawGatewayHostKind; - resolvedAddresses: string[]; - healthUrl?: string; - healthStatus: TestOpenclawGatewayStepStatus; - healthDetail?: string; - socketCloseCode?: number; - socketCloseReason?: string; - socketError?: string; - gatewayErrorCode?: string; - gatewayErrorDetailCode?: string; - gatewayErrorDetailReason?: string; - gatewayRecommendedNextStep?: string; - gatewayCanRetryWithDeviceToken?: boolean; - observedNotifications: string[]; - hints: string[]; -} - -interface RunOpenclawGatewayTestOptions { - readonly stateDir?: string | undefined; -} - -interface OpenClawGatewayErrorLike { - readonly message: string; - readonly code?: string; - readonly details?: Record; -} - -function withTimeout(promise: Promise, timeoutMs: number, fallback: T): Promise { - return new Promise((resolve) => { - const timeout = setTimeout(() => resolve(fallback), timeoutMs); - void promise.then( - (value) => { - clearTimeout(timeout); - resolve(value); - }, - () => { - clearTimeout(timeout); - resolve(fallback); - }, - ); - }); -} - -function toMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - -function applyGatewayError( - diagnostics: MutableGatewayDiagnostics, - error: OpenClawGatewayErrorLike | undefined, -): void { - if (!error) { - return; - } - - if (typeof error.code === "string") { - diagnostics.gatewayErrorCode = error.code; - } - const details = error.details ?? {}; - if (typeof details.code === "string") { - diagnostics.gatewayErrorDetailCode = details.code; - } - if (typeof details.reason === "string") { - diagnostics.gatewayErrorDetailReason = details.reason; - } - if (typeof details.recommendedNextStep === "string") { - diagnostics.gatewayRecommendedNextStep = details.recommendedNextStep; - } - if (typeof details.canRetryWithDeviceToken === "boolean") { - diagnostics.gatewayCanRetryWithDeviceToken = details.canRetryWithDeviceToken; - } -} - -function pushUnique(items: string[], value: string): void { - if (items.includes(value) || items.length >= MAX_CAPTURED_NOTIFICATIONS) return; - items.push(value); -} - -function formatGatewayFailureDetail( - detail: string, - diagnostics: Pick, -): string { - const code = diagnostics.gatewayErrorDetailCode; - if (!code || detail.includes(code)) { - return detail; - } - return `${detail} (${code})`; -} - -function isLoopbackHost(host: string): boolean { - const normalized = host.toLowerCase(); - return ( - normalized === "localhost" || - normalized === "127.0.0.1" || - normalized === "::1" || - normalized === "[::1]" - ); -} - -function isTailscaleIpv4(address: string): boolean { - const parts = address.split(".").map(Number); - if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) return false; - const first = parts[0] ?? -1; - const second = parts[1] ?? -1; - return first === 100 && second >= 64 && second <= 127; -} - -function isPrivateIpv4(address: string): boolean { - const parts = address.split(".").map(Number); - if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) return false; - const first = parts[0] ?? -1; - const second = parts[1] ?? -1; - if (first === 10) return true; - if (first === 172 && second >= 16 && second <= 31) return true; - if (first === 192 && second === 168) return true; - if (first === 169 && second === 254) return true; - return false; -} - -function isLoopbackIp(address: string): boolean { - if (address === "::1") return true; - return address.startsWith("127."); -} - -function isTailscaleIpv6(address: string): boolean { - return address.toLowerCase().startsWith("fd7a:115c:a1e0:"); -} - -function isPrivateIpv6(address: string): boolean { - const normalized = address.toLowerCase(); - return normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe8"); -} - -function isPrivateAddress(address: string): boolean { - const kind = isIP(address); - if (kind === 4) { - return isPrivateIpv4(address); - } - if (kind === 6) { - return isPrivateIpv6(address); - } - return false; -} - -function classifyGatewayHost( - host: string, - resolvedAddresses: ReadonlyArray, -): TestOpenclawGatewayHostKind { - const normalized = host.toLowerCase(); - if (isLoopbackHost(normalized) || resolvedAddresses.some(isLoopbackIp)) { - return "loopback"; - } - if ( - normalized.endsWith(".ts.net") || - resolvedAddresses.some((address) => isTailscaleIpv4(address) || isTailscaleIpv6(address)) - ) { - return "tailscale"; - } - if (isIP(host) !== 0) { - if (isPrivateAddress(host)) { - return "private"; - } - return "public"; - } - if (resolvedAddresses.some(isPrivateAddress)) { - return "private"; - } - if (resolvedAddresses.length > 0) { - return "public"; - } - return "unknown"; -} - -async function resolveAddresses(host: string): Promise { - if (isIP(host) !== 0) { - return [host]; - } - const results = await lookup(host, { all: true, verbatim: true }); - return [...new Set(results.map((result) => result.address))]; -} - -function buildHealthUrl(parsedUrl: URL): string | null { - if (parsedUrl.pathname.length > 1 && parsedUrl.pathname !== "/") { - return null; - } - const healthUrl = new URL(parsedUrl.toString()); - healthUrl.protocol = parsedUrl.protocol === "wss:" ? "https:" : "http:"; - healthUrl.pathname = "/health"; - healthUrl.search = ""; - healthUrl.hash = ""; - return healthUrl.toString(); -} - -async function probeHealth(parsedUrl: URL): Promise { - const healthUrl = buildHealthUrl(parsedUrl); - if (!healthUrl) { - return { - status: "skip", - detail: "Skipped best-effort /health probe because the gateway URL uses a non-root path.", - }; - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), OPENCLAW_TEST_HEALTH_TIMEOUT_MS); - try { - const response = await fetch(healthUrl, { signal: controller.signal }); - if (response.ok) { - return { - status: "pass", - url: healthUrl, - detail: `HTTP ${response.status}`, - }; - } - return { - status: "fail", - url: healthUrl, - detail: `HTTP ${response.status}`, - }; - } catch (cause) { - return { - status: "fail", - url: healthUrl, - detail: toMessage(cause, "Health probe failed."), - }; - } finally { - clearTimeout(timeout); - } -} - -function buildHints( - parsedUrl: URL, - diagnostics: Pick< - MutableGatewayDiagnostics, - | "healthStatus" - | "healthUrl" - | "hostKind" - | "observedNotifications" - | "hints" - | "resolvedAddresses" - | "gatewayErrorCode" - | "gatewayErrorDetailCode" - | "gatewayErrorDetailReason" - | "gatewayRecommendedNextStep" - | "gatewayCanRetryWithDeviceToken" - >, - failedStepName: string | null, - error: string | undefined, - sharedSecretProvided: boolean, -): string[] { - const hints: string[] = []; - const handshakeFailure = failedStepName === "Gateway handshake"; - const websocketFailure = failedStepName === "WebSocket connect"; - const errorLower = error?.toLowerCase() ?? ""; - const detailCode = diagnostics.gatewayErrorDetailCode; - const gatewayRecommendedNextStep = diagnostics.gatewayRecommendedNextStep; - - if (diagnostics.hostKind === "loopback") { - hints.push( - "This gateway host is loopback-only. `localhost`, `127.0.0.1`, and `::1` only work when OK Code and the OpenClaw gateway run on the same machine.", - ); - } - - if (diagnostics.hostKind === "private") { - hints.push( - "This host looks like a LAN/private address. Make sure the gateway is listening on that interface or on `0.0.0.0`, and confirm local firewalls allow inbound TCP on the gateway port.", - ); - } - - if (diagnostics.hostKind === "tailscale") { - hints.push( - "This host looks like Tailscale. Confirm both devices are on the same tailnet, MagicDNS is resolving the hostname correctly, and the gateway is bound to the Tailnet IP or `0.0.0.0` rather than only `127.0.0.1`.", - ); - } - - if (websocketFailure) { - hints.push( - "The WebSocket handshake did not complete. Double-check the hostname, port, firewall rules, and whether the OpenClaw gateway is actually running at this URL.", - ); - } - - if (handshakeFailure) { - hints.push( - "The WebSocket handshake succeeded, so DNS/TLS/basic routing are working. The remaining failure is inside the OpenClaw `connect` handshake.", - ); - if (errorLower.includes("connect.challenge")) { - hints.push( - "Modern OpenClaw gateways send `connect.challenge` before they will accept any client request. If that event never arrived, this URL may point at the wrong WebSocket service or an intermediary is swallowing frames.", - ); - } - if (errorLower.includes("timed out")) { - hints.push( - "A timeout during the `connect.challenge`/`connect` exchange usually means this URL is not the actual OpenClaw WebSocket gateway endpoint, or a proxy/Tailscale Serve setup upgraded the socket but did not keep forwarding frames.", - ); - } - } - - if ( - !sharedSecretProvided && - (detailCode === "AUTH_TOKEN_MISSING" || - detailCode === "AUTH_PASSWORD_MISSING" || - errorLower.includes("auth_token_missing") || - errorLower.includes("auth_password_missing")) - ) { - hints.push( - "No shared secret was provided for this test. If your OpenClaw gateway uses token/password auth, add the configured secret and test again.", - ); - } - - if ( - sharedSecretProvided && - (detailCode === "AUTH_TOKEN_MISMATCH" || - detailCode === "AUTH_PASSWORD_MISMATCH" || - detailCode === "AUTH_DEVICE_TOKEN_MISMATCH" || - errorLower.includes("auth_token_mismatch") || - errorLower.includes("auth_password_mismatch")) - ) { - hints.push( - "The gateway rejected the provided auth material. Re-check the configured shared secret and confirm whether this gateway expects token auth, password auth, or a paired device token.", - ); - } - - if (diagnostics.healthStatus === "fail" && diagnostics.healthUrl) { - hints.push( - `The best-effort health probe to ${diagnostics.healthUrl} failed. That often means the gateway is not healthy yet, the path is routed somewhere else, or HTTPS/HTTP is terminating before the OpenClaw service.`, - ); - } - - if (detailCode === "PAIRING_REQUIRED") { - hints.push( - "The gateway is asking for device pairing approval. Approve the pending device with `openclaw devices list` and `openclaw devices approve `, then retry.", - ); - } - - if ( - detailCode?.startsWith("DEVICE_AUTH_") || - errorLower.includes("device identity required") || - errorLower.includes("device nonce") || - errorLower.includes("device signature") - ) { - hints.push( - "This gateway requires challenge-based device auth. Modern OpenClaw connections must wait for `connect.challenge`, sign it with a device identity, and send that identity back in `connect.params.device`.", - ); - } - - if ( - errorLower.includes("/client/id") || - errorLower.includes("/client/mode") || - errorLower.includes("client id") || - errorLower.includes("client mode") - ) { - hints.push( - "The gateway rejected the advertised client identity. That usually means the gateway expects a newer OpenClaw `connect.params.client` allowlist than this OK Code build is using.", - ); - } - - if ( - diagnostics.hostKind === "tailscale" && - (detailCode === "PAIRING_REQUIRED" || - detailCode?.startsWith("DEVICE_AUTH_") || - errorLower.includes("device identity")) - ) { - hints.push( - "OpenClaw treats tailnet and LAN connects as remote for pairing/device auth. Even on the same physical machine, a `*.ts.net` connection usually needs an approved device identity unless the gateway is explicitly configured for a trusted proxy flow.", - ); - } - - if (gatewayRecommendedNextStep) { - hints.push(`Gateway recommended next step: \`${gatewayRecommendedNextStep}\`.`); - } - - if (diagnostics.gatewayCanRetryWithDeviceToken) { - hints.push( - "The gateway reported that a retry with a cached device token could work. That only helps after the device has already been paired and a token was persisted.", - ); - } - - if (parsedUrl.protocol === "wss:" && (websocketFailure || handshakeFailure)) { - hints.push( - "Because this uses `wss://`, check any reverse proxy or Tailscale Serve setup too. It must preserve WebSocket upgrades and continue forwarding frames after the initial handshake.", - ); - } - - if (diagnostics.observedNotifications.length > 0 && handshakeFailure) { - hints.push( - "The gateway sent events before `connect` completed. Check the gateway logs around the same time to see why it never answered the handshake successfully.", - ); - } - - return [...new Set(hints)]; -} - -function createDiagnostics(): MutableGatewayDiagnostics { - return { - resolvedAddresses: [], - healthStatus: "skip", - observedNotifications: [], - hints: [], - }; -} - -export async function runOpenclawGatewayTest( - input: TestOpenclawGatewayInput, - options?: RunOpenclawGatewayTestOptions, -): Promise { - const overallStart = Date.now(); - const steps: TestOpenclawGatewayStep[] = []; - const diagnostics: MutableGatewayDiagnostics = createDiagnostics(); - let parsedUrlForHints: URL | null = null; - let connection: Awaited> | undefined; - - const pushStep = ( - name: string, - status: TestOpenclawGatewayStepStatus, - durationMs: number, - detail?: string, - ) => { - steps.push({ name, status, durationMs, ...(detail ? { detail } : {}) }); - }; - - const applyHealthProbe = (healthProbe: GatewayHealthProbe) => { - diagnostics.healthStatus = healthProbe.status; - if (healthProbe.url !== undefined) { - diagnostics.healthUrl = healthProbe.url; - } - if (healthProbe.detail !== undefined) { - diagnostics.healthDetail = healthProbe.detail; - } - }; - - const finalize = ( - success: boolean, - error?: string, - failedStepName: string | null = null, - ): TestOpenclawGatewayResult => { - const hints = buildHints( - parsedUrlForHints ?? new URL("ws://localhost"), - diagnostics, - failedStepName, - error, - Boolean(input.password?.trim()), - ); - const diagnosticsResult: TestOpenclawGatewayDiagnostics = { - ...diagnostics, - resolvedAddresses: [...diagnostics.resolvedAddresses], - observedNotifications: [...diagnostics.observedNotifications], - hints, - }; - return { - success, - steps, - totalDurationMs: Date.now() - overallStart, - diagnostics: diagnosticsResult, - ...(error ? { error } : {}), - }; - }; - - try { - const urlStart = Date.now(); - const gatewayUrl = input.gatewayUrl?.trim() ?? ""; - const sharedSecret = input.password?.trim() || undefined; - if (!gatewayUrl) { - pushStep("URL validation", "fail", Date.now() - urlStart, "Gateway URL is empty."); - return finalize(false, "Gateway URL is empty.", "URL validation"); - } - - const parsedUrl = URL.canParse(gatewayUrl) ? new URL(gatewayUrl) : null; - if (!parsedUrl) { - pushStep("URL validation", "fail", Date.now() - urlStart, "Malformed URL."); - return finalize(false, "Malformed URL.", "URL validation"); - } - - parsedUrlForHints = parsedUrl; - diagnostics.normalizedUrl = parsedUrl.toString(); - diagnostics.host = parsedUrl.hostname; - diagnostics.pathname = parsedUrl.pathname; - - if (!["ws:", "wss:"].includes(parsedUrl.protocol)) { - const detail = `Invalid protocol "${parsedUrl.protocol}". Expected ws: or wss:.`; - pushStep("URL validation", "fail", Date.now() - urlStart, detail); - return finalize(false, detail, "URL validation"); - } - - const resolutionPromise = withTimeout( - resolveAddresses(parsedUrl.hostname), - OPENCLAW_TEST_LOOKUP_TIMEOUT_MS, - [], - ); - const healthPromise = probeHealth(parsedUrl); - - pushStep( - "URL validation", - "pass", - Date.now() - urlStart, - `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname}`, - ); - - diagnostics.resolvedAddresses = await resolutionPromise; - diagnostics.hostKind = classifyGatewayHost(parsedUrl.hostname, diagnostics.resolvedAddresses); - - const connectStart = Date.now(); - try { - connection = await connectOpenClawGateway({ - gatewayUrl, - sessionKey: "okcode:gateway-test", - role: "operator", - scopes: [...OPENCLAW_OPERATOR_SCOPES], - client: { - id: OPENCLAW_GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, - displayName: "OK Code gateway test", - version: serverBuildInfo.version, - platform: - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : process.platform, - deviceFamily: "server", - mode: OPENCLAW_GATEWAY_CLIENT_MODES.BACKEND, - }, - userAgent: `okcode/${serverBuildInfo.version}`, - locale: Intl.DateTimeFormat().resolvedOptions().locale || "en-US", - ...(options?.stateDir ? { stateDir: options.stateDir } : {}), - ...(sharedSecret ? { password: sharedSecret } : {}), - onEvent: (event) => { - pushUnique(diagnostics.observedNotifications, event.event); - }, - connectTimeoutMs: OPENCLAW_TEST_CONNECT_TIMEOUT_MS, - requestTimeoutMs: OPENCLAW_TEST_RPC_TIMEOUT_MS, - }); - pushStep( - "WebSocket connect", - "pass", - Date.now() - connectStart, - `Connected in ${Date.now() - connectStart}ms`, - ); - } catch (cause) { - const gatewayError = - cause instanceof Error - ? (cause as Error & { readonly gatewayError?: OpenClawGatewayErrorLike }).gatewayError - : undefined; - const connectionStage = - cause instanceof Error - ? (cause as Error & { readonly openClawConnectionStage?: "websocket" | "handshake" }) - .openClawConnectionStage - : undefined; - applyGatewayError(diagnostics, gatewayError); - const detail = formatGatewayFailureDetail( - toMessage(cause, "Connection failed."), - diagnostics, - ); - if (connectionStage === "handshake") { - pushStep( - "WebSocket connect", - "pass", - Date.now() - connectStart, - `Connected in ${Date.now() - connectStart}ms`, - ); - applyHealthProbe(await healthPromise); - pushStep("Gateway handshake", "fail", 0, detail); - return finalize(false, detail, "Gateway handshake"); - } - pushStep("WebSocket connect", "fail", Date.now() - connectStart, detail); - applyHealthProbe(await healthPromise); - return finalize(false, detail, "WebSocket connect"); - } - - applyHealthProbe(await healthPromise); - - const handshakeStart = Date.now(); - pushStep("Gateway handshake", "pass", Date.now() - handshakeStart, "Connected."); - return finalize(true); - } finally { - try { - await connection?.close(); - } catch { - // ignore close errors during cleanup - } - } -} - -export const OpenclawGatewayTestInternals = { - buildHealthUrl, - buildHints, - classifyGatewayHost, -}; diff --git a/apps/server/src/persistence/Layers/OpenclawGatewayConfig.ts b/apps/server/src/persistence/Layers/OpenclawGatewayConfig.ts deleted file mode 100644 index 310e0939d..000000000 --- a/apps/server/src/persistence/Layers/OpenclawGatewayConfig.ts +++ /dev/null @@ -1,482 +0,0 @@ -import type { - OpenclawGatewayConfigSummary, - ResetOpenclawGatewayDeviceStateInput, - SaveOpenclawGatewayConfigInput, -} from "@okcode/contracts"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Option, Schema } from "effect"; -import path from "node:path"; - -import { ServerConfig } from "../../config.ts"; -import { generateOpenclawDeviceIdentity } from "../../openclaw/deviceAuth.ts"; -import { - PersistenceCryptoError, - toPersistenceCryptoError, - toPersistenceDecodeError, - toPersistenceSqlError, -} from "../Errors.ts"; -import { - OpenclawGatewayConfig, - type OpenclawGatewayConfigError, - type OpenclawGatewayStoredConfig, - type ResolveOpenclawGatewayConfigInput, - type SaveOpenclawDeviceTokenInput, -} from "../Services/OpenclawGatewayConfig.ts"; -import { decodeVaultPayload, encodeVaultPayload, readOrCreateVaultKey } from "../vault.ts"; - -const OPENCLAW_CONFIG_ID = "default"; - -const OpenclawGatewayConfigRow = Schema.Struct({ - configId: Schema.String, - gatewayUrl: Schema.String, - encryptedSharedSecret: Schema.NullOr(Schema.String), - deviceId: Schema.String, - devicePublicKey: Schema.String, - deviceFingerprint: Schema.String, - encryptedDevicePrivateKey: Schema.String, - encryptedDeviceToken: Schema.NullOr(Schema.String), - deviceTokenRole: Schema.NullOr(Schema.String), - deviceTokenScopesJson: Schema.String, - createdAt: Schema.String, - updatedAt: Schema.String, -}); - -const GetOpenclawGatewayConfigRequest = Schema.Struct({ - configId: Schema.String, -}); - -function emptySummary(): OpenclawGatewayConfigSummary { - return { - gatewayUrl: null, - hasSharedSecret: false, - deviceId: null, - devicePublicKey: null, - deviceFingerprint: null, - hasDeviceToken: false, - deviceTokenRole: null, - deviceTokenScopes: [], - updatedAt: null, - }; -} - -function normalizeScopes(scopes: ReadonlyArray | undefined): string[] { - const unique = new Set(); - for (const scope of scopes ?? []) { - const trimmed = scope.trim(); - if (trimmed.length > 0) { - unique.add(trimmed); - } - } - return [...unique].toSorted((left, right) => left.localeCompare(right)); -} - -function fromGeneratedIdentity(identity: ReturnType) { - return { - deviceId: identity.deviceId, - devicePublicKey: identity.publicKey, - deviceFingerprint: identity.deviceFingerprint, - devicePrivateKeyPem: identity.privateKeyPem, - }; -} - -function makeStoredConfig(input: { - readonly gatewayUrl: string; - readonly sharedSecret: string | undefined; - readonly deviceId: string; - readonly devicePublicKey: string; - readonly deviceFingerprint: string; - readonly devicePrivateKeyPem: string; - readonly deviceToken: string | undefined; - readonly deviceTokenRole: string | undefined; - readonly deviceTokenScopes: ReadonlyArray; - readonly updatedAt: string; -}): OpenclawGatewayStoredConfig { - return { - gatewayUrl: input.gatewayUrl, - sharedSecret: input.sharedSecret, - deviceId: input.deviceId, - devicePublicKey: input.devicePublicKey, - deviceFingerprint: input.deviceFingerprint, - devicePrivateKeyPem: input.devicePrivateKeyPem, - deviceToken: input.deviceToken, - deviceTokenRole: input.deviceTokenRole, - deviceTokenScopes: normalizeScopes(input.deviceTokenScopes), - updatedAt: input.updatedAt, - }; -} - -function toSummary(config: OpenclawGatewayStoredConfig | null): OpenclawGatewayConfigSummary { - if (!config) { - return emptySummary(); - } - return { - gatewayUrl: config.gatewayUrl, - hasSharedSecret: Boolean(config.sharedSecret), - deviceId: config.deviceId, - devicePublicKey: config.devicePublicKey, - deviceFingerprint: config.deviceFingerprint, - hasDeviceToken: Boolean(config.deviceToken), - deviceTokenRole: config.deviceTokenRole ?? null, - deviceTokenScopes: [...config.deviceTokenScopes], - updatedAt: config.updatedAt, - }; -} - -function toOpenclawGatewayConfigError( - operation: string, - cause: unknown, -): OpenclawGatewayConfigError { - if (Schema.is(PersistenceCryptoError)(cause)) { - return cause; - } - if (Schema.isSchemaError(cause)) { - return toPersistenceDecodeError(operation)(cause); - } - if (cause instanceof Error) { - return new PersistenceCryptoError({ - operation, - detail: cause.message.length > 0 ? cause.message : `Failed to execute ${operation}`, - cause, - }); - } - return toPersistenceCryptoError(operation)(cause); -} - -export const OpenclawGatewayConfigLive = Layer.effect( - OpenclawGatewayConfig, - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const { stateDir } = yield* ServerConfig; - const secretKeyPath = path.join(stateDir, "openclaw-vault.key"); - let secretKeyPromise: Promise | null = null; - - const getSecretKey = () => { - if (!secretKeyPromise) { - secretKeyPromise = readOrCreateVaultKey(secretKeyPath).catch((error) => { - secretKeyPromise = null; - throw error; - }); - } - return secretKeyPromise; - }; - - const findRow = SqlSchema.findOneOption({ - Request: GetOpenclawGatewayConfigRequest, - Result: OpenclawGatewayConfigRow, - execute: ({ configId }) => - sql` - SELECT - config_id AS "configId", - gateway_url AS "gatewayUrl", - encrypted_shared_secret AS "encryptedSharedSecret", - device_id AS "deviceId", - device_public_key AS "devicePublicKey", - device_fingerprint AS "deviceFingerprint", - encrypted_device_private_key AS "encryptedDevicePrivateKey", - encrypted_device_token AS "encryptedDeviceToken", - device_token_role AS "deviceTokenRole", - device_token_scopes_json AS "deviceTokenScopesJson", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM openclaw_gateway_config - WHERE config_id = ${configId} - `, - }); - - const upsertRow = SqlSchema.void({ - Request: OpenclawGatewayConfigRow, - execute: (row) => - sql` - INSERT INTO openclaw_gateway_config ( - config_id, - gateway_url, - encrypted_shared_secret, - device_id, - device_public_key, - device_fingerprint, - encrypted_device_private_key, - encrypted_device_token, - device_token_role, - device_token_scopes_json, - created_at, - updated_at - ) VALUES ( - ${row.configId}, - ${row.gatewayUrl}, - ${row.encryptedSharedSecret}, - ${row.deviceId}, - ${row.devicePublicKey}, - ${row.deviceFingerprint}, - ${row.encryptedDevicePrivateKey}, - ${row.encryptedDeviceToken}, - ${row.deviceTokenRole}, - ${row.deviceTokenScopesJson}, - ${row.createdAt}, - ${row.updatedAt} - ) - ON CONFLICT (config_id) - DO UPDATE SET - gateway_url = excluded.gateway_url, - encrypted_shared_secret = excluded.encrypted_shared_secret, - device_id = excluded.device_id, - device_public_key = excluded.device_public_key, - device_fingerprint = excluded.device_fingerprint, - encrypted_device_private_key = excluded.encrypted_device_private_key, - encrypted_device_token = excluded.encrypted_device_token, - device_token_role = excluded.device_token_role, - device_token_scopes_json = excluded.device_token_scopes_json, - updated_at = excluded.updated_at - `, - }); - - const decodeRow = (row: typeof OpenclawGatewayConfigRow.Type) => - Effect.tryPromise({ - try: async () => { - const key = await getSecretKey(); - const deviceTokenScopes = normalizeScopes( - JSON.parse(row.deviceTokenScopesJson) as ReadonlyArray, - ); - const sharedSecret = - row.encryptedSharedSecret !== null - ? decodeVaultPayload({ - key, - aad: ["openclaw", "shared-secret", row.gatewayUrl], - encryptedValue: row.encryptedSharedSecret, - }) - : undefined; - const devicePrivateKeyPem = decodeVaultPayload({ - key, - aad: ["openclaw", "device-private-key", row.deviceId], - encryptedValue: row.encryptedDevicePrivateKey, - }); - const deviceToken = - row.encryptedDeviceToken !== null - ? decodeVaultPayload({ - key, - aad: ["openclaw", "device-token", row.deviceId, row.deviceTokenRole ?? ""], - encryptedValue: row.encryptedDeviceToken, - }) - : undefined; - - return { - gatewayUrl: row.gatewayUrl, - sharedSecret, - deviceId: row.deviceId, - devicePublicKey: row.devicePublicKey, - deviceFingerprint: row.deviceFingerprint, - devicePrivateKeyPem, - deviceToken, - deviceTokenRole: row.deviceTokenRole ?? undefined, - deviceTokenScopes, - updatedAt: row.updatedAt, - } satisfies OpenclawGatewayStoredConfig; - }, - catch: (cause) => toOpenclawGatewayConfigError("OpenclawGatewayConfig.decodeRow", cause), - }); - - const writeConfig = (config: OpenclawGatewayStoredConfig) => - Effect.gen(function* () { - const key = yield* Effect.tryPromise({ - try: () => getSecretKey(), - catch: (cause) => - toOpenclawGatewayConfigError("OpenclawGatewayConfig.writeConfig:key", cause), - }); - const now = new Date().toISOString(); - const row = { - configId: OPENCLAW_CONFIG_ID, - gatewayUrl: config.gatewayUrl, - encryptedSharedSecret: - config.sharedSecret !== undefined - ? encodeVaultPayload({ - key, - aad: ["openclaw", "shared-secret", config.gatewayUrl], - value: config.sharedSecret, - }) - : null, - deviceId: config.deviceId, - devicePublicKey: config.devicePublicKey, - deviceFingerprint: config.deviceFingerprint, - encryptedDevicePrivateKey: encodeVaultPayload({ - key, - aad: ["openclaw", "device-private-key", config.deviceId], - value: config.devicePrivateKeyPem, - }), - encryptedDeviceToken: - config.deviceToken !== undefined - ? encodeVaultPayload({ - key, - aad: ["openclaw", "device-token", config.deviceId, config.deviceTokenRole ?? ""], - value: config.deviceToken, - }) - : null, - deviceTokenRole: config.deviceTokenRole ?? null, - deviceTokenScopesJson: JSON.stringify(normalizeScopes(config.deviceTokenScopes)), - createdAt: now, - updatedAt: now, - }; - yield* upsertRow(row).pipe( - Effect.mapError(toPersistenceSqlError("OpenclawGatewayConfig.writeConfig:query")), - ); - }); - - const getStored = () => - findRow({ configId: OPENCLAW_CONFIG_ID }).pipe( - Effect.mapError(toPersistenceSqlError("OpenclawGatewayConfig.getStored:query")), - Effect.flatMap( - Option.match({ - onNone: () => Effect.succeed(null), - onSome: (row) => decodeRow(row), - }), - ), - ); - - const save = (input: SaveOpenclawGatewayConfigInput) => - Effect.gen(function* () { - const existing = yield* getStored(); - const sharedSecret = input.clearSharedSecret - ? undefined - : input.sharedSecret?.trim() !== undefined && input.sharedSecret.trim().length > 0 - ? input.sharedSecret.trim() - : existing?.sharedSecret; - const generatedIdentity = fromGeneratedIdentity(generateOpenclawDeviceIdentity()); - const identity = existing ?? { - ...generatedIdentity, - deviceToken: undefined, - deviceTokenRole: undefined, - deviceTokenScopes: [], - updatedAt: new Date().toISOString(), - gatewayUrl: input.gatewayUrl, - sharedSecret, - }; - const nextConfig = makeStoredConfig({ - gatewayUrl: input.gatewayUrl, - sharedSecret, - deviceId: identity.deviceId, - devicePublicKey: identity.devicePublicKey, - deviceFingerprint: identity.deviceFingerprint, - devicePrivateKeyPem: identity.devicePrivateKeyPem, - deviceToken: identity.deviceToken, - deviceTokenRole: identity.deviceTokenRole, - deviceTokenScopes: identity.deviceTokenScopes, - updatedAt: new Date().toISOString(), - }); - yield* writeConfig(nextConfig); - return toSummary(nextConfig); - }); - - const saveDeviceToken = (input: SaveOpenclawDeviceTokenInput) => - Effect.gen(function* () { - const existing = yield* getStored(); - if (!existing) { - return; - } - yield* writeConfig( - makeStoredConfig({ - ...existing, - deviceToken: input.deviceToken, - deviceTokenRole: input.role ?? existing.deviceTokenRole, - deviceTokenScopes: input.scopes ?? existing.deviceTokenScopes, - updatedAt: new Date().toISOString(), - }), - ); - }); - - const clearDeviceToken = () => - Effect.gen(function* () { - const existing = yield* getStored(); - if (!existing) { - return; - } - yield* writeConfig( - makeStoredConfig({ - ...existing, - deviceToken: undefined, - deviceTokenRole: undefined, - deviceTokenScopes: [], - updatedAt: new Date().toISOString(), - }), - ); - }); - - const resetDeviceState = (input?: ResetOpenclawGatewayDeviceStateInput) => - Effect.gen(function* () { - const existing = yield* getStored(); - if (!existing) { - return emptySummary(); - } - const regenerateIdentity = input?.regenerateIdentity ?? true; - const nextIdentity = regenerateIdentity - ? fromGeneratedIdentity(generateOpenclawDeviceIdentity()) - : existing; - const nextConfig = makeStoredConfig({ - gatewayUrl: existing.gatewayUrl, - sharedSecret: existing.sharedSecret, - deviceId: nextIdentity.deviceId, - devicePublicKey: nextIdentity.devicePublicKey, - deviceFingerprint: nextIdentity.deviceFingerprint, - devicePrivateKeyPem: nextIdentity.devicePrivateKeyPem, - deviceToken: undefined, - deviceTokenRole: undefined, - deviceTokenScopes: [], - updatedAt: new Date().toISOString(), - }); - yield* writeConfig(nextConfig); - return toSummary(nextConfig); - }); - - const resolveForConnect = (input?: ResolveOpenclawGatewayConfigInput) => - Effect.gen(function* () { - const existing = yield* getStored(); - if (!existing) { - const gatewayUrl = input?.gatewayUrl?.trim(); - if (!gatewayUrl) { - return null; - } - if (!input?.allowEphemeralIdentity) { - return null; - } - const identity = fromGeneratedIdentity(generateOpenclawDeviceIdentity()); - const sharedSecret = - input.sharedSecret?.trim() && input.sharedSecret.trim().length > 0 - ? input.sharedSecret.trim() - : undefined; - return makeStoredConfig({ - gatewayUrl, - sharedSecret, - deviceId: identity.deviceId, - devicePublicKey: identity.devicePublicKey, - deviceFingerprint: identity.deviceFingerprint, - devicePrivateKeyPem: identity.devicePrivateKeyPem, - deviceToken: undefined, - deviceTokenRole: undefined, - deviceTokenScopes: [], - updatedAt: new Date().toISOString(), - }); - } - - const gatewayUrl = input?.gatewayUrl?.trim() || existing.gatewayUrl; - const sharedSecret = - input?.sharedSecret?.trim() && input.sharedSecret.trim().length > 0 - ? input.sharedSecret.trim() - : existing.sharedSecret; - return makeStoredConfig({ - ...existing, - gatewayUrl, - sharedSecret, - }); - }); - - const getSummary = () => getStored().pipe(Effect.map(toSummary)); - - return { - getSummary, - getStored, - save, - resolveForConnect, - saveDeviceToken, - clearDeviceToken, - resetDeviceState, - }; - }), -); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ceec24da8..557f34272 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -35,7 +35,6 @@ import Migration0020 from "./Migrations/020_SmeConversationProviderAuth.ts"; import Migration0021 from "./Migrations/021_ProjectionPendingUserInputs.ts"; import Migration0022 from "./Migrations/022_DecisionWorkspace.ts"; import Migration0023 from "./Migrations/023_ProjectionPendingUserInputsBackfill.ts"; -import Migration0024 from "./Migrations/024_OpenclawGatewayConfig.ts"; import Migration0025 from "./Migrations/025_CanonicalizeModelSelections.ts"; import Migration0026 from "./Migrations/025_ProjectionProjectIconPath.ts"; import Migration0027 from "./Migrations/027_CanonicalizeModelSelectionsBackfill.ts"; @@ -76,7 +75,6 @@ const loader = Migrator.fromRecord({ "21_ProjectionPendingUserInputs": Migration0021, "22_DecisionWorkspace": Migration0022, "23_ProjectionPendingUserInputsBackfill": Migration0023, - "24_OpenclawGatewayConfig": Migration0024, "25_CanonicalizeModelSelections": Migration0025, "26_ProjectionProjectIconPath": Migration0026, "27_CanonicalizeModelSelectionsBackfill": Migration0027, diff --git a/apps/server/src/persistence/Migrations/020_SmeConversationProviderAuth.ts b/apps/server/src/persistence/Migrations/020_SmeConversationProviderAuth.ts index 9c3ee26fb..06f46407e 100644 --- a/apps/server/src/persistence/Migrations/020_SmeConversationProviderAuth.ts +++ b/apps/server/src/persistence/Migrations/020_SmeConversationProviderAuth.ts @@ -19,7 +19,6 @@ export default Effect.gen(function* () { SET provider = CASE WHEN lower(model) LIKE 'claude-%' THEN 'claudeAgent' WHEN lower(model) LIKE 'gpt-%' THEN 'codex' - WHEN lower(model) LIKE 'openclaw/%' OR lower(model) = 'default' THEN 'openclaw' ELSE 'claudeAgent' END WHERE provider IS NULL diff --git a/apps/server/src/persistence/Migrations/021_OpenclawGatewayConfig.ts b/apps/server/src/persistence/Migrations/021_OpenclawGatewayConfig.ts deleted file mode 100644 index a09c92b8f..000000000 --- a/apps/server/src/persistence/Migrations/021_OpenclawGatewayConfig.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* sql` - CREATE TABLE IF NOT EXISTS openclaw_gateway_config ( - config_id TEXT PRIMARY KEY, - gateway_url TEXT NOT NULL, - encrypted_shared_secret TEXT NULL, - device_id TEXT NOT NULL, - device_public_key TEXT NOT NULL, - device_fingerprint TEXT NOT NULL, - encrypted_device_private_key TEXT NOT NULL, - encrypted_device_token TEXT NULL, - device_token_role TEXT NULL, - device_token_scopes_json TEXT NOT NULL DEFAULT '[]', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) - `; -}); diff --git a/apps/server/src/persistence/Migrations/024_OpenclawGatewayConfig.ts b/apps/server/src/persistence/Migrations/024_OpenclawGatewayConfig.ts deleted file mode 100644 index a09c92b8f..000000000 --- a/apps/server/src/persistence/Migrations/024_OpenclawGatewayConfig.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -export default Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* sql` - CREATE TABLE IF NOT EXISTS openclaw_gateway_config ( - config_id TEXT PRIMARY KEY, - gateway_url TEXT NOT NULL, - encrypted_shared_secret TEXT NULL, - device_id TEXT NOT NULL, - device_public_key TEXT NOT NULL, - device_fingerprint TEXT NOT NULL, - encrypted_device_private_key TEXT NOT NULL, - encrypted_device_token TEXT NULL, - device_token_role TEXT NULL, - device_token_scopes_json TEXT NOT NULL DEFAULT '[]', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) - `; -}); diff --git a/apps/server/src/persistence/Migrations/025_CanonicalizeModelSelections.ts b/apps/server/src/persistence/Migrations/025_CanonicalizeModelSelections.ts index ef86ff101..4791bf7cd 100644 --- a/apps/server/src/persistence/Migrations/025_CanonicalizeModelSelections.ts +++ b/apps/server/src/persistence/Migrations/025_CanonicalizeModelSelections.ts @@ -19,7 +19,6 @@ export default Effect.gen(function* () { SET default_model_selection = CASE WHEN default_model IS NULL OR TRIM(default_model) = '' THEN NULL WHEN default_model LIKE 'claude-%' THEN json_object('provider', 'claudeAgent', 'model', default_model) - WHEN default_model LIKE 'openclaw/%' THEN json_object('provider', 'openclaw', 'model', default_model) WHEN default_model LIKE 'copilot/%' THEN json_object('provider', 'copilot', 'model', default_model) WHEN default_model LIKE 'gemini-%' OR default_model LIKE 'auto-gemini-%' THEN json_object('provider', 'gemini', 'model', default_model) ELSE json_object('provider', 'codex', 'model', default_model) @@ -32,7 +31,6 @@ export default Effect.gen(function* () { SET model_selection = CASE WHEN model IS NULL OR TRIM(model) = '' THEN NULL WHEN model LIKE 'claude-%' THEN json_object('provider', 'claudeAgent', 'model', model) - WHEN model LIKE 'openclaw/%' THEN json_object('provider', 'openclaw', 'model', model) WHEN model LIKE 'copilot/%' THEN json_object('provider', 'copilot', 'model', model) WHEN model LIKE 'gemini-%' OR model LIKE 'auto-gemini-%' THEN json_object('provider', 'gemini', 'model', model) ELSE json_object('provider', 'codex', 'model', model) diff --git a/apps/server/src/persistence/Migrations/027_CanonicalizeModelSelectionsBackfill.ts b/apps/server/src/persistence/Migrations/027_CanonicalizeModelSelectionsBackfill.ts index 30afc39c1..8cf25354b 100644 --- a/apps/server/src/persistence/Migrations/027_CanonicalizeModelSelectionsBackfill.ts +++ b/apps/server/src/persistence/Migrations/027_CanonicalizeModelSelectionsBackfill.ts @@ -17,7 +17,6 @@ type ThreadRow = { function inferProviderKind(model: string): ProviderKind { if (model.startsWith("claude-")) return "claudeAgent"; - if (model.startsWith("openclaw/")) return "openclaw"; if (model.startsWith("copilot/")) return "copilot"; if (model.startsWith("gemini-") || model.startsWith("auto-gemini-")) return "gemini"; return "codex"; diff --git a/apps/server/src/persistence/Services/OpenclawGatewayConfig.ts b/apps/server/src/persistence/Services/OpenclawGatewayConfig.ts deleted file mode 100644 index f28374cfc..000000000 --- a/apps/server/src/persistence/Services/OpenclawGatewayConfig.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { - OpenclawGatewayConfigSummary, - ResetOpenclawGatewayDeviceStateInput, - SaveOpenclawGatewayConfigInput, -} from "@okcode/contracts"; -import { ServiceMap } from "effect"; -import type { Effect } from "effect"; - -import type { - PersistenceCryptoError, - PersistenceDecodeError, - PersistenceSqlError, -} from "../Errors.ts"; - -export type OpenclawGatewayConfigError = - | PersistenceSqlError - | PersistenceDecodeError - | PersistenceCryptoError; - -export interface OpenclawGatewayStoredConfig { - readonly gatewayUrl: string; - readonly sharedSecret: string | undefined; - readonly deviceId: string; - readonly devicePublicKey: string; - readonly deviceFingerprint: string; - readonly devicePrivateKeyPem: string; - readonly deviceToken: string | undefined; - readonly deviceTokenRole: string | undefined; - readonly deviceTokenScopes: ReadonlyArray; - readonly updatedAt: string; -} - -export interface ResolveOpenclawGatewayConfigInput { - readonly gatewayUrl?: string; - readonly sharedSecret?: string; - readonly allowEphemeralIdentity?: boolean; -} - -export interface SaveOpenclawDeviceTokenInput { - readonly deviceToken: string; - readonly role?: string; - readonly scopes?: ReadonlyArray; -} - -export interface OpenclawGatewayConfigShape { - readonly getSummary: () => Effect.Effect< - OpenclawGatewayConfigSummary, - OpenclawGatewayConfigError - >; - readonly getStored: () => Effect.Effect< - OpenclawGatewayStoredConfig | null, - OpenclawGatewayConfigError - >; - readonly save: ( - input: SaveOpenclawGatewayConfigInput, - ) => Effect.Effect; - readonly resolveForConnect: ( - input?: ResolveOpenclawGatewayConfigInput, - ) => Effect.Effect; - readonly saveDeviceToken: ( - input: SaveOpenclawDeviceTokenInput, - ) => Effect.Effect; - readonly clearDeviceToken: () => Effect.Effect; - readonly resetDeviceState: ( - input?: ResetOpenclawGatewayDeviceStateInput, - ) => Effect.Effect; -} - -export class OpenclawGatewayConfig extends ServiceMap.Service< - OpenclawGatewayConfig, - OpenclawGatewayConfigShape ->()("okcode/persistence/Services/OpenclawGatewayConfig") {} diff --git a/apps/server/src/prReview/Layers/RepoReviewConfig.ts b/apps/server/src/prReview/Layers/RepoReviewConfig.ts index b4dcc1ee5..dbdb94669 100644 --- a/apps/server/src/prReview/Layers/RepoReviewConfig.ts +++ b/apps/server/src/prReview/Layers/RepoReviewConfig.ts @@ -33,7 +33,7 @@ type LocalProfileDefinition = { title: string; body: string; repositories: string[]; - adapter: "openclawMaintainer"; + adapter: "maintainer"; maintainersRepo: string; relativePath: string; absolutePath: string; @@ -527,7 +527,7 @@ function parseLocalProfileDocument(input: { absolutePath: string; raw: string }) issues: [toIssue("warning", relativePath, "Profile is missing repositories[] matchers.")], }; } - if (adapterRaw !== "openclawMaintainer") { + if (adapterRaw !== "maintainer") { return { profile: null, issues: [ @@ -548,7 +548,7 @@ function parseLocalProfileDocument(input: { absolutePath: string; raw: string }) title, body, repositories, - adapter: "openclawMaintainer", + adapter: "maintainer", maintainersRepo: resolveMaybeHomePath(maintainersRepoRaw), relativePath, absolutePath: input.absolutePath, @@ -634,7 +634,7 @@ async function determineGitHubRepositoryNameWithOwner(cwd: string): Promise { @@ -684,7 +684,7 @@ async function buildOpenClawMaintainerConfig(input: { const workflowDoc = readTitleAndDescription({ raw: workflowFile.raw, - fallbackTitle: "OpenClaw Maintainer PR Workflow", + fallbackTitle: "Maintainer PR Workflow", fallbackDescription: "Private maintainer workflow loaded from a local OK Code profile.", }); const reviewSkillDoc = readTitleAndDescription({ @@ -881,9 +881,9 @@ async function loadLocalProfileConfig(cwd: string): Promise<{ if (!parsed.profile.repositories.includes(normalizedRepository)) { continue; } - if (parsed.profile.adapter === "openclawMaintainer") { + if (parsed.profile.adapter === "maintainer") { return { - config: await buildOpenClawMaintainerConfig({ + config: await buildMaintainerConfig({ profile: parsed.profile, issues: [...issues], }), diff --git a/apps/server/src/prReview/localProfiles.test.ts b/apps/server/src/prReview/localProfiles.test.ts index 265505e26..dd8f6c77d 100644 --- a/apps/server/src/prReview/localProfiles.test.ts +++ b/apps/server/src/prReview/localProfiles.test.ts @@ -9,24 +9,24 @@ import { describe("prReview local profiles", () => { it("parses GitHub HTTPS and SSH remotes", () => { expect( - parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/openclaw/openclaw.git"), - ).toBe("openclaw/openclaw"); + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/acme/acme.git"), + ).toBe("acme/acme"); expect( - parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:OpenClaw/maintainers.git"), - ).toBe("OpenClaw/maintainers"); + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:Acme/maintainers.git"), + ).toBe("Acme/maintainers"); }); it("round-trips encoded local command actions", () => { const encoded = encodePrReviewLocalCommandAction({ kind: "localCommand", - cwd: "/Users/val/Documents/GitHub/OpenClaw/maintainers", + cwd: "/Users/val/Documents/GitHub/Acme/maintainers", args: ["scripts/pr-review", "{{prNumber}}"], label: "review-pr", }); expect(decodePrReviewLocalCommandAction(encoded)).toEqual({ kind: "localCommand", - cwd: "/Users/val/Documents/GitHub/OpenClaw/maintainers", + cwd: "/Users/val/Documents/GitHub/Acme/maintainers", args: ["scripts/pr-review", "{{prNumber}}"], label: "review-pr", }); diff --git a/apps/server/src/provider/Layers/OpenClawAdapter.ts b/apps/server/src/provider/Layers/OpenClawAdapter.ts deleted file mode 100644 index 4bda212e4..000000000 --- a/apps/server/src/provider/Layers/OpenClawAdapter.ts +++ /dev/null @@ -1,1168 +0,0 @@ -/** - * OpenClawAdapterLive - Scoped live implementation for the OpenClaw gateway provider adapter. - * - * Connects to an OpenClaw gateway over WebSocket using JSON-RPC 2.0, manages - * stateful server-side sessions, and emits canonical runtime events. - * - * @module OpenClawAdapterLive - */ -import WebSocket from "ws"; -import { - ApprovalRequestId, - type CanonicalItemType, - type CanonicalRequestType, - EventId, - type ProviderApprovalDecision, - type ProviderRuntimeEvent, - type ProviderRuntimeTurnStatus, - type ProviderSession, - type ProviderUserInputAnswers, - type RuntimeContentStreamKind, - RuntimeItemId, - RuntimeRequestId, - type RuntimeSessionState, - RuntimeTaskId, - ThreadId, - TurnId, - isToolLifecycleItemType, -} from "@okcode/contracts"; -import { Deferred, Effect, Fiber, Layer, Queue, Random, Stream } from "effect"; - -import { - ProviderAdapterProcessError, - ProviderAdapterRequestError, - ProviderAdapterSessionClosedError, - ProviderAdapterSessionNotFoundError, - ProviderAdapterValidationError, - type ProviderAdapterError, -} from "../Errors.ts"; -import { OpenClawAdapter, type OpenClawAdapterShape } from "../Services/OpenClawAdapter.ts"; -import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; - -// ── Constants ────────────────────────────────────────────────────── - -const PROVIDER = "openclaw" as const; -const JSON_RPC_VERSION = "2.0" as const; -const DEFAULT_GATEWAY_URL = "ws://localhost:8080"; -const RPC_TIMEOUT_MS = 30_000; -const WS_CONNECT_TIMEOUT_MS = 10_000; - -// ── JSON-RPC 2.0 types ──────────────────────────────────────────── - -interface JsonRpcRequest { - readonly jsonrpc: typeof JSON_RPC_VERSION; - readonly method: string; - readonly params?: Record; - readonly id: number; -} - -interface JsonRpcResponse { - readonly jsonrpc: typeof JSON_RPC_VERSION; - readonly result?: unknown; - readonly error?: { readonly code: number; readonly message: string; readonly data?: unknown }; - readonly id: number | string | null; -} - -interface JsonRpcNotification { - readonly jsonrpc: typeof JSON_RPC_VERSION; - readonly method: string; - readonly params?: Record; -} - -type JsonRpcMessage = JsonRpcResponse | JsonRpcNotification; - -function isJsonRpcResponse(msg: JsonRpcMessage): msg is JsonRpcResponse { - return "id" in msg && msg.id !== undefined && msg.id !== null; -} - -// ── Resume cursor ────────────────────────────────────────────────── - -interface OpenClawResumeCursor { - readonly version: 1; - readonly gatewaySessionId: string; -} - -function readResumeCursor(cursor: unknown): OpenClawResumeCursor | undefined { - if ( - typeof cursor === "object" && - cursor !== null && - "version" in cursor && - (cursor as Record).version === 1 && - "gatewaySessionId" in cursor && - typeof (cursor as Record).gatewaySessionId === "string" - ) { - return cursor as OpenClawResumeCursor; - } - return undefined; -} - -function makeResumeCursor(gatewaySessionId: string): OpenClawResumeCursor { - return { version: 1, gatewaySessionId }; -} - -// ── Turn state ───────────────────────────────────────────────────── - -interface OpenClawTurnState { - readonly turnId: TurnId; - readonly startedAt: string; -} - -// ── Pending approval / user-input ────────────────────────────────── - -interface PendingApproval { - readonly requestType: CanonicalRequestType; - readonly detail?: string; - readonly decision: Deferred.Deferred; -} - -interface PendingUserInput { - readonly questions: ReadonlyArray; - readonly answers: Deferred.Deferred; -} - -// ── Session context ──────────────────────────────────────────────── - -interface OpenClawSessionContext { - session: ProviderSession; - ws: WebSocket; - gatewaySessionId: string; - readonly pendingRpcCalls: Map>; - readonly pendingApprovals: Map; - readonly pendingUserInputs: Map; - turnState: OpenClawTurnState | undefined; - nextRpcId: number; - streamFiber: Fiber.Fiber | undefined; - stopped: boolean; -} - -// ── Helpers ──────────────────────────────────────────────────────── - -function nowIsoString(): string { - return new Date().toISOString(); -} - -function toMessage(cause: unknown, fallback: string): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - return fallback; -} - -// ── Adapter factory ──────────────────────────────────────────────── - -export interface OpenClawAdapterLiveOptions { - readonly nativeEventLogger?: EventNdjsonLogger; -} - -function makeOpenClawAdapter(options?: OpenClawAdapterLiveOptions) { - return Effect.gen(function* () { - const runtimeEventQueue = yield* Queue.unbounded(); - const sessions = new Map(); - - // ── Event emission helpers ────────────────────────────────── - - const emitEvent = (event: ProviderRuntimeEvent): Effect.Effect => - Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); - - const makeEventBase = ( - threadId: ThreadId, - turnId?: TurnId, - itemId?: string, - requestId?: string, - ) => ({ - eventId: EventId.makeUnsafe(crypto.randomUUID()), - provider: PROVIDER, - threadId, - createdAt: nowIsoString(), - ...(turnId ? { turnId } : {}), - ...(itemId ? { itemId: RuntimeItemId.makeUnsafe(itemId) } : {}), - ...(requestId ? { requestId: RuntimeRequestId.makeUnsafe(requestId) } : {}), - providerRefs: {}, - }); - - // ── WebSocket + JSON-RPC helpers ──────────────────────────── - - const connectWebSocket = (url: string): Effect.Effect => - Effect.callback((resume) => { - const ws = new WebSocket(url); - const timeout = setTimeout(() => { - ws.close(); - resume( - Effect.fail( - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: "pending", - detail: `WebSocket connection to ${url} timed out after ${WS_CONNECT_TIMEOUT_MS}ms.`, - }), - ), - ); - }, WS_CONNECT_TIMEOUT_MS); - - ws.on("open", () => { - clearTimeout(timeout); - resume(Effect.succeed(ws)); - }); - ws.on("error", (error) => { - clearTimeout(timeout); - resume( - Effect.fail( - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: "pending", - detail: `WebSocket connection to ${url} failed: ${toMessage(error, "Unknown error")}`, - cause: error, - }), - ), - ); - }); - }); - - const sendRpc = ( - context: OpenClawSessionContext, - method: string, - params?: Record, - ): Effect.Effect => - Effect.gen(function* () { - if (context.stopped || context.ws.readyState !== WebSocket.OPEN) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method, - detail: "WebSocket is not open.", - }); - } - const id = context.nextRpcId++; - const deferred = yield* Deferred.make(); - context.pendingRpcCalls.set(id, deferred); - const request: JsonRpcRequest = { - jsonrpc: JSON_RPC_VERSION, - method, - ...(params !== undefined ? { params } : {}), - id, - }; - context.ws.send(JSON.stringify(request)); - const response = yield* Deferred.await(deferred).pipe( - Effect.timeoutOrElse({ - duration: RPC_TIMEOUT_MS, - onTimeout: () => - Effect.fail( - new ProviderAdapterRequestError({ - provider: PROVIDER, - method, - detail: `RPC call '${method}' timed out after ${RPC_TIMEOUT_MS}ms.`, - }), - ), - }), - ); - context.pendingRpcCalls.delete(id); - if (response.error) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method, - detail: `RPC error ${response.error.code}: ${response.error.message}`, - }); - } - return response; - }); - - // ── Notification → canonical event mapping ────────────────── - - const mapNotificationToEvents = ( - context: OpenClawSessionContext, - notification: JsonRpcNotification, - ): Effect.Effect => - Effect.gen(function* () { - const { method, params } = notification; - const threadId = context.session.threadId; - const turnId = context.turnState?.turnId; - const p = (params ?? {}) as Record; - - if (options?.nativeEventLogger) { - yield* options.nativeEventLogger.write( - { - observedAt: nowIsoString(), - threadId, - turnId, - source: "openclaw.gateway.notification", - method, - payload: params, - }, - threadId, - ); - } - - switch (method) { - case "session.started": { - const sessionStartedPayload: Record = {}; - if (typeof p.message === "string") { - sessionStartedPayload.message = p.message; - } - yield* emitEvent({ - ...makeEventBase(threadId), - type: "session.started", - payload: sessionStartedPayload, - }); - break; - } - - case "session.configured": { - yield* emitEvent({ - ...makeEventBase(threadId), - type: "session.configured", - payload: { - config: (p.config && typeof p.config === "object" - ? (p.config as Record) - : {}) as { [x: string]: unknown }, - }, - }); - break; - } - - case "session.state.changed": { - const rawState = typeof p.state === "string" ? p.state : "ready"; - const sessionState: RuntimeSessionState = - rawState === "running" - ? "running" - : rawState === "error" - ? "error" - : rawState === "stopped" - ? "stopped" - : rawState === "waiting" - ? "waiting" - : rawState === "starting" - ? "starting" - : "ready"; - context.session = { - ...context.session, - status: - sessionState === "running" - ? "running" - : sessionState === "error" - ? "error" - : sessionState === "stopped" - ? "closed" - : "ready", - updatedAt: nowIsoString(), - }; - yield* emitEvent({ - ...makeEventBase(threadId), - type: "session.state.changed", - payload: { state: sessionState }, - }); - break; - } - - case "turn.started": { - const gatewayTurnId = typeof p.turnId === "string" ? p.turnId : undefined; - if (context.turnState) { - yield* emitEvent({ - ...makeEventBase(threadId, context.turnState.turnId), - type: "turn.started", - payload: {}, - ...(gatewayTurnId ? { providerRefs: { providerTurnId: gatewayTurnId } } : {}), - }); - } - break; - } - - case "content.delta": { - const delta = - typeof p.delta === "string" - ? p.delta - : typeof p.textDelta === "string" - ? p.textDelta - : ""; - const streamKind: RuntimeContentStreamKind = - typeof p.streamKind === "string" && - (p.streamKind === "assistant_text" || - p.streamKind === "reasoning_text" || - p.streamKind === "reasoning_summary_text" || - p.streamKind === "plan_text" || - p.streamKind === "command_output" || - p.streamKind === "file_change_output" || - p.streamKind === "unknown") - ? p.streamKind - : "assistant_text"; - const itemId = typeof p.itemId === "string" ? p.itemId : crypto.randomUUID(); - yield* emitEvent({ - ...makeEventBase(threadId, turnId, itemId), - type: "content.delta", - payload: { delta, streamKind }, - }); - break; - } - - case "item.started": { - const itemId = typeof p.itemId === "string" ? p.itemId : crypto.randomUUID(); - const rawItemType = typeof p.itemType === "string" ? p.itemType : "unknown"; - const itemType: CanonicalItemType = isToolLifecycleItemType(rawItemType) - ? rawItemType - : rawItemType === "user_message" || - rawItemType === "assistant_message" || - rawItemType === "reasoning" || - rawItemType === "plan" || - rawItemType === "review_entered" || - rawItemType === "review_exited" || - rawItemType === "context_compaction" || - rawItemType === "error" - ? rawItemType - : "unknown"; - yield* emitEvent({ - ...makeEventBase(threadId, turnId, itemId), - type: "item.started", - payload: { - itemType, - status: "inProgress", - ...(typeof p.title === "string" ? { title: p.title } : {}), - ...(typeof p.detail === "string" ? { detail: p.detail } : {}), - }, - }); - break; - } - - case "item.completed": { - const itemId = typeof p.itemId === "string" ? p.itemId : crypto.randomUUID(); - const rawItemType = typeof p.itemType === "string" ? p.itemType : "unknown"; - const itemType: CanonicalItemType = isToolLifecycleItemType(rawItemType) - ? rawItemType - : rawItemType === "user_message" || - rawItemType === "assistant_message" || - rawItemType === "reasoning" || - rawItemType === "plan" || - rawItemType === "review_entered" || - rawItemType === "review_exited" || - rawItemType === "context_compaction" || - rawItemType === "error" - ? rawItemType - : "unknown"; - yield* emitEvent({ - ...makeEventBase(threadId, turnId, itemId), - type: "item.completed", - payload: { - itemType, - status: "completed", - ...(typeof p.title === "string" ? { title: p.title } : {}), - ...(typeof p.detail === "string" ? { detail: p.detail } : {}), - }, - }); - break; - } - - case "turn.completed": { - const state: ProviderRuntimeTurnStatus = - typeof p.state === "string" && - (p.state === "completed" || - p.state === "interrupted" || - p.state === "failed" || - p.state === "cancelled") - ? p.state - : "completed"; - yield* emitEvent({ - ...makeEventBase(threadId, turnId), - type: "turn.completed", - payload: { - state, - ...(typeof p.stopReason === "string" ? { stopReason: p.stopReason } : {}), - ...(typeof p.errorMessage === "string" ? { errorMessage: p.errorMessage } : {}), - }, - }); - context.turnState = undefined; - context.session = { - ...context.session, - status: "ready", - activeTurnId: undefined, - updatedAt: nowIsoString(), - }; - break; - } - - case "turn.aborted": { - yield* emitEvent({ - ...makeEventBase(threadId, turnId), - type: "turn.aborted", - payload: { - reason: typeof p.reason === "string" ? p.reason : "Turn aborted", - }, - }); - context.turnState = undefined; - context.session = { - ...context.session, - status: "ready", - activeTurnId: undefined, - updatedAt: nowIsoString(), - }; - break; - } - - case "approval.requested": { - const requestId = ApprovalRequestId.makeUnsafe( - typeof p.requestId === "string" ? p.requestId : crypto.randomUUID(), - ); - const requestType: CanonicalRequestType = - typeof p.requestType === "string" - ? (p.requestType as CanonicalRequestType) - : "command_execution_approval"; - const detail = typeof p.detail === "string" ? p.detail : undefined; - - const decision = yield* Deferred.make(); - const pendingEntry: PendingApproval = { - requestType, - decision, - ...(detail !== undefined ? { detail } : {}), - }; - context.pendingApprovals.set(requestId, pendingEntry); - - yield* emitEvent({ - ...makeEventBase(threadId, turnId, undefined, requestId), - type: "request.opened", - payload: { - requestType, - ...(detail ? { detail } : {}), - ...(p.args && typeof p.args === "object" - ? { args: p.args as Record } - : {}), - }, - }); - break; - } - - case "user-input.requested": { - const requestId = ApprovalRequestId.makeUnsafe( - typeof p.requestId === "string" ? p.requestId : crypto.randomUUID(), - ); - const questions = Array.isArray(p.questions) ? p.questions : []; - - const answers = yield* Deferred.make(); - context.pendingUserInputs.set(requestId, { - questions, - answers, - }); - - yield* emitEvent({ - ...makeEventBase(threadId, turnId, undefined, requestId), - type: "user-input.requested", - payload: { questions }, - }); - break; - } - - case "tool.progress": { - const itemId = typeof p.itemId === "string" ? p.itemId : crypto.randomUUID(); - yield* emitEvent({ - ...makeEventBase(threadId, turnId, itemId), - type: "tool.progress", - payload: { - ...(typeof p.toolUseId === "string" ? { toolUseId: p.toolUseId } : {}), - ...(typeof p.toolName === "string" ? { toolName: p.toolName } : {}), - ...(typeof p.summary === "string" ? { summary: p.summary } : {}), - ...(typeof p.elapsedSeconds === "number" - ? { elapsedSeconds: p.elapsedSeconds } - : {}), - }, - }); - break; - } - - case "task.started": { - const taskId = - typeof p.taskId === "string" - ? RuntimeTaskId.makeUnsafe(p.taskId) - : RuntimeTaskId.makeUnsafe(crypto.randomUUID()); - yield* emitEvent({ - ...makeEventBase(threadId, turnId), - type: "task.started", - payload: { - taskId, - ...(typeof p.description === "string" ? { description: p.description } : {}), - ...(typeof p.taskType === "string" ? { taskType: p.taskType } : {}), - }, - }); - break; - } - - case "task.progress": { - const taskId = - typeof p.taskId === "string" - ? RuntimeTaskId.makeUnsafe(p.taskId) - : RuntimeTaskId.makeUnsafe(crypto.randomUUID()); - yield* emitEvent({ - ...makeEventBase(threadId, turnId), - type: "task.progress", - payload: { - taskId, - description: typeof p.description === "string" ? p.description : "Task in progress", - ...(typeof p.summary === "string" ? { summary: p.summary } : {}), - }, - }); - break; - } - - case "task.completed": { - const taskId = - typeof p.taskId === "string" - ? RuntimeTaskId.makeUnsafe(p.taskId) - : RuntimeTaskId.makeUnsafe(crypto.randomUUID()); - const taskStatus = - typeof p.status === "string" && - (p.status === "completed" || p.status === "failed" || p.status === "stopped") - ? p.status - : "completed"; - yield* emitEvent({ - ...makeEventBase(threadId, turnId), - type: "task.completed", - payload: { - taskId, - status: taskStatus, - ...(typeof p.summary === "string" ? { summary: p.summary } : {}), - }, - }); - break; - } - - case "runtime.error": { - yield* emitEvent({ - ...makeEventBase(threadId, turnId), - type: "runtime.error", - payload: { - message: typeof p.message === "string" ? p.message : "Unknown runtime error", - ...(typeof p.code === "string" - ? { - class: p.code as - | "provider_error" - | "transport_error" - | "permission_error" - | "validation_error" - | "unknown", - } - : {}), - }, - }); - break; - } - - case "runtime.warning": { - yield* emitEvent({ - ...makeEventBase(threadId, turnId), - type: "runtime.warning", - payload: { - message: typeof p.message === "string" ? p.message : "Unknown runtime warning", - }, - }); - break; - } - - case "thread.token-usage.updated": { - const usageObj = (p.usage && typeof p.usage === "object" ? p.usage : p) as Record< - string, - unknown - >; - const usedTokens = typeof usageObj.usedTokens === "number" ? usageObj.usedTokens : 0; - yield* emitEvent({ - ...makeEventBase(threadId, turnId), - type: "thread.token-usage.updated", - payload: { - usage: { - usedTokens, - ...(typeof usageObj.inputTokens === "number" - ? { inputTokens: usageObj.inputTokens } - : {}), - ...(typeof usageObj.outputTokens === "number" - ? { outputTokens: usageObj.outputTokens } - : {}), - }, - }, - }); - break; - } - - default: { - // Unknown notification method — emit as runtime warning for observability. - yield* emitEvent({ - ...makeEventBase(threadId, turnId), - type: "runtime.warning", - payload: { - message: `Unknown OpenClaw gateway notification: ${method}`, - }, - raw: { - source: "openclaw.gateway.notification" as const, - method, - payload: params, - }, - }); - break; - } - } - }); - - // ── WebSocket message dispatcher ──────────────────────────── - - const setupWsMessageHandler = (context: OpenClawSessionContext): Effect.Effect => - Effect.sync(() => { - context.ws.on("message", (data: WebSocket.Data) => { - try { - const raw = typeof data === "string" ? data : data.toString("utf-8"); - const msg: JsonRpcMessage = JSON.parse(raw); - - if (isJsonRpcResponse(msg)) { - // Match response to pending RPC call by id. - const id = typeof msg.id === "number" ? msg.id : undefined; - if (id !== undefined) { - const pending = context.pendingRpcCalls.get(id); - if (pending) { - context.pendingRpcCalls.delete(id); - Effect.runFork(Deferred.succeed(pending, msg)); - } - } - } else { - // Notification — map to canonical events. - Effect.runFork(mapNotificationToEvents(context, msg)); - } - } catch { - // Malformed message — ignore silently for resilience. - } - }); - - context.ws.on("close", (_code: number, _reason: Buffer) => { - if (!context.stopped) { - context.stopped = true; - Effect.runFork( - emitEvent({ - ...makeEventBase(context.session.threadId), - type: "session.exited", - payload: { - exitKind: "error", - reason: "WebSocket connection closed unexpectedly.", - }, - }), - ); - } - }); - - context.ws.on("error", (error: Error) => { - if (!context.stopped) { - Effect.runFork( - emitEvent({ - ...makeEventBase(context.session.threadId), - type: "runtime.error", - payload: { - message: `WebSocket error: ${toMessage(error, "Unknown error")}`, - }, - }), - ); - } - }); - }); - - // ── Session stop helper ───────────────────────────────────── - - const stopSessionInternal = ( - context: OpenClawSessionContext, - opts: { emitExitEvent: boolean }, - ): Effect.Effect => - Effect.gen(function* () { - if (context.stopped) return; - context.stopped = true; - - // Cancel pending approvals. - for (const [, pending] of context.pendingApprovals) { - yield* Deferred.succeed(pending.decision, "cancel"); - } - context.pendingApprovals.clear(); - - // Cancel pending user inputs. - for (const [, pending] of context.pendingUserInputs) { - yield* Deferred.succeed(pending.answers, {}); - } - context.pendingUserInputs.clear(); - - // Fail pending RPC calls. - for (const [, pending] of context.pendingRpcCalls) { - yield* Deferred.succeed(pending, { - jsonrpc: JSON_RPC_VERSION, - error: { code: -32000, message: "Session stopped." }, - id: null, - }); - } - context.pendingRpcCalls.clear(); - - // Best-effort stop RPC (don't fail if WS is already closed). - if (context.ws.readyState === WebSocket.OPEN) { - yield* Effect.try({ - try: () => - context.ws.send( - JSON.stringify({ - jsonrpc: JSON_RPC_VERSION, - method: "session.stop", - params: { sessionId: context.gatewaySessionId }, - id: context.nextRpcId++, - }), - ), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: context.session.threadId, - detail: "Failed to send session.stop during teardown.", - cause, - }), - }).pipe(Effect.orElseSucceed(() => undefined)); - } - - // Close WebSocket. - yield* Effect.try({ - try: () => context.ws.close(), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: context.session.threadId, - detail: "Failed to close websocket during teardown.", - cause, - }), - }).pipe(Effect.orElseSucceed(() => undefined)); - - // Interrupt stream fiber if active. - if (context.streamFiber) { - yield* Fiber.interrupt(context.streamFiber); - context.streamFiber = undefined; - } - - // Emit exit event. - if (opts.emitExitEvent) { - yield* emitEvent({ - ...makeEventBase(context.session.threadId), - type: "session.exited", - payload: { exitKind: "graceful" }, - }); - } - - sessions.delete(context.session.threadId); - }); - - // ── Require active session helper ─────────────────────────── - - const requireSession = ( - threadId: ThreadId, - ): Effect.Effect => { - const context = sessions.get(threadId); - if (!context) { - return Effect.fail( - new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - }), - ); - } - if (context.stopped || context.session.status === "closed") { - return Effect.fail( - new ProviderAdapterSessionClosedError({ - provider: PROVIDER, - threadId, - }), - ); - } - return Effect.succeed(context); - }; - - // ── Adapter interface: startSession ───────────────────────── - - const startSession: OpenClawAdapterShape["startSession"] = (input) => - Effect.gen(function* () { - if (input.provider !== undefined && input.provider !== PROVIDER) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, - }); - } - - const threadId = input.threadId; - const resumeState = readResumeCursor(input.resumeCursor); - const openclawOptions = input.providerOptions?.openclaw; - - // Resolve gateway URL. - const gatewayUrl = - openclawOptions?.gatewayUrl ?? process.env.OPENCLAW_GATEWAY_URL ?? DEFAULT_GATEWAY_URL; - - const password = openclawOptions?.password ?? process.env.OPENCLAW_PASSWORD; - - // Connect WebSocket. - const ws = yield* connectWebSocket(gatewayUrl); - - const now = nowIsoString(); - const context: OpenClawSessionContext = { - session: { - provider: PROVIDER, - status: "connecting", - runtimeMode: input.runtimeMode, - cwd: input.cwd, - model: input.model, - threadId, - resumeCursor: resumeState, - createdAt: now, - updatedAt: now, - }, - ws, - gatewaySessionId: resumeState?.gatewaySessionId ?? "", - pendingRpcCalls: new Map(), - pendingApprovals: new Map(), - pendingUserInputs: new Map(), - turnState: undefined, - nextRpcId: 1, - streamFiber: undefined, - stopped: false, - }; - - sessions.set(threadId, context); - - // Set up message handler. - yield* setupWsMessageHandler(context); - - // Authenticate if password is provided. - if (password) { - yield* sendRpc(context, "auth.authenticate", { password }); - } - - // Create or resume session. - if (resumeState?.gatewaySessionId) { - const response = yield* sendRpc(context, "session.resume", { - sessionId: resumeState.gatewaySessionId, - }); - const result = (response.result ?? {}) as Record; - context.gatewaySessionId = - typeof result.sessionId === "string" ? result.sessionId : resumeState.gatewaySessionId; - } else { - const response = yield* sendRpc(context, "session.create", { - ...(input.model ? { model: input.model } : {}), - ...(input.cwd ? { cwd: input.cwd } : {}), - runtimeMode: input.runtimeMode, - }); - const result = (response.result ?? {}) as Record; - context.gatewaySessionId = - typeof result.sessionId === "string" ? result.sessionId : crypto.randomUUID(); - } - - // Update session with resume cursor. - const cursor = makeResumeCursor(context.gatewaySessionId); - context.session = { - ...context.session, - status: "ready", - resumeCursor: cursor, - updatedAt: nowIsoString(), - }; - - // Emit session started events. - yield* emitEvent({ - ...makeEventBase(threadId), - type: "session.started", - payload: { message: "OpenClaw gateway session started." }, - }); - - yield* emitEvent({ - ...makeEventBase(threadId), - type: "session.state.changed", - payload: { state: "ready" }, - }); - - return context.session; - }); - - // ── Adapter interface: sendTurn ────────────────────────────── - - const sendTurn: OpenClawAdapterShape["sendTurn"] = (input) => - Effect.gen(function* () { - const context = yield* requireSession(input.threadId); - const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); - const now = nowIsoString(); - - context.turnState = { turnId, startedAt: now }; - context.session = { - ...context.session, - status: "running", - activeTurnId: turnId, - updatedAt: now, - ...(input.model ? { model: input.model } : {}), - }; - - // Send turn to gateway. - yield* sendRpc(context, "session.sendTurn", { - sessionId: context.gatewaySessionId, - ...(input.input ? { input: input.input } : {}), - ...(input.model ? { model: input.model } : {}), - ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), - }); - - // Emit turn.started. - yield* emitEvent({ - ...makeEventBase(input.threadId, turnId), - type: "turn.started", - payload: {}, - }); - - return { - threadId: input.threadId, - turnId, - resumeCursor: makeResumeCursor(context.gatewaySessionId), - }; - }); - - // ── Adapter interface: interruptTurn ───────────────────────── - - const interruptTurn: OpenClawAdapterShape["interruptTurn"] = (threadId, turnId) => - Effect.gen(function* () { - const context = yield* requireSession(threadId); - yield* sendRpc(context, "session.interrupt", { - sessionId: context.gatewaySessionId, - ...(turnId ? { turnId } : {}), - }); - }); - - // ── Adapter interface: respondToRequest ────────────────────── - - const respondToRequest: OpenClawAdapterShape["respondToRequest"] = ( - threadId, - requestId, - decision, - ) => - Effect.gen(function* () { - const context = yield* requireSession(threadId); - const pending = context.pendingApprovals.get(requestId); - if (pending) { - yield* Deferred.succeed(pending.decision, decision); - context.pendingApprovals.delete(requestId); - } - - // Notify gateway of the decision. - yield* sendRpc(context, "approval.respond", { - sessionId: context.gatewaySessionId, - requestId, - decision, - }); - - yield* emitEvent({ - ...makeEventBase(threadId, context.turnState?.turnId, undefined, requestId), - type: "request.resolved", - payload: { - requestType: pending?.requestType ?? "unknown", - decision, - }, - }); - }); - - // ── Adapter interface: respondToUserInput ──────────────────── - - const respondToUserInput: OpenClawAdapterShape["respondToUserInput"] = ( - threadId, - requestId, - answers, - ) => - Effect.gen(function* () { - const context = yield* requireSession(threadId); - const pending = context.pendingUserInputs.get(requestId); - if (pending) { - yield* Deferred.succeed(pending.answers, answers); - context.pendingUserInputs.delete(requestId); - } - - // Notify gateway of the user input. - yield* sendRpc(context, "user-input.respond", { - sessionId: context.gatewaySessionId, - requestId, - answers, - }); - - yield* emitEvent({ - ...makeEventBase(threadId, context.turnState?.turnId, undefined, requestId), - type: "user-input.resolved", - payload: { answers }, - }); - }); - - // ── Adapter interface: stopSession ─────────────────────────── - - const stopSession: OpenClawAdapterShape["stopSession"] = (threadId) => - Effect.gen(function* () { - const context = sessions.get(threadId); - if (!context) return; - yield* stopSessionInternal(context, { emitExitEvent: true }); - }); - - // ── Adapter interface: listSessions ───────────────────────── - - const listSessions: OpenClawAdapterShape["listSessions"] = () => - Effect.sync(() => Array.from(sessions.values()).map((ctx) => ctx.session)); - - // ── Adapter interface: hasSession ─────────────────────────── - - const hasSession: OpenClawAdapterShape["hasSession"] = (threadId) => - Effect.sync(() => sessions.has(threadId)); - - // ── Adapter interface: readThread (MVP: unsupported) ──────── - - const readThread: OpenClawAdapterShape["readThread"] = (_threadId) => - Effect.fail( - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "readThread", - detail: "Thread snapshot reading is not supported for OpenClaw in this version.", - }), - ); - - // ── Adapter interface: rollbackThread (MVP: unsupported) ──── - - const rollbackThread: OpenClawAdapterShape["rollbackThread"] = (_threadId, _numTurns) => - Effect.fail( - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "rollbackThread", - detail: "Thread rollback is not supported for OpenClaw in this version.", - }), - ); - - // ── Adapter interface: stopAll ────────────────────────────── - - const stopAll: OpenClawAdapterShape["stopAll"] = () => - Effect.gen(function* () { - const allContexts = Array.from(sessions.values()); - yield* Effect.forEach( - allContexts, - (context) => stopSessionInternal(context, { emitExitEvent: true }), - { discard: true }, - ); - }); - - // ── Finalizer ─────────────────────────────────────────────── - - yield* Effect.addFinalizer(() => - Effect.forEach( - Array.from(sessions.values()), - (context) => stopSessionInternal(context, { emitExitEvent: false }), - { discard: true }, - ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))), - ); - - // ── Return adapter shape ──────────────────────────────────── - - return { - provider: PROVIDER, - capabilities: { sessionModelSwitch: "restart-session" }, - startSession, - sendTurn, - interruptTurn, - respondToRequest, - respondToUserInput, - stopSession, - listSessions, - hasSession, - readThread, - rollbackThread, - stopAll, - streamEvents: Stream.fromQueue(runtimeEventQueue), - } satisfies OpenClawAdapterShape; - }); -} - -// ── Layer exports ─────────────────────────────────────────────── - -export const OpenClawAdapterLive = Layer.effect(OpenClawAdapter, makeOpenClawAdapter()); - -export function makeOpenClawAdapterLive(options?: OpenClawAdapterLiveOptions) { - return Layer.effect(OpenClawAdapter, makeOpenClawAdapter(options)); -} diff --git a/apps/server/src/provider/Layers/OpenClawGatewayClient.ts b/apps/server/src/provider/Layers/OpenClawGatewayClient.ts deleted file mode 100644 index 451c2ea17..000000000 --- a/apps/server/src/provider/Layers/OpenClawGatewayClient.ts +++ /dev/null @@ -1,940 +0,0 @@ -import { - createHash, - generateKeyPairSync, - sign as cryptoSign, - createPrivateKey, - createPublicKey, -} from "node:crypto"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import os from "node:os"; -import { join } from "node:path"; - -import WebSocket from "ws"; - -const OPENCLAW_PROTOCOL_VERSION = 3; -const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; -const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; -const AUTH_STATE_FILE_NAME = "openclaw-gateway-auth.json"; -const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); - -export const OPENCLAW_GATEWAY_CLIENT_IDS = { - WEBCHAT_UI: "webchat-ui", - CONTROL_UI: "openclaw-control-ui", - TUI: "openclaw-tui", - WEBCHAT: "webchat", - CLI: "cli", - GATEWAY_CLIENT: "gateway-client", - MACOS_APP: "openclaw-macos", - IOS_APP: "openclaw-ios", - ANDROID_APP: "openclaw-android", - NODE_HOST: "node-host", - TEST: "test", - FINGERPRINT: "fingerprint", - PROBE: "openclaw-probe", -} as const; - -export type OpenClawGatewayClientId = - (typeof OPENCLAW_GATEWAY_CLIENT_IDS)[keyof typeof OPENCLAW_GATEWAY_CLIENT_IDS]; - -export const OPENCLAW_GATEWAY_CLIENT_MODES = { - WEBCHAT: "webchat", - CLI: "cli", - UI: "ui", - BACKEND: "backend", - NODE: "node", - PROBE: "probe", - TEST: "test", -} as const; - -export type OpenClawGatewayClientMode = - (typeof OPENCLAW_GATEWAY_CLIENT_MODES)[keyof typeof OPENCLAW_GATEWAY_CLIENT_MODES]; - -export interface OpenClawGatewayClientInfo { - readonly id: OpenClawGatewayClientId; - readonly displayName?: string | undefined; - readonly version: string; - readonly platform: string; - readonly deviceFamily?: string | undefined; - readonly modelIdentifier?: string | undefined; - readonly mode: OpenClawGatewayClientMode; - readonly instanceId?: string | undefined; -} - -export interface OpenClawGatewayConnectOptions { - readonly gatewayUrl: string; - readonly stateDir?: string | undefined; - readonly sessionKey?: string | undefined; - readonly role: "operator" | "node"; - readonly scopes: ReadonlyArray; - readonly client: OpenClawGatewayClientInfo; - readonly userAgent: string; - readonly locale?: string | undefined; - readonly caps?: ReadonlyArray | undefined; - readonly commands?: ReadonlyArray | undefined; - readonly permissions?: Record | undefined; - readonly password?: string | undefined; - readonly deviceToken?: string | undefined; - readonly onEvent?: ((event: OpenClawGatewayEvent) => void) | undefined; - readonly connectTimeoutMs?: number | undefined; - readonly requestTimeoutMs?: number | undefined; -} - -export interface OpenClawGatewayEvent { - readonly event: string; - readonly payload?: unknown; - readonly seq?: number; - readonly stateVersion?: number; -} - -export interface OpenClawGatewayError { - readonly code?: string | undefined; - readonly message: string; - readonly details?: Record | undefined; -} - -export interface OpenClawGatewayRequestResult { - readonly ok: boolean; - readonly payload?: T; - readonly error?: OpenClawGatewayError; -} - -export interface OpenClawGatewayConnection { - readonly origin: string; - readonly sessionKey: string; - readonly deviceId: string; - request( - method: string, - params?: Record, - timeoutMs?: number, - ): Promise>; - close(): Promise; -} - -type OpenClawConnectionStage = "websocket" | "handshake"; - -interface PersistedOpenClawGatewayAuthState { - readonly version: 1; - readonly device: { - readonly id: string; - readonly privateKeyPem: string; - readonly publicKeyPem: string; - }; - readonly deviceTokens: Record; -} - -interface OpenClawDeviceIdentity { - readonly id: string; - readonly privateKeyPem: string; - readonly publicKeyPem: string; -} - -interface GatewayFrame { - readonly type?: unknown; - readonly id?: unknown; - readonly ok?: unknown; - readonly method?: unknown; - readonly event?: unknown; - readonly params?: unknown; - readonly payload?: unknown; - readonly error?: unknown; - readonly seq?: unknown; - readonly stateVersion?: unknown; -} - -interface GatewayChallengePayload { - readonly nonce?: unknown; - readonly ts?: unknown; -} - -interface GatewayConnectPayload { - readonly type?: unknown; - readonly protocol?: unknown; - readonly auth?: { - readonly deviceToken?: unknown; - }; -} - -type OpenClawGatewayAuthSelection = - | { readonly kind: "token"; readonly value: string } - | { readonly kind: "password"; readonly value: string } - | { readonly kind: "deviceToken"; readonly value: string } - | { readonly kind: "none" }; - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function normalizePathSegments(value: string): string { - return value.replaceAll(/[^a-zA-Z0-9._-]+/g, "-"); -} - -function getDefaultStateDir(): string { - return join(os.tmpdir(), "okcode-openclaw-gateway"); -} - -function getAuthStatePath(stateDir: string): string { - return join(stateDir, "openclaw", AUTH_STATE_FILE_NAME); -} - -function exportPublicKeyPem(publicKey: ReturnType): string { - return publicKey.export({ format: "pem", type: "spki" }).toString(); -} - -function exportPrivateKeyPem(privateKey: ReturnType): string { - return privateKey.export({ format: "pem", type: "pkcs8" }).toString(); -} - -function base64UrlEncode(buf: Buffer): string { - return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, ""); -} - -function derivePublicKeyRaw(publicKeyPem: string): Buffer { - const publicKey = createPublicKey(publicKeyPem); - const spki = publicKey.export({ format: "der", type: "spki" }) as Buffer; - if ( - spki.length === ED25519_SPKI_PREFIX.length + 32 && - spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX) - ) { - return spki.subarray(ED25519_SPKI_PREFIX.length); - } - return spki; -} - -function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string { - return base64UrlEncode(derivePublicKeyRaw(publicKeyPem)); -} - -function fingerprintPublicKey(publicKeyPem: string): string { - return createHash("sha256").update(derivePublicKeyRaw(publicKeyPem)).digest("hex"); -} - -function makeDeviceIdentity(): OpenClawDeviceIdentity { - const { privateKey, publicKey } = generateKeyPairSync("ed25519"); - const privateKeyPem = exportPrivateKeyPem(privateKey); - const publicKeyPem = exportPublicKeyPem(publicKey); - const deviceId = fingerprintPublicKey(publicKeyPem); - return { - id: deviceId, - privateKeyPem, - publicKeyPem, - }; -} - -function normalizeDeviceMetadataForAuth(value?: string | undefined): string { - const trimmed = value?.trim() ?? ""; - return trimmed.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32)); -} - -function buildDeviceAuthPayloadV3(input: { - readonly deviceId: string; - readonly client: OpenClawGatewayClientInfo; - readonly role: "operator" | "node"; - readonly scopes: ReadonlyArray; - readonly signedAtMs: number; - readonly token?: string | undefined; - readonly nonce: string; -}): string { - return [ - "v3", - input.deviceId, - input.client.id, - input.client.mode, - input.role, - input.scopes.join(","), - String(input.signedAtMs), - input.token ?? "", - input.nonce, - normalizeDeviceMetadataForAuth(input.client.platform), - normalizeDeviceMetadataForAuth(input.client.deviceFamily), - ].join("|"); -} - -function signDevicePayload( - identity: OpenClawDeviceIdentity, - input: Parameters[0], -): string { - const privateKey = createPrivateKey(identity.privateKeyPem); - const signature = cryptoSign( - null, - Buffer.from(buildDeviceAuthPayloadV3(input), "utf8"), - privateKey, - ); - return base64UrlEncode(signature); -} - -async function readAuthState(stateDir: string): Promise { - try { - const raw = await readFile(getAuthStatePath(stateDir), "utf8"); - const parsed = JSON.parse(raw) as unknown; - if ( - !isObject(parsed) || - parsed.version !== 1 || - !isObject(parsed.device) || - typeof parsed.device.id !== "string" || - typeof parsed.device.privateKeyPem !== "string" || - typeof parsed.device.publicKeyPem !== "string" || - !isObject(parsed.deviceTokens) - ) { - return null; - } - return { - version: 1, - device: { - id: parsed.device.id, - privateKeyPem: parsed.device.privateKeyPem, - publicKeyPem: parsed.device.publicKeyPem, - }, - deviceTokens: Object.fromEntries( - Object.entries(parsed.deviceTokens).filter( - ([origin, token]) => typeof origin === "string" && typeof token === "string", - ), - ) as Record, - }; - } catch { - return null; - } -} - -async function writeAuthState( - stateDir: string, - state: PersistedOpenClawGatewayAuthState, -): Promise { - await mkdir(join(stateDir, "openclaw"), { recursive: true }); - await writeFile(getAuthStatePath(stateDir), `${JSON.stringify(state, null, 2)}\n`, "utf8"); -} - -function normalizePersistedAuthState( - state: PersistedOpenClawGatewayAuthState, -): PersistedOpenClawGatewayAuthState { - try { - const normalizedDeviceId = fingerprintPublicKey(state.device.publicKeyPem); - if (normalizedDeviceId === state.device.id) { - return state; - } - return { - ...state, - device: { - ...state.device, - id: normalizedDeviceId, - }, - }; - } catch { - return state; - } -} - -class OpenClawGatewayAuthStore { - private cachedState: PersistedOpenClawGatewayAuthState | undefined; - - constructor(private readonly stateDir: string) {} - - private async loadState(): Promise { - if (this.cachedState !== undefined) { - return this.cachedState; - } - - const storedState = await readAuthState(this.stateDir); - const loaded: PersistedOpenClawGatewayAuthState = - storedState !== null - ? normalizePersistedAuthState(storedState) - : { - version: 1 as const, - device: makeDeviceIdentity(), - deviceTokens: {}, - }; - this.cachedState = loaded; - if (storedState === null || storedState.device.id !== loaded.device.id) { - await writeAuthState(this.stateDir, loaded); - } - return loaded; - } - - async getDeviceIdentity(): Promise { - const state = await this.loadState(); - return state.device; - } - - async getDeviceToken(origin: string): Promise { - const state = await this.loadState(); - return state.deviceTokens[origin]; - } - - async persistDeviceToken(origin: string, token: string): Promise { - const state = await this.loadState(); - if (state.deviceTokens[origin] === token) { - return; - } - this.cachedState = { - ...state, - deviceTokens: { - ...state.deviceTokens, - [origin]: token, - }, - }; - await writeAuthState(this.stateDir, this.cachedState); - } -} - -function parseFrame(data: WebSocket.Data): GatewayFrame | null { - try { - const raw = - typeof data === "string" - ? data - : data instanceof ArrayBuffer - ? Buffer.from(data).toString("utf8") - : Array.isArray(data) - ? Buffer.concat(data).toString("utf8") - : data.toString("utf8"); - const parsed = JSON.parse(raw) as unknown; - return isObject(parsed) ? (parsed as GatewayFrame) : null; - } catch { - return null; - } -} - -function makeRequestError(message: string): Error { - return new Error(message); -} - -function withConnectionStage( - error: T, - stage: OpenClawConnectionStage, -): T & { openClawConnectionStage: OpenClawConnectionStage } { - return Object.assign(error, { openClawConnectionStage: stage }); -} - -function toGatewayError(frameError: unknown): OpenClawGatewayError { - if (!isObject(frameError)) { - return { message: "Gateway request failed." }; - } - const details = isObject(frameError.details) ? frameError.details : undefined; - const code = readString(frameError.code); - return { - message: readString(frameError.message) ?? "Gateway request failed.", - ...(code !== undefined ? { code } : {}), - ...(details ? { details } : {}), - }; -} - -function buildConnectParams(input: { - readonly client: OpenClawGatewayClientInfo; - readonly role: "operator" | "node"; - readonly scopes: ReadonlyArray; - readonly auth: OpenClawGatewayAuthSelection; - readonly challengeNonce: string; - readonly deviceIdentity: OpenClawDeviceIdentity; - readonly userAgent: string; - readonly locale?: string | undefined; - readonly caps?: ReadonlyArray | undefined; - readonly commands?: ReadonlyArray | undefined; - readonly permissions?: Record | undefined; -}): Record { - const signedAtMs = Date.now(); - const auth = - input.auth.kind === "token" - ? { - token: input.auth.value, - } - : input.auth.kind === "password" - ? { - password: input.auth.value, - } - : input.auth.kind === "deviceToken" - ? { - // Legacy compatibility: device-token auth keeps `token` populated too. - token: input.auth.value, - deviceToken: input.auth.value, - } - : undefined; - const signatureToken = - input.auth.kind === "token" || input.auth.kind === "deviceToken" ? input.auth.value : undefined; - return { - minProtocol: OPENCLAW_PROTOCOL_VERSION, - maxProtocol: OPENCLAW_PROTOCOL_VERSION, - client: { - id: input.client.id, - ...(input.client.displayName ? { displayName: input.client.displayName } : {}), - version: input.client.version, - platform: input.client.platform, - ...(input.client.deviceFamily ? { deviceFamily: input.client.deviceFamily } : {}), - ...(input.client.modelIdentifier ? { modelIdentifier: input.client.modelIdentifier } : {}), - mode: input.client.mode, - ...(input.client.instanceId ? { instanceId: input.client.instanceId } : {}), - }, - role: input.role, - scopes: [...input.scopes], - caps: [...(input.caps ?? [])], - commands: [...(input.commands ?? [])], - permissions: { ...input.permissions }, - ...(auth ? { auth } : {}), - locale: input.locale ?? (Intl.DateTimeFormat().resolvedOptions().locale || "en-US"), - userAgent: input.userAgent, - device: { - id: input.deviceIdentity.id, - publicKey: publicKeyRawBase64UrlFromPem(input.deviceIdentity.publicKeyPem), - signature: signDevicePayload(input.deviceIdentity, { - deviceId: input.deviceIdentity.id, - client: input.client, - role: input.role, - scopes: input.scopes, - signedAtMs, - ...(signatureToken !== undefined ? { token: signatureToken } : {}), - nonce: input.challengeNonce, - }), - signedAt: signedAtMs, - nonce: input.challengeNonce, - }, - }; -} - -function isDeviceTokenError(error: OpenClawGatewayError | undefined): boolean { - const code = - error?.details && isObject(error.details) ? readString(error.details.code) : undefined; - return ( - code === "AUTH_TOKEN_MISMATCH" || - code === "AUTH_DEVICE_TOKEN_MISMATCH" || - code?.startsWith("DEVICE_AUTH_") === true || - error?.message.toLowerCase().includes("auth_token_mismatch") === true - ); -} - -function isPasswordAuthError(error: OpenClawGatewayError | undefined): boolean { - const code = - error?.details && isObject(error.details) ? readString(error.details.code) : undefined; - return ( - code === "AUTH_PASSWORD_MISSING" || - code === "AUTH_PASSWORD_MISMATCH" || - error?.message.toLowerCase().includes("auth_password_missing") === true || - error?.message.toLowerCase().includes("auth_password_mismatch") === true - ); -} - -export function createOpenClawIdempotencyKey(parts: ReadonlyArray): string { - return `okcode-${createHash("sha256").update(parts.join("\u0000")).digest("hex")}`; -} - -export async function connectOpenClawGateway( - options: OpenClawGatewayConnectOptions, -): Promise { - const parsedUrl = new URL(options.gatewayUrl); - const origin = parsedUrl.origin; - const stateDir = options.stateDir ?? getDefaultStateDir(); - const authStore = new OpenClawGatewayAuthStore(stateDir); - const deviceIdentity = await authStore.getDeviceIdentity(); - const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; - const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; - - const deviceTokenSelections: OpenClawGatewayAuthSelection[] = []; - if (options.deviceToken && options.deviceToken.length > 0) { - deviceTokenSelections.push({ kind: "deviceToken", value: options.deviceToken }); - } - const cachedDeviceToken = await authStore.getDeviceToken(origin); - if (cachedDeviceToken) { - deviceTokenSelections.push({ kind: "deviceToken", value: cachedDeviceToken }); - } - - let lastError: Error | undefined; - const attemptConnect = async (auth: OpenClawGatewayAuthSelection) => - await connectOnce({ - gatewayUrl: options.gatewayUrl, - origin, - authStore, - deviceIdentity, - auth, - connectTimeoutMs, - requestTimeoutMs, - onEvent: options.onEvent, - client: options.client, - role: options.role, - scopes: options.scopes, - userAgent: options.userAgent, - locale: options.locale, - caps: options.caps, - commands: options.commands, - permissions: options.permissions, - sessionKey: options.sessionKey ?? `okcode:${normalizePathSegments(options.client.id)}`, - }); - - const sharedSecret = options.password?.trim(); - if (sharedSecret) { - try { - return await attemptConnect({ kind: "token", value: sharedSecret }); - } catch (cause) { - const error = cause instanceof Error ? cause : new Error(String(cause)); - lastError = error; - const parsedError = error as Error & { readonly gatewayError?: OpenClawGatewayError }; - const gatewayError = parsedError.gatewayError; - - if (isPasswordAuthError(gatewayError)) { - try { - return await attemptConnect({ kind: "password", value: sharedSecret }); - } catch (passwordCause) { - lastError = - passwordCause instanceof Error ? passwordCause : new Error(String(passwordCause)); - } - } else if (deviceTokenSelections.length > 0 && isDeviceTokenError(gatewayError)) { - for (const auth of deviceTokenSelections) { - try { - return await attemptConnect(auth); - } catch (deviceTokenCause) { - lastError = - deviceTokenCause instanceof Error - ? deviceTokenCause - : new Error(String(deviceTokenCause)); - } - } - } - } - } - - for (const auth of deviceTokenSelections) { - try { - return await attemptConnect(auth); - } catch (cause) { - lastError = cause instanceof Error ? cause : new Error(String(cause)); - } - } - - if (!sharedSecret) { - try { - return await attemptConnect({ kind: "none" }); - } catch (cause) { - lastError = cause instanceof Error ? cause : new Error(String(cause)); - } - } - - throw lastError ?? new Error("OpenClaw gateway connect failed."); -} - -async function connectOnce(input: { - readonly gatewayUrl: string; - readonly origin: string; - readonly authStore: OpenClawGatewayAuthStore; - readonly deviceIdentity: OpenClawDeviceIdentity; - readonly auth: OpenClawGatewayAuthSelection; - readonly connectTimeoutMs: number; - readonly requestTimeoutMs: number; - readonly onEvent?: ((event: OpenClawGatewayEvent) => void) | undefined; - readonly client: OpenClawGatewayClientInfo; - readonly role: "operator" | "node"; - readonly scopes: ReadonlyArray; - readonly userAgent: string; - readonly locale?: string | undefined; - readonly caps?: ReadonlyArray | undefined; - readonly commands?: ReadonlyArray | undefined; - readonly permissions?: Record | undefined; - readonly sessionKey: string; -}): Promise { - return await new Promise((resolve, reject) => { - const ws = new WebSocket(input.gatewayUrl); - const pendingRequests = new Map< - string, - { - readonly resolve: (value: OpenClawGatewayRequestResult) => void; - readonly reject: (reason: unknown) => void; - } - >(); - const bufferedEvents: OpenClawGatewayEvent[] = []; - let connected = false; - let closed = false; - let socketOpened = false; - let handshakeSettled = false; - let nextRequestId = 1; - let challengeNonce: string | undefined; - let challengeResolved = false; - let resolveChallenge: - | ((value: { readonly nonce: string; readonly ts?: number }) => void) - | undefined; - let rejectChallenge: ((reason: Error) => void) | undefined; - const challengePromise = new Promise<{ readonly nonce: string; readonly ts?: number }>( - (resolveChallengePromise, rejectChallengePromise) => { - resolveChallenge = resolveChallengePromise; - rejectChallenge = rejectChallengePromise; - }, - ); - - const cleanup = (): void => { - ws.off("message", onMessage); - ws.off("close", onClose); - ws.off("error", onError); - }; - - const rejectAllPending = (reason: unknown): void => { - for (const [, pending] of pendingRequests) { - pending.reject(reason); - } - pendingRequests.clear(); - }; - - const settleHandshakeFailure = (reason: Error): void => { - if (handshakeSettled) { - return; - } - handshakeSettled = true; - closed = true; - const stagedReason = withConnectionStage(reason, socketOpened ? "handshake" : "websocket"); - rejectChallenge?.(stagedReason); - cleanup(); - try { - ws.close(); - } catch { - // ignore close errors - } - reject(stagedReason); - }; - - const deliverBufferedEvents = (): void => { - if (bufferedEvents.length === 0) { - return; - } - for (const event of bufferedEvents.splice(0)) { - input.onEvent?.(event); - } - }; - - const onClose = (code: number, reasonBuffer: Buffer): void => { - closed = true; - const reasonText = reasonBuffer.toString("utf8"); - const closeError = new Error( - reasonText.length > 0 - ? `WebSocket closed with code ${code}: ${reasonText}` - : `WebSocket closed with code ${code}`, - ); - rejectAllPending(closeError); - if (!challengeResolved) { - rejectChallenge?.(closeError); - } - if (!handshakeSettled) { - settleHandshakeFailure(closeError); - } - }; - - const onError = (cause: Error): void => { - if (!handshakeSettled) { - settleHandshakeFailure(cause); - return; - } - rejectAllPending(cause); - if (!challengeResolved) { - rejectChallenge?.(cause); - } - }; - - const onMessage = (data: WebSocket.Data): void => { - const frame = parseFrame(data); - if (!frame || closed) { - return; - } - - if (frame.type === "event") { - const eventName = readString(frame.event); - if (!eventName) { - return; - } - if (eventName === "connect.challenge") { - input.onEvent?.({ - event: eventName, - ...(frame.payload !== undefined ? { payload: frame.payload } : {}), - ...(typeof frame.seq === "number" ? { seq: frame.seq } : {}), - ...(typeof frame.stateVersion === "number" ? { stateVersion: frame.stateVersion } : {}), - }); - const payload = isObject(frame.payload) - ? (frame.payload as GatewayChallengePayload) - : undefined; - const nonce = readString(payload?.nonce); - if (nonce && !challengeNonce) { - challengeNonce = nonce; - challengeResolved = true; - resolveChallenge?.({ - nonce, - ...(typeof payload?.ts === "number" ? { ts: payload.ts } : {}), - }); - } - return; - } - const event: OpenClawGatewayEvent = { - event: eventName, - ...(frame.payload !== undefined ? { payload: frame.payload } : {}), - ...(typeof frame.seq === "number" ? { seq: frame.seq } : {}), - ...(typeof frame.stateVersion === "number" ? { stateVersion: frame.stateVersion } : {}), - }; - if (!connected) { - bufferedEvents.push(event); - return; - } - input.onEvent?.(event); - return; - } - - if (frame.type !== "res") { - return; - } - - const id = readString(frame.id); - if (id === undefined) { - return; - } - const pending = pendingRequests.get(id); - if (pending === undefined) { - return; - } - pendingRequests.delete(id); - if (frame.ok === true) { - const payload = frame.payload as GatewayConnectPayload | undefined; - if (payload && isObject(payload) && payload.type === "hello-ok") { - const auth = isObject(payload.auth) ? payload.auth : undefined; - const token = readString(auth?.deviceToken); - if (token) { - void input.authStore.persistDeviceToken(input.origin, token); - } - } - pending.resolve({ - ok: true, - ...(frame.payload !== undefined ? { payload: frame.payload } : {}), - }); - return; - } - const gatewayError = toGatewayError(frame.error); - pending.resolve({ ok: false, error: gatewayError }); - }; - - ws.on("message", onMessage); - ws.on("close", onClose); - ws.on("error", onError); - - const connectTimeout = setTimeout(() => { - settleHandshakeFailure( - makeRequestError( - `Connection to ${input.gatewayUrl} timed out after ${input.connectTimeoutMs}ms.`, - ), - ); - }, input.connectTimeoutMs); - - ws.once("open", () => { - socketOpened = true; - void (async () => { - try { - const challenge = await challengePromise; - challengeNonce = challenge.nonce; - const requestId = `connect-${nextRequestId++}`; - const requestResult = new Promise((resolve, reject) => { - pendingRequests.set(requestId, { resolve, reject }); - }); - ws.send( - JSON.stringify({ - type: "req", - id: requestId, - method: "connect", - params: buildConnectParams({ - client: input.client, - role: input.role, - scopes: input.scopes, - auth: input.auth, - challengeNonce, - deviceIdentity: input.deviceIdentity, - userAgent: input.userAgent, - locale: input.locale, - caps: input.caps, - commands: input.commands, - permissions: input.permissions, - }), - }), - ); - const response = await requestResult; - clearTimeout(connectTimeout); - handshakeSettled = true; - if (!response.ok) { - const error = withConnectionStage( - new Error(response.error?.message ?? "Gateway connect failed."), - "handshake", - ) as Error & { - gatewayError?: OpenClawGatewayError | undefined; - }; - error.gatewayError = response.error; - cleanup(); - try { - ws.close(); - } catch { - // ignore close errors - } - reject(error); - return; - } - connected = true; - deliverBufferedEvents(); - resolve({ - origin: input.origin, - sessionKey: input.sessionKey, - deviceId: input.deviceIdentity.id, - request( - method: string, - params?: Record, - timeoutMs?: number, - ) { - if (closed) { - return Promise.reject(makeRequestError("Gateway connection is closed.")); - } - const id = `req-${nextRequestId++}`; - const deadlineMs = timeoutMs ?? input.requestTimeoutMs; - return new Promise>( - (resolveRequest, rejectRequest) => { - const timer = setTimeout(() => { - pendingRequests.delete(id); - rejectRequest( - makeRequestError(`RPC call '${method}' timed out after ${deadlineMs}ms.`), - ); - }, deadlineMs); - - pendingRequests.set(id, { - resolve: (result) => { - clearTimeout(timer); - resolveRequest(result as OpenClawGatewayRequestResult); - }, - reject: (reason) => { - clearTimeout(timer); - rejectRequest(reason); - }, - }); - - try { - ws.send( - JSON.stringify({ - type: "req", - id, - method, - ...(params !== undefined ? { params } : {}), - }), - ); - } catch (cause) { - clearTimeout(timer); - pendingRequests.delete(id); - rejectRequest(cause); - } - }, - ); - }, - close: async () => { - closed = true; - clearTimeout(connectTimeout); - rejectAllPending(makeRequestError("Gateway connection closed.")); - cleanup(); - try { - ws.close(); - } catch { - // ignore close errors - } - }, - }); - } catch (cause) { - clearTimeout(connectTimeout); - const error = cause instanceof Error ? cause : new Error(String(cause)); - settleHandshakeFailure(error); - } - })(); - }); - }); -} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 9d8939d13..26a06bd63 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -7,7 +7,6 @@ import { Effect, Layer, Stream } from "effect"; import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { CopilotAdapter, CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; -import { OpenClawAdapter, OpenClawAdapterShape } from "../Services/OpenClawAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -47,23 +46,6 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { streamEvents: Stream.empty, }; -const fakeOpenClawAdapter: OpenClawAdapterShape = { - provider: "openclaw", - capabilities: { sessionModelSwitch: "restart-session" }, - 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 fakeCopilotAdapter: CopilotAdapterShape = { provider: "copilot", capabilities: { sessionModelSwitch: "in-session" }, @@ -88,7 +70,6 @@ const layer = it.layer( Layer.mergeAll( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), - Layer.succeed(OpenClawAdapter, fakeOpenClawAdapter), Layer.succeed(CopilotAdapter, fakeCopilotAdapter), ), ), @@ -106,7 +87,7 @@ layer("ProviderAdapterRegistryLive", (it) => { assert.equal(claude, fakeClaudeAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent", "openclaw", "copilot"]); + assert.deepEqual(providers, ["codex", "claudeAgent", "copilot"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index e15f88b63..3b147da56 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -20,7 +20,6 @@ import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { CopilotAdapter } from "../Services/CopilotAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { GeminiAdapter } from "../Services/GeminiAdapter.ts"; -import { OpenClawAdapter } from "../Services/OpenClawAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -35,7 +34,6 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption : [ yield* CodexAdapter, yield* ClaudeAdapter, - yield* OpenClawAdapter, yield* CopilotAdapter, ...(Option.isSome(maybeGeminiAdapter) ? [maybeGeminiAdapter.value] : []), ]; diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 2b644bb44..d4e0dab85 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -7,7 +7,6 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { checkClaudeProviderStatus, checkCodexProviderStatus, - isOpenClawGatewayUnauthenticatedDetailCode, parseAuthStatusFromOutput, parseClaudeAuthStatusFromOutput, setCodexAppServerThreadStartProbeForTest, @@ -106,21 +105,6 @@ function withCodexAppServerThreadStartProbe( } it.layer(NodeServices.layer)("ProviderHealth", (it) => { - describe("isOpenClawGatewayUnauthenticatedDetailCode", () => { - it("treats password-mode auth failures as unauthenticated", () => { - assert.strictEqual(isOpenClawGatewayUnauthenticatedDetailCode("AUTH_PASSWORD_MISSING"), true); - assert.strictEqual( - isOpenClawGatewayUnauthenticatedDetailCode("AUTH_PASSWORD_MISMATCH"), - true, - ); - assert.strictEqual(isOpenClawGatewayUnauthenticatedDetailCode("AUTH_TOKEN_MISSING"), true); - assert.strictEqual( - isOpenClawGatewayUnauthenticatedDetailCode("SOME_OTHER_GATEWAY_CODE"), - false, - ); - }); - }); - // ── checkCodexProviderStatus tests ──────────────────────────────── // // These tests control CODEX_HOME to ensure the custom-provider detection diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index c9cd1064a..691ec9a31 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -20,10 +20,7 @@ import type { import { Array, Data, Effect, FileSystem, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { serverBuildInfo } from "../../buildInfo.ts"; import { buildCodexInitializeParams } from "../../codexAppServerManager.ts"; -import { OpenclawGatewayClient, OpenclawGatewayClientError } from "../../openclaw/GatewayClient.ts"; -import { OpenclawGatewayConfig } from "../../persistence/Services/OpenclawGatewayConfig.ts"; import { formatCodexCliUpgradeMessage, isCodexCliVersionSupported, @@ -39,10 +36,6 @@ const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const; const COPILOT_PROVIDER = "copilot" as const; const GEMINI_PROVIDER = "gemini" as const; -class OpenClawHealthProbeError extends Data.TaggedError("OpenClawHealthProbeError")<{ - cause: unknown; -}> {} - class CopilotHealthProbeError extends Data.TaggedError("CopilotHealthProbeError")<{ cause: unknown; }> {} @@ -66,28 +59,6 @@ function nonEmptyVersion(stdout: string, stderr: string): string | null { return version ?? null; } -const OPENCLAW_HEALTH_REQUIRED_METHODS = [ - "sessions.create", - "sessions.get", - "sessions.send", - "sessions.abort", - "sessions.messages.subscribe", -] as const; - -export function isOpenClawGatewayUnauthenticatedDetailCode( - detailCode: string | undefined, -): boolean { - return ( - detailCode === "PAIRING_REQUIRED" || - detailCode === "AUTH_TOKEN_MISSING" || - detailCode === "AUTH_PASSWORD_MISSING" || - detailCode === "AUTH_TOKEN_MISMATCH" || - detailCode === "AUTH_PASSWORD_MISMATCH" || - detailCode === "AUTH_DEVICE_TOKEN_MISMATCH" || - detailCode?.startsWith("DEVICE_AUTH_") === true - ); -} - // ── Pure helpers ──────────────────────────────────────────────────── export interface CommandResult { @@ -967,156 +938,6 @@ export const checkClaudeProviderStatus: Effect.Effect< }); }); -// ── OpenClaw health check ───────────────────────────────────────── - -const OPENCLAW_PROVIDER = "openclaw" as const; - -const checkOpenClawProviderStatus: Effect.Effect< - ServerProviderStatus, - never, - OpenclawGatewayConfig -> = Effect.gen(function* () { - const checkedAt = new Date().toISOString(); - const gatewayConfig = yield* OpenclawGatewayConfig; - const resolvedConfigResult = yield* gatewayConfig.resolveForConnect().pipe( - Effect.match({ - onSuccess: (resolvedConfig) => ({ ok: true as const, resolvedConfig }), - onFailure: (cause) => ({ ok: false as const, cause }), - }), - ); - - if (!resolvedConfigResult.ok) { - const reason = - resolvedConfigResult.cause instanceof Error - ? resolvedConfigResult.cause.message - : String(resolvedConfigResult.cause); - - return createServerProviderStatus({ - provider: OPENCLAW_PROVIDER, - enabled: true, - installed: false, - version: null, - status: "error" as const, - auth: { status: "unknown" as const }, - checkedAt, - message: `OpenClaw gateway configuration could not be read. ${reason}`, - }); - } - - const resolvedConfig = resolvedConfigResult.resolvedConfig; - - if (!resolvedConfig) { - return createServerProviderStatus({ - provider: OPENCLAW_PROVIDER, - enabled: true, - installed: false, - version: null, - status: "error" as const, - auth: { status: "unauthenticated" as const }, - checkedAt, - message: "OpenClaw gateway URL is not configured. Save it in Settings to enable OpenClaw.", - }); - } - - const connectResult = yield* Effect.tryPromise({ - try: async () => { - const connection = await OpenclawGatewayClient.connect({ - url: resolvedConfig.gatewayUrl, - identity: { - deviceId: resolvedConfig.deviceId, - deviceFingerprint: resolvedConfig.deviceFingerprint, - publicKey: resolvedConfig.devicePublicKey, - privateKeyPem: resolvedConfig.devicePrivateKeyPem, - }, - ...(resolvedConfig.sharedSecret ? { sharedSecret: resolvedConfig.sharedSecret } : {}), - ...(resolvedConfig.deviceToken ? { deviceToken: resolvedConfig.deviceToken } : {}), - ...(resolvedConfig.deviceTokenRole - ? { deviceTokenRole: resolvedConfig.deviceTokenRole } - : {}), - ...(resolvedConfig.deviceTokenScopes.length > 0 - ? { deviceTokenScopes: resolvedConfig.deviceTokenScopes } - : {}), - clientId: "okcode", - clientVersion: serverBuildInfo.version, - clientPlatform: - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : process.platform, - clientMode: "operator", - locale: Intl.DateTimeFormat().resolvedOptions().locale || "en-US", - userAgent: `okcode/${serverBuildInfo.version}`, - role: "operator", - scopes: ["operator.read", "operator.write"], - requiredMethods: OPENCLAW_HEALTH_REQUIRED_METHODS, - }); - try { - const deviceToken = connection.connect.auth?.deviceToken; - if (deviceToken && deviceToken !== resolvedConfig.deviceToken) { - await Effect.runPromise( - gatewayConfig.saveDeviceToken({ - deviceToken, - ...(connection.connect.auth?.role ? { role: connection.connect.auth.role } : {}), - ...(connection.connect.auth?.scopes.length - ? { scopes: connection.connect.auth.scopes } - : {}), - }), - ); - } - } finally { - await connection.client.close(); - } - return connection.connect; - }, - catch: (cause) => new OpenClawHealthProbeError({ cause }), - }).pipe(Effect.result); - - if (Result.isSuccess(connectResult)) { - return createServerProviderStatus({ - provider: OPENCLAW_PROVIDER, - enabled: true, - installed: true, - version: null, - status: "ready" as const, - auth: { status: "authenticated" as const }, - checkedAt, - }); - } - - const cause = connectResult.failure.cause; - if (cause instanceof OpenClawHealthProbeError) { - const error = cause.cause; - if (error instanceof OpenclawGatewayClientError) { - const detailCode = error.gatewayError?.detailCode; - const gatewayMessage = error.gatewayError?.message ?? error.message; - if (isOpenClawGatewayUnauthenticatedDetailCode(detailCode)) { - return createServerProviderStatus({ - provider: OPENCLAW_PROVIDER, - enabled: true, - installed: true, - version: null, - status: "error" as const, - auth: { status: "unauthenticated" as const }, - checkedAt, - message: gatewayMessage, - }); - } - } - } - - return createServerProviderStatus({ - provider: OPENCLAW_PROVIDER, - enabled: true, - installed: true, - version: null, - status: "warning" as const, - auth: { status: "unknown" as const }, - checkedAt, - message: `Cannot complete the OpenClaw gateway handshake at ${resolvedConfig.gatewayUrl}. Check connectivity, proxying, and pairing/device auth state.`, - }); -}); - export const checkGeminiProviderStatus: Effect.Effect< ServerProviderStatus, never, @@ -1203,7 +1024,6 @@ export const ProviderHealthLive = Layer.effect( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const openclawGatewayConfig = yield* OpenclawGatewayConfig; return { getStatuses: Effect.all( @@ -1211,7 +1031,6 @@ export const ProviderHealthLive = Layer.effect( checkCodexProviderStatus, checkClaudeProviderStatus, checkCopilotProviderStatus, - checkOpenClawProviderStatus, checkGeminiProviderStatus, ], { @@ -1220,7 +1039,6 @@ export const ProviderHealthLive = Layer.effect( ).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(OpenclawGatewayConfig, openclawGatewayConfig), ), } satisfies ProviderHealthShape; }), diff --git a/apps/server/src/provider/Services/OpenClawAdapter.ts b/apps/server/src/provider/Services/OpenClawAdapter.ts deleted file mode 100644 index a5b66961e..000000000 --- a/apps/server/src/provider/Services/OpenClawAdapter.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * OpenClawAdapter - OpenClaw gateway implementation of the generic provider adapter contract. - * - * This service owns OpenClaw gateway/runtime session semantics and emits canonical - * provider runtime events. It does not perform cross-provider routing, shared - * event fan-out, or checkpoint orchestration. - * - * Uses Effect `ServiceMap.Service` for dependency injection and returns the - * shared provider-adapter error channel with `provider: "openclaw"` context. - * - * @module OpenClawAdapter - */ -import { ServiceMap } from "effect"; - -import type { ProviderAdapterError } from "../Errors.ts"; -import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; - -/** - * OpenClawAdapterShape - Service API for the OpenClaw gateway provider adapter. - */ -export interface OpenClawAdapterShape extends ProviderAdapterShape { - readonly provider: "openclaw"; -} - -/** - * OpenClawAdapter - Service tag for OpenClaw gateway provider adapter operations. - */ -export class OpenClawAdapter extends ServiceMap.Service()( - "okcode/provider/Services/OpenClawAdapter", -) {} diff --git a/apps/server/src/provider/providerCatalog.ts b/apps/server/src/provider/providerCatalog.ts index cead5f7de..06a89dfef 100644 --- a/apps/server/src/provider/providerCatalog.ts +++ b/apps/server/src/provider/providerCatalog.ts @@ -147,7 +147,6 @@ export const BUILT_IN_PROVIDER_MODELS: Record runOpenclawGatewayTest(input), - catch: (cause) => - new RouteRequestError({ - message: `OpenClaw gateway test failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); -} - const resolveCheckPath = Effect.fn(function* (input: string) { return path.resolve(yield* expandHomePath(input.trim())); }); @@ -355,8 +341,7 @@ export type ServerRuntimeServices = | GitCore | Keybindings | Open - | EnvironmentVariables - | OpenclawGatewayConfig; + | EnvironmentVariables; export class ServerLifecycleError extends Schema.TaggedErrorClass()( "ServerLifecycleError", @@ -411,7 +396,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const keybindingsManager = yield* Keybindings; const providerHealth = yield* ProviderHealth; - const openclawGatewayConfig = yield* OpenclawGatewayConfig; const git = yield* GitCore; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1197,9 +1181,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< isLocalWebSocketClient, pickFolder: pickFolderNative, tokenManager, - openclawGatewayConfig, publishServerConfigUpdated, - testOpenclawGateway, isNewerSemver, createRouteRequestError: (message: string) => new RouteRequestError({ message }), } as any), diff --git a/apps/server/src/wsServer/routes/server.ts b/apps/server/src/wsServer/routes/server.ts index 8ccb0b8d7..6e37c8fd7 100644 --- a/apps/server/src/wsServer/routes/server.ts +++ b/apps/server/src/wsServer/routes/server.ts @@ -46,14 +46,7 @@ export function createServerRouteHandlers(input: { revoke: (tokenId: string) => boolean; list: () => unknown; }; - openclawGatewayConfig: { - getSummary: () => Effect.Effect; - save: (body: unknown) => Effect.Effect; - resetDeviceState: (body: unknown) => Effect.Effect; - resolveForConnect: (body: unknown) => Effect.Effect; - }; publishServerConfigUpdated: () => Effect.Effect; - testOpenclawGateway: (body: unknown) => Effect.Effect; isNewerSemver: (a: string, b: string) => boolean; createRouteRequestError: (message: string) => unknown; }): WebSocketRouteRegistry { @@ -168,47 +161,6 @@ export function createServerRouteHandlers(input: { [WS_METHODS.serverListTokens]: () => Effect.succeed({ tokens: input.tokenManager.list() }), - [WS_METHODS.serverGetOpenclawGatewayConfig]: () => input.openclawGatewayConfig.getSummary(), - - [WS_METHODS.serverSaveOpenclawGatewayConfig]: (_ws, request) => - Effect.gen(function* () { - const body = stripTaggedBody(request.body as any); - const summary = yield* input.openclawGatewayConfig.save(body); - yield* input.publishServerConfigUpdated(); - return summary; - }), - - [WS_METHODS.serverResetOpenclawGatewayDeviceState]: (_ws, request) => - Effect.gen(function* () { - const body = stripTaggedBody(request.body as any); - const summary = yield* input.openclawGatewayConfig.resetDeviceState(body); - yield* input.publishServerConfigUpdated(); - return summary; - }), - - [WS_METHODS.serverTestOpenclawGateway]: (_ws, request) => - Effect.gen(function* () { - const body = stripTaggedBody(request.body as any); - const resolvedConfig = yield* input.openclawGatewayConfig.resolveForConnect({ - ...(body.gatewayUrl ? { gatewayUrl: body.gatewayUrl } : {}), - ...(body.password ? { sharedSecret: body.password } : {}), - allowEphemeralIdentity: body.gatewayUrl !== undefined, - }); - if (!resolvedConfig) { - return yield* Effect.fail( - input.createRouteRequestError( - "OpenClaw gateway URL is not configured. Save it in Settings or provide a test override.", - ), - ); - } - const result = yield* input.testOpenclawGateway({ - gatewayUrl: resolvedConfig.gatewayUrl, - password: body.password ?? resolvedConfig.sharedSecret, - }); - yield* input.publishServerConfigUpdated(); - return result; - }), - [WS_METHODS.serverPing]: () => Effect.succeed({ pong: true, diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 90747fde3..5bf5f424e 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -103,8 +103,6 @@ describe("getProviderStartOptions", () => { codexHomePath: "", copilotBinaryPath: "", copilotConfigDir: "", - openclawGatewayUrl: "", - openclawPassword: "", }), ).toEqual({ claudeAgent: { @@ -121,8 +119,6 @@ describe("getProviderStartOptions", () => { codexHomePath: "", copilotBinaryPath: "", copilotConfigDir: "", - openclawGatewayUrl: "", - openclawPassword: "", }), ).toBeUndefined(); }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 45911f64a..fddbf811c 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -65,7 +65,6 @@ export const DEFAULT_PR_REVIEW_REQUEST_CHANGES_TONE: PrReviewRequestChangesTone type CustomModelSettingsKey = | "customCodexModels" | "customClaudeModels" - | "customOpenClawModels" | "customCopilotModels" | "customGeminiModels"; export type ProviderCustomModelConfig = { @@ -81,7 +80,6 @@ export type ProviderCustomModelConfig = { const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)), - openclaw: new Set(getModelOptions("openclaw").map((option) => option.slug)), copilot: new Set(getModelOptions("copilot").map((option) => option.slug)), gemini: new Set(getModelOptions("gemini").map((option) => option.slug)), }; @@ -149,10 +147,7 @@ export const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customCopilotModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - customOpenClawModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customGeminiModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - openclawGatewayUrl: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), - openclawPassword: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), textGenerationModel: Schema.optional(TrimmedNonEmptyString), }); export type AppSettings = typeof AppSettingsSchema.Type; @@ -191,15 +186,6 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record, ): ProviderStartOptions | undefined { const providerOptions: ProviderStartOptions = { @@ -430,14 +408,6 @@ export function getProviderStartOptions( }, } : {}), - ...(settings.openclawGatewayUrl || settings.openclawPassword - ? { - openclaw: { - ...(settings.openclawGatewayUrl ? { gatewayUrl: settings.openclawGatewayUrl } : {}), - ...(settings.openclawPassword ? { password: settings.openclawPassword } : {}), - }, - } - : {}), }; return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3296d1789..c3e33eb4a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -878,9 +878,8 @@ export default function ChatView({ () => getSelectableThreadProviders({ statuses: providerStatuses, - openclawGatewayUrl: settings.openclawGatewayUrl, }), - [providerStatuses, settings.openclawGatewayUrl], + [providerStatuses], ); const hasThreadStarted = Boolean( activeThread && @@ -5513,7 +5512,6 @@ export default function ChatView({ codexSelectedModelProviderId={ serverConfigQuery.data?.codexConfig?.selectedModelProviderId ?? null } - openclawGatewayUrl={settings.openclawGatewayUrl} {...(composerProviderState.modelPickerIconClassName ? { activeProviderIconClassName: diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 28edf6cfe..7d210fa17 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -296,15 +296,6 @@ export const AntigravityIcon: Icon = (props) => ( ); -export const OpenClawIcon: Icon = (props) => ( - - - -); - export const OpenCodeIcon: Icon = (props) => ( diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 497443d6f..e3862ba03 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -4,7 +4,7 @@ import { ChevronDownIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { getThreadProviderLabel } from "~/lib/providerAvailability"; -import { ClaudeAI, Gemini, GitHubIcon, type Icon, OpenAI, OpenClawIcon } from "../Icons"; +import { ClaudeAI, Gemini, GitHubIcon, type Icon, OpenAI } from "../Icons"; import { Button } from "../ui/button"; import { Menu, @@ -29,37 +29,16 @@ function getCodexLocalBackendLabel(id: string | null | undefined): string | null return CODEX_LOCAL_BACKEND_LABELS[id] ?? null; } -type OpenclawGatewayBadge = "connected" | "url-configured" | null; - -function getOpenclawGatewayBadge(input: { - readonly snapshot: ServerProviderStatus | null; - readonly gatewayUrl: string | null | undefined; -}): OpenclawGatewayBadge { - const snapshot = input.snapshot; - if (snapshot !== null) { - const isAvailable = snapshot.available === true || snapshot.enabled === true; - if (snapshot.status === "ready" && isAvailable) { - return "connected"; - } - } - if (typeof input.gatewayUrl === "string" && input.gatewayUrl.trim().length > 0) { - return "url-configured"; - } - return null; -} - const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, claudeAgent: ClaudeAI, gemini: Gemini, copilot: GitHubIcon, - openclaw: OpenClawIcon, }; function providerIconClassName(provider: ProviderKind, fallbackClassName: string): string { if (provider === "claudeAgent") return "text-[#d97757]"; if (provider === "gemini") return "text-[#78c2ff]"; - if (provider === "openclaw") return "text-[#6cb4ee]"; if (provider === "copilot") return "text-white/85"; return fallbackClassName; } @@ -82,7 +61,6 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { providers: ReadonlyArray; activeProviderIconClassName?: string; codexSelectedModelProviderId?: string | null | undefined; - openclawGatewayUrl?: string | null | undefined; compact?: boolean; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; @@ -170,13 +148,6 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { return null; } - const openclawBadge = - provider === "openclaw" - ? getOpenclawGatewayBadge({ - snapshot: providerSnapshot, - gatewayUrl: props.openclawGatewayUrl, - }) - : null; const codexGroupBackendLabel = provider === "codex" ? getCodexLocalBackendLabel(props.codexSelectedModelProviderId ?? null) @@ -198,21 +169,6 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {codexGroupBackendLabel ? ` · ${codexGroupBackendLabel}` : ""} {props.lockedProvider === provider ? " · locked for this thread" : ""} - {openclawBadge === "connected" ? ( - - ✓ Connected - - ) : openclawBadge === "url-configured" ? ( - - URL configured - - ) : null} = { ), }, - openclaw: { - getState: ({ modelOptions }) => { - const promptEffort = - resolveReasoningEffortForProvider("openclaw", modelOptions?.openclaw?.reasoningEffort) ?? - getDefaultReasoningEffort("openclaw"); - return { - provider: "openclaw", - promptEffort, - modelOptionsForDispatch: modelOptions?.openclaw?.reasoningEffort - ? { openclaw: { reasoningEffort: modelOptions.openclaw.reasoningEffort } } - : undefined, - }; - }, - renderTraitsMenuContent: () => null, - renderTraitsPicker: () => null, - }, copilot: { getState: ({ modelOptions }) => { const defaultPromptEffort = getDefaultReasoningEffort("copilot"); diff --git a/apps/web/src/components/chat/providerStatusPresentation.ts b/apps/web/src/components/chat/providerStatusPresentation.ts index 624bc9cb6..515173cb0 100644 --- a/apps/web/src/components/chat/providerStatusPresentation.ts +++ b/apps/web/src/components/chat/providerStatusPresentation.ts @@ -6,7 +6,6 @@ export type ProviderSetupPhase = "install" | "authenticate" | "verify" | "ready" const PROVIDER_LABELS = { codex: "OpenAI (Codex CLI)", claudeAgent: "Claude Code", - openclaw: "OpenClaw", copilot: "GitHub Copilot", gemini: "Gemini CLI", } as const; diff --git a/apps/web/src/components/home/home-utils.ts b/apps/web/src/components/home/home-utils.ts index ceff5f41e..cbd4d910b 100644 --- a/apps/web/src/components/home/home-utils.ts +++ b/apps/web/src/components/home/home-utils.ts @@ -10,10 +10,10 @@ export function getProviderLabel(provider: ServerProviderStatus["provider"]) { return "Claude"; case "codex": return "Codex"; - case "openclaw": - return "OpenClaw"; case "copilot": return "GitHub Copilot"; + case "gemini": + return "Gemini"; } } diff --git a/apps/web/src/components/settings/ProviderCapabilityMatrix.tsx b/apps/web/src/components/settings/ProviderCapabilityMatrix.tsx index 89fc4011e..8447d0ea7 100644 --- a/apps/web/src/components/settings/ProviderCapabilityMatrix.tsx +++ b/apps/web/src/components/settings/ProviderCapabilityMatrix.tsx @@ -13,16 +13,14 @@ import { } from "../../lib/settingsProviderMetadata"; import { cn } from "../../lib/utils"; -function getProviderBadge(input: { - provider: ProviderKind; - status: ServerProviderStatus | null; - openclawGatewayUrl: string; -}): { tone: "success" | "warning" | "error"; label: string } { +function getProviderBadge(input: { provider: ProviderKind; status: ServerProviderStatus | null }): { + tone: "success" | "warning" | "error"; + label: string; +} { if ( isProviderReadyForThreadSelection({ provider: input.provider, statuses: input.status ? [input.status] : [], - openclawGatewayUrl: input.openclawGatewayUrl, }) ) { return { tone: "success", label: "Available" }; @@ -32,10 +30,6 @@ function getProviderBadge(input: { return { tone: "error", label: "Sign-in required" }; } - if (input.provider === "openclaw" && input.openclawGatewayUrl.trim().length === 0) { - return { tone: "warning", label: "Gateway missing" }; - } - if (input.status?.available === false || input.status?.status === "error") { return { tone: "error", label: "Unavailable" }; } @@ -56,10 +50,8 @@ function getBadgeClassName(tone: "success" | "warning" | "error"): string { export function ProviderCapabilityMatrix({ statuses, - openclawGatewayUrl, }: { statuses: ReadonlyArray; - openclawGatewayUrl: string; }) { return (
@@ -90,7 +82,6 @@ export function ProviderCapabilityMatrix({ const badge = getProviderBadge({ provider, status, - openclawGatewayUrl, }); const heading = status ? getProviderStatusHeading(status) diff --git a/apps/web/src/components/settings/SettingsRouteContext.tsx b/apps/web/src/components/settings/SettingsRouteContext.tsx index a6a6043e0..06e79f6fb 100644 --- a/apps/web/src/components/settings/SettingsRouteContext.tsx +++ b/apps/web/src/components/settings/SettingsRouteContext.tsx @@ -110,9 +110,6 @@ export function SettingsRouteContextProvider({ children }: { children: ReactNode settings.copilotConfigDir !== defaults.copilotConfigDir || settings.codexBinaryPath !== defaults.codexBinaryPath || settings.codexHomePath !== defaults.codexHomePath; - const isOpenClawSettingsDirty = - settings.openclawGatewayUrl !== defaults.openclawGatewayUrl || - settings.openclawPassword !== defaults.openclawPassword; const changedSettingLabels = useMemo( () => @@ -170,12 +167,10 @@ export function SettingsRouteContextProvider({ children }: { children: ReactNode ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 || settings.customCopilotModels.length > 0 || - settings.customGeminiModels.length > 0 || - settings.customOpenClawModels.length > 0 + settings.customGeminiModels.length > 0 ? ["Custom models"] : []), ...(isInstallSettingsDirty ? ["Provider installs"] : []), - ...(isOpenClawSettingsDirty ? ["OpenClaw gateway"] : []), ...(settings.backgroundImageUrl !== defaults.backgroundImageUrl ? ["Background image"] : []), @@ -203,7 +198,6 @@ export function SettingsRouteContextProvider({ children }: { children: ReactNode fontSizeOverrideState, isGitTextGenerationModelDirty, isInstallSettingsDirty, - isOpenClawSettingsDirty, messageFont, radiusOverrideState, settings, diff --git a/apps/web/src/lib/projectChat.ts b/apps/web/src/lib/projectChat.ts index 007db58a2..04ed38599 100644 --- a/apps/web/src/lib/projectChat.ts +++ b/apps/web/src/lib/projectChat.ts @@ -25,8 +25,6 @@ function buildDefaultModelSelection(provider: ProviderKind): ModelSelection { return { provider, model: getDefaultModel(provider) }; case "gemini": return { provider, model: getDefaultModel(provider) }; - case "openclaw": - return { provider, model: getDefaultModel(provider) }; } } diff --git a/apps/web/src/lib/providerAvailability.test.ts b/apps/web/src/lib/providerAvailability.test.ts index a195fe611..8de8cc3d6 100644 --- a/apps/web/src/lib/providerAvailability.test.ts +++ b/apps/web/src/lib/providerAvailability.test.ts @@ -79,81 +79,22 @@ describe("providerAvailability", () => { ).toBe(false); }); - it("treats configured OpenClaw as selectable even when server auth state is unknown", () => { - expect( - isProviderReadyForThreadSelection({ - provider: "openclaw", - statuses: [], - openclawGatewayUrl: "ws://localhost:8080", - }), - ).toBe(true); - }); - - it("shows openclaw as selectable when gateway URL is set but not yet probed", () => { - expect( - isProviderReadyForThreadSelection({ - provider: "openclaw", - statuses: [ - makeStatus("openclaw", { - status: "warning", - available: false, - authStatus: "unknown", - }), - ], - openclawGatewayUrl: "ws://gateway.example/local", - }), - ).toBe(true); - }); - - it("shows openclaw as selectable when status is ready && available", () => { - expect( - isProviderReadyForThreadSelection({ - provider: "openclaw", - statuses: [ - makeStatus("openclaw", { - status: "ready", - available: true, - authStatus: "authenticated", - }), - ], - openclawGatewayUrl: "", - }), - ).toBe(true); - }); - - it("excludes openclaw when gateway URL is blank and status is not ready", () => { - expect( - isProviderReadyForThreadSelection({ - provider: "openclaw", - statuses: [ - makeStatus("openclaw", { - status: "error", - available: false, - authStatus: "unauthenticated", - }), - ], - openclawGatewayUrl: "", - }), - ).toBe(false); - }); - it("returns selectable providers in stable picker order", () => { expect( getSelectableThreadProviders({ statuses: [ - makeStatus("openclaw", { authStatus: "unknown" }), makeStatus("codex"), makeStatus("claudeAgent", { status: "error", authStatus: "unauthenticated" }), ], }), - ).toEqual(["codex", "openclaw"]); + ).toEqual(["codex"]); }); it("falls back to the first selectable provider when the preferred one is unavailable", () => { expect( resolveThreadProviderSelection({ preferredProvider: "claudeAgent", - selectableProviders: ["codex", "openclaw"], + selectableProviders: ["codex"], }), ).toBe("codex"); }); diff --git a/apps/web/src/lib/providerAvailability.ts b/apps/web/src/lib/providerAvailability.ts index 64017e8a8..51fc4828e 100644 --- a/apps/web/src/lib/providerAvailability.ts +++ b/apps/web/src/lib/providerAvailability.ts @@ -5,7 +5,6 @@ const THREAD_PROVIDER_ORDER: readonly ProviderKind[] = [ "claudeAgent", "gemini", "copilot", - "openclaw", ]; const THREAD_PROVIDER_LABELS: Record = { @@ -13,7 +12,6 @@ const THREAD_PROVIDER_LABELS: Record = { claudeAgent: "Claude Code", gemini: "Gemini", copilot: "GitHub Copilot", - openclaw: "OpenClaw", }; export function getThreadProviderLabel(provider: ProviderKind): string { @@ -30,17 +28,9 @@ export function getProviderStatusByKind( export function isProviderReadyForThreadSelection(input: { provider: ProviderKind; statuses: ReadonlyArray; - openclawGatewayUrl?: string | null | undefined; }): boolean { const status = getProviderStatusByKind(input.statuses, input.provider); - if (input.provider === "openclaw") { - if (status?.status === "ready" && status.available) { - return true; - } - return (input.openclawGatewayUrl ?? "").trim().length > 0; - } - if (!status) { return false; } @@ -51,13 +41,11 @@ export function isProviderReadyForThreadSelection(input: { export function getSelectableThreadProviders(input: { statuses: ReadonlyArray; - openclawGatewayUrl?: string | null | undefined; }): ProviderKind[] { return THREAD_PROVIDER_ORDER.filter((provider) => isProviderReadyForThreadSelection({ provider, statuses: input.statuses, - openclawGatewayUrl: input.openclawGatewayUrl, }), ); } diff --git a/apps/web/src/lib/remoteFolderPicker.ts b/apps/web/src/lib/remoteFolderPicker.ts index 52c0f85eb..0a60af08a 100644 --- a/apps/web/src/lib/remoteFolderPicker.ts +++ b/apps/web/src/lib/remoteFolderPicker.ts @@ -18,12 +18,6 @@ export function deriveRemoteFolderBrowserRoot(cwd: string | null | undefined): s return "/"; } - const openclawWorkspaceMarker = "/.openclaw/workspace/"; - const openclawWorkspaceIndex = normalized.indexOf(openclawWorkspaceMarker); - if (openclawWorkspaceIndex !== -1) { - return normalized.slice(0, openclawWorkspaceIndex + openclawWorkspaceMarker.length - 1); - } - const windowsDriveMatch = normalized.match(/^[a-z]:/i); if (windowsDriveMatch) { return `${windowsDriveMatch[0]}\\`; diff --git a/apps/web/src/lib/serverReactQuery.ts b/apps/web/src/lib/serverReactQuery.ts index ec5b7d768..6d0be949c 100644 --- a/apps/web/src/lib/serverReactQuery.ts +++ b/apps/web/src/lib/serverReactQuery.ts @@ -4,7 +4,6 @@ import { ensureNativeApi } from "~/nativeApi"; export const serverQueryKeys = { all: ["server"] as const, config: () => ["server", "config"] as const, - openclawGatewayConfig: () => ["server", "openclawGatewayConfig"] as const, update: () => ["server", "update"] as const, }; @@ -32,14 +31,3 @@ export function serverUpdateQueryOptions() { retry: false, }); } - -export function openclawGatewayConfigQueryOptions() { - return queryOptions({ - queryKey: serverQueryKeys.openclawGatewayConfig(), - queryFn: async () => { - const api = ensureNativeApi(); - return api.server.getOpenclawGatewayConfig(); - }, - staleTime: Infinity, - }); -} diff --git a/apps/web/src/lib/settingsProviderMetadata.test.ts b/apps/web/src/lib/settingsProviderMetadata.test.ts index 2be06d763..9b91cc1da 100644 --- a/apps/web/src/lib/settingsProviderMetadata.test.ts +++ b/apps/web/src/lib/settingsProviderMetadata.test.ts @@ -3,12 +3,10 @@ import { describe, expect, it } from "vitest"; import { PROVIDER_AUTH_GUIDES } from "./settingsProviderMetadata"; describe("PROVIDER_AUTH_GUIDES", () => { - it("describes OpenClaw auth as a shared-secret flow", () => { - const guide = PROVIDER_AUTH_GUIDES.openclaw; + it("describes Codex auth", () => { + const guide = PROVIDER_AUTH_GUIDES.codex; - expect(guide.authCmd).toBe("Use gateway shared secret"); - expect(guide.note).toContain("shared secret"); - expect(guide.note).toContain("password-style auth"); - expect(guide.note).toContain("remote gateways"); + expect(guide.authCmd).toBe("codex login"); + expect(guide.installCmd).toContain("codex"); }); }); diff --git a/apps/web/src/lib/settingsProviderMetadata.tsx b/apps/web/src/lib/settingsProviderMetadata.tsx index 563c048b8..14d1fa171 100644 --- a/apps/web/src/lib/settingsProviderMetadata.tsx +++ b/apps/web/src/lib/settingsProviderMetadata.tsx @@ -33,7 +33,6 @@ export const SETTINGS_AUTH_PROVIDER_ORDER = [ "claudeAgent", "gemini", "copilot", - "openclaw", ] as const satisfies readonly ProviderKind[]; export const INSTALL_PROVIDER_SETTINGS = [ @@ -106,11 +105,6 @@ export const PROVIDER_AUTH_GUIDES: Record = { verifyCmd: "copilot auth status", note: "GitHub Copilot must be installed and signed in before it appears in the thread picker.", }, - openclaw: { - authCmd: "Use gateway shared secret", - verifyCmd: "Test Connection", - note: "OpenClaw uses the gateway URL and shared secret below rather than a local CLI login. Depending on gateway auth mode, OK Code sends that shared secret as token-style or password-style auth. Shared-secret auth usually works without device pairing and is the recommended default for Tailscale and remote gateways. Connection is verified by a WebSocket handshake plus /health probe and a connect handshake; click Test Connection again if the gateway restarts or your network changes.", - }, }; export type LocalBackendKey = "ollama" | "lmstudio"; @@ -163,8 +157,4 @@ export const PROVIDER_CAPABILITY_METADATA: Record, diff --git a/apps/web/src/routes/_chat.project.$projectId.tsx b/apps/web/src/routes/_chat.project.$projectId.tsx index d2f4436d1..bdbbfae39 100644 --- a/apps/web/src/routes/_chat.project.$projectId.tsx +++ b/apps/web/src/routes/_chat.project.$projectId.tsx @@ -34,9 +34,8 @@ function ProjectChatRouteView() { () => getSelectableThreadProviders({ statuses: serverConfigQuery.data?.providers ?? [], - openclawGatewayUrl: settings.openclawGatewayUrl, }), - [serverConfigQuery.data?.providers, settings.openclawGatewayUrl], + [serverConfigQuery.data?.providers], ); const [createAttempt, setCreateAttempt] = useState(0); const [createError, setCreateError] = useState(null); diff --git a/apps/web/src/routes/_chat.settings.index.tsx b/apps/web/src/routes/_chat.settings.index.tsx index c6c2d9052..9befd3616 100644 --- a/apps/web/src/routes/_chat.settings.index.tsx +++ b/apps/web/src/routes/_chat.settings.index.tsx @@ -10,7 +10,6 @@ import { XIcon, } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; -import type { TestOpenclawGatewayHostKind, TestOpenclawGatewayResult } from "@okcode/contracts"; import { type BuildMetadata, type KeybindingCommand, @@ -98,128 +97,6 @@ const PR_REVIEW_REQUEST_CHANGES_TONE_OPTIONS: ReadonlyArray<{ { value: "brand", label: "Brand" }, ]; -function describeOpenclawGatewayHostKind(hostKind: TestOpenclawGatewayHostKind): string { - switch (hostKind) { - case "loopback": - return "Loopback / same machine"; - case "tailscale": - return "Tailscale / tailnet"; - case "private": - return "Private LAN"; - case "public": - return "Public / internet-routable"; - case "unknown": - return "Unknown"; - } -} - -function describeOpenclawGatewayHealthStatus(result: TestOpenclawGatewayResult): string | null { - const diagnostics = result.diagnostics; - if (!diagnostics) return null; - switch (diagnostics.healthStatus) { - case "pass": - return diagnostics.healthDetail ? `Reachable (${diagnostics.healthDetail})` : "Reachable"; - case "fail": - return diagnostics.healthDetail ? `Failed (${diagnostics.healthDetail})` : "Failed"; - case "skip": - return diagnostics.healthDetail ?? "Skipped"; - } -} - -function formatOpenclawGatewayDebugReport(result: TestOpenclawGatewayResult): string { - const lines = [ - `OpenClaw gateway connection test: ${result.success ? "success" : "failed"}`, - `Total duration: ${result.totalDurationMs}ms`, - ]; - - if (result.error) { - lines.push(`Error: ${result.error}`); - } - - lines.push(""); - lines.push("Steps:"); - for (const step of result.steps) { - lines.push( - `- ${step.name}: ${step.status} (${step.durationMs}ms)${ - step.detail ? ` — ${step.detail}` : "" - }`, - ); - } - - if (result.serverInfo) { - lines.push(""); - lines.push("Server info:"); - if (result.serverInfo.version) { - lines.push(`- Version: ${result.serverInfo.version}`); - } - if (result.serverInfo.sessionId) { - lines.push(`- Session: ${result.serverInfo.sessionId}`); - } - } - - if (result.diagnostics) { - const diagnostics = result.diagnostics; - lines.push(""); - lines.push("Diagnostics:"); - if (diagnostics.normalizedUrl) { - lines.push(`- Endpoint: ${diagnostics.normalizedUrl}`); - } - if (diagnostics.hostKind) { - lines.push(`- Host type: ${describeOpenclawGatewayHostKind(diagnostics.hostKind)}`); - } - if (diagnostics.resolvedAddresses.length > 0) { - lines.push(`- Resolved: ${diagnostics.resolvedAddresses.join(", ")}`); - } - const healthStatus = describeOpenclawGatewayHealthStatus(result); - if (healthStatus) { - lines.push( - `- Health probe: ${healthStatus}${ - diagnostics.healthUrl ? ` at ${diagnostics.healthUrl}` : "" - }`, - ); - } - if (diagnostics.socketCloseCode !== undefined) { - lines.push( - `- Socket close: ${diagnostics.socketCloseCode}${ - diagnostics.socketCloseReason ? ` (${diagnostics.socketCloseReason})` : "" - }`, - ); - } - if (diagnostics.socketError) { - lines.push(`- Socket error: ${diagnostics.socketError}`); - } - if (diagnostics.gatewayErrorCode) { - lines.push(`- Gateway error code: ${diagnostics.gatewayErrorCode}`); - } - if (diagnostics.gatewayErrorDetailCode) { - lines.push(`- Gateway detail code: ${diagnostics.gatewayErrorDetailCode}`); - } - if (diagnostics.gatewayErrorDetailReason) { - lines.push(`- Gateway detail reason: ${diagnostics.gatewayErrorDetailReason}`); - } - if (diagnostics.gatewayRecommendedNextStep) { - lines.push(`- Gateway next step: ${diagnostics.gatewayRecommendedNextStep}`); - } - if (diagnostics.gatewayCanRetryWithDeviceToken !== undefined) { - lines.push( - `- Device-token retry available: ${diagnostics.gatewayCanRetryWithDeviceToken ? "yes" : "no"}`, - ); - } - if (diagnostics.observedNotifications.length > 0) { - lines.push(`- Gateway events: ${diagnostics.observedNotifications.join(", ")}`); - } - if (diagnostics.hints.length > 0) { - lines.push(""); - lines.push("Troubleshooting:"); - for (const hint of diagnostics.hints) { - lines.push(`- ${hint}`); - } - } - } - - return lines.join("\n"); -} - function getErrorMessage(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; @@ -287,7 +164,6 @@ function SettingsRouteView() { claudeAgent: Boolean(settings.claudeBinaryPath), gemini: false, copilot: Boolean(settings.copilotBinaryPath || settings.copilotConfigDir), - openclaw: Boolean(settings.openclawGatewayUrl || settings.openclawPassword), }); const [selectedCustomModelProvider, setSelectedCustomModelProvider] = useState("codex"); @@ -298,18 +174,11 @@ function SettingsRouteView() { claudeAgent: "", gemini: "", copilot: "", - openclaw: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); const [showAllCustomModels, setShowAllCustomModels] = useState(false); - const [openclawTestResult, setOpenclawTestResult] = useState( - null, - ); - const [openclawTestLoading, setOpenclawTestLoading] = useState(false); - const { copyToClipboard: copyOpenclawDebugReport, isCopied: openclawDebugReportCopied } = - useCopyToClipboard(); const globalEnvironmentVariablesQuery = useQuery(globalEnvironmentVariablesQueryOptions()); const activeProjectId = selectedProjectId ?? projects[0]?.id ?? null; @@ -350,7 +219,6 @@ function SettingsRouteView() { const isRefreshingProviderStatuses = serverConfigQuery.isFetching; const selectableProviders = getSelectableThreadProviders({ statuses: providerStatuses, - openclawGatewayUrl: settings.openclawGatewayUrl, }); const gitTextGenerationModelOptions = getAppModelOptions( @@ -376,8 +244,7 @@ function SettingsRouteView() { settings.customCodexModels.length + settings.customClaudeModels.length + settings.customCopilotModels.length + - settings.customGeminiModels.length + - settings.customOpenClawModels.length; + settings.customGeminiModels.length; const activeProjectEnvironmentVariables = selectedProjectEnvironmentVariablesQuery.data?.entries; const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) => getCustomModelsForProvider(settings, providerSettings.provider).map((slug) => ({ @@ -396,9 +263,6 @@ function SettingsRouteView() { settings.copilotConfigDir !== defaults.copilotConfigDir || settings.codexBinaryPath !== defaults.codexBinaryPath || settings.codexHomePath !== defaults.codexHomePath; - const isOpenClawSettingsDirty = - settings.openclawGatewayUrl !== defaults.openclawGatewayUrl || - settings.openclawPassword !== defaults.openclawPassword; const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; @@ -476,37 +340,6 @@ function SettingsRouteView() { await updateProjectIconOverride(api, selectedProject.id, nextIconPath); }, [projectIconDraft, selectedProject]); - const testOpenclawGateway = useCallback(async () => { - if (openclawTestLoading) return; - setOpenclawTestLoading(true); - setOpenclawTestResult(null); - try { - const api = ensureNativeApi(); - const result = await api.server.testOpenclawGateway({ - gatewayUrl: settings.openclawGatewayUrl, - password: settings.openclawPassword || undefined, - }); - setOpenclawTestResult(result); - if (result.success) { - await queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); - } - } catch (err) { - setOpenclawTestResult({ - success: false, - steps: [], - totalDurationMs: 0, - error: err instanceof Error ? err.message : "Unexpected error during test.", - }); - } finally { - setOpenclawTestLoading(false); - } - }, [openclawTestLoading, queryClient, settings.openclawGatewayUrl, settings.openclawPassword]); - - const handleCopyOpenclawDebugReport = useCallback(() => { - if (!openclawTestResult) return; - copyOpenclawDebugReport(formatOpenclawGatewayDebugReport(openclawTestResult), undefined); - }, [copyOpenclawDebugReport, openclawTestResult]); - const addCustomModel = useCallback( (provider: ProviderKind) => { const customModelInput = customModelInputByProvider[provider]; @@ -1194,10 +1027,7 @@ function SettingsRouteView() { status={`${selectableProviders.length} provider${selectableProviders.length === 1 ? "" : "s"} currently selectable`} >
- +
@@ -1222,7 +1052,6 @@ function SettingsRouteView() { claudeAgent: false, gemini: false, copilot: false, - openclaw: false, }); }} /> @@ -1370,158 +1199,6 @@ function SettingsRouteView() {
- - 0 - ? `Configured for ${settings.openclawGatewayUrl}` - : "Not configured" - } - resetAction={ - isOpenClawSettingsDirty ? ( - - updateSettings({ - openclawGatewayUrl: defaults.openclawGatewayUrl, - openclawPassword: defaults.openclawPassword, - }) - } - /> - ) : null - } - > -
- - - -
- -
- - {openclawTestResult ? ( -
-
- {openclawTestResult.success ? ( - - ) : ( - - )} - - {openclawTestResult.success ? "Connection successful" : "Connection failed"} - - - {openclawTestResult.totalDurationMs}ms total - - -
- - {openclawTestResult.steps.length > 0 ? ( -
- {openclawTestResult.steps.map((step) => ( -
- {step.status === "pass" ? ( - - ) : null} - {step.status === "fail" ? ( - - ) : null} - {step.status === "skip" ? ( - - ) : null} -
-
- {step.name} - - {step.durationMs}ms - -
- {step.detail ? ( - - {step.detail} - - ) : null} -
-
- ))} -
- ) : null} - - {openclawTestResult.error && - !openclawTestResult.steps.some((step) => step.status === "fail") ? ( -
{openclawTestResult.error}
- ) : null} -
- ) : null} -
-
)} @@ -1826,7 +1503,7 @@ function SettingsRouteView() { 0 ? ( { expect(PROVIDER_OPTIONS).toEqual([ { value: "codex", label: "Codex", available: true }, { value: "claudeAgent", label: "Claude Code", available: true }, - { value: "openclaw", label: "OpenClaw", available: true }, { value: "copilot", label: "GitHub Copilot", available: true }, { value: "cursor", label: "Cursor", available: false }, ]); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 26721acc3..f1510d6fa 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -29,7 +29,6 @@ export const PROVIDER_OPTIONS: Array<{ }> = [ { value: "codex", label: "Codex", available: true }, { value: "claudeAgent", label: "Claude Code", available: true }, - { value: "openclaw", label: "OpenClaw", available: true }, { value: "copilot", label: "GitHub Copilot", available: true }, { value: "cursor", label: "Cursor", available: false }, ]; diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 2191e4248..543b520f4 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -244,12 +244,7 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if ( - providerName === "codex" || - providerName === "claudeAgent" || - providerName === "openclaw" || - providerName === "copilot" - ) { + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "copilot") { return providerName; } return "codex"; @@ -262,7 +257,6 @@ function inferProviderForThreadModel(input: { if ( input.sessionProviderName === "codex" || input.sessionProviderName === "claudeAgent" || - input.sessionProviderName === "openclaw" || input.sessionProviderName === "copilot" ) { return input.sessionProviderName; diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 10d43ec73..a094ed62e 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -394,15 +394,8 @@ export function createWsNativeApi(): NativeApi { saveProjectEnvironmentVariables: (input) => transport.request(WS_METHODS.serverSaveProjectEnvironmentVariables, input), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), - getOpenclawGatewayConfig: () => transport.request(WS_METHODS.serverGetOpenclawGatewayConfig), - saveOpenclawGatewayConfig: (input) => - transport.request(WS_METHODS.serverSaveOpenclawGatewayConfig, input), - resetOpenclawGatewayDeviceState: (input) => - transport.request(WS_METHODS.serverResetOpenclawGatewayDeviceState, input), replaceKeybindingRules: (input) => transport.request(WS_METHODS.serverReplaceKeybindingRules, input), - testOpenclawGateway: (input) => - transport.request(WS_METHODS.serverTestOpenclawGateway, input), }, orchestration: { getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), diff --git a/docs/architecture/private-maintainer-profiles.md b/docs/architecture/private-maintainer-profiles.md index 2cf384b67..c703f7fa0 100644 --- a/docs/architecture/private-maintainer-profiles.md +++ b/docs/architecture/private-maintainer-profiles.md @@ -18,27 +18,27 @@ If `OKCODE_HOME` is unset, that defaults to: ~/.okcode/pr-review-profiles/*.md ``` -## OpenClaw maintainer profile +## Maintainer profile Current built-in adapter: -- `adapter: openclawMaintainer` +- `adapter: maintainer` -This adapter is intended for repositories like `openclaw/openclaw`, where the maintainer workflow lives in a private local `maintainers` checkout. +This adapter is intended for repositories where the maintainer workflow lives in a private local `maintainers` checkout. ### Example ```md --- -id: openclaw-maintainer -title: OpenClaw Maintainer Workflow +id: example-maintainer +title: Example Maintainer Workflow repositories: - - openclaw/openclaw -adapter: openclawMaintainer -maintainersRepo: ~/Documents/GitHub/OpenClaw/maintainers + - example/example +adapter: maintainer +maintainersRepo: ~/Documents/GitHub/Example/maintainers --- -Load the private OpenClaw maintainer workflow and run the local wrapper scripts. +Load the private maintainer workflow and run the local wrapper scripts. ``` ## Behavior @@ -50,7 +50,7 @@ When the active repo matches a local profile and no repo-local `.okcode/` workfl 3. Project them into OK Code's internal PR workflow model. 4. Expose runnable workflow steps in the PR Review UI. -For the OpenClaw adapter, the PR Review steps run these local commands from `maintainersRepo`: +For the maintainer adapter, the PR Review steps run these local commands from `maintainersRepo`: - `scripts/pr-review ` - `scripts/pr-prepare run ` diff --git a/docs/product-scope.md b/docs/product-scope.md index 2f7aab302..d0d3d4711 100644 --- a/docs/product-scope.md +++ b/docs/product-scope.md @@ -57,7 +57,6 @@ Supported provider matrix: - Claude Code - GitHub Copilot - Gemini CLI -- OpenClaw Shared provider contract: @@ -76,7 +75,6 @@ Provider-specific behavior is only justified when it changes real setup or runti - Claude Code: permission and thinking controls - GitHub Copilot: binary and config-directory overrides - Gemini CLI: binary and credential diagnostics -- OpenClaw: gateway configuration and live gateway testing ## Future Scope Guidance diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 72a65f47b..edfde759e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -93,17 +93,12 @@ import type { TerminalWriteInput, } from "./terminal"; import type { - OpenclawGatewayConfigSummary, - ResetOpenclawGatewayDeviceStateInput, - SaveOpenclawGatewayConfigInput, ServerConfig, ServerReplaceKeybindingRulesInput, ServerReplaceKeybindingRulesResult, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult, ServerUpdateInfo, - TestOpenclawGatewayInput, - TestOpenclawGatewayResult, } from "./server"; import type { SkillListInput, @@ -456,17 +451,9 @@ export interface NativeApi { input: SaveProjectEnvironmentVariablesInput, ) => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; - getOpenclawGatewayConfig: () => Promise; - saveOpenclawGatewayConfig: ( - input: SaveOpenclawGatewayConfigInput, - ) => Promise; - resetOpenclawGatewayDeviceState: ( - input?: ResetOpenclawGatewayDeviceStateInput, - ) => Promise; replaceKeybindingRules: ( input: ServerReplaceKeybindingRulesInput, ) => Promise; - testOpenclawGateway: (input: TestOpenclawGatewayInput) => Promise; }; orchestration: { getSnapshot: () => Promise; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 329d25a7c..1649105ab 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -6,15 +6,12 @@ export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; export const CLAUDE_CODE_EFFORT_OPTIONS = ["low", "medium", "high", "max", "ultrathink"] as const; export type ClaudeCodeEffort = (typeof CLAUDE_CODE_EFFORT_OPTIONS)[number]; -export const OPENCLAW_REASONING_EFFORT_OPTIONS = ["low", "medium", "high"] as const; -export type OpenClawReasoningEffort = (typeof OPENCLAW_REASONING_EFFORT_OPTIONS)[number]; export const COPILOT_REASONING_EFFORT_OPTIONS = ["low", "medium", "high", "xhigh"] as const; export type CopilotReasoningEffort = (typeof COPILOT_REASONING_EFFORT_OPTIONS)[number]; export type GeminiReasoningEffort = never; export type ProviderReasoningEffort = | CodexReasoningEffort | ClaudeCodeEffort - | OpenClawReasoningEffort | CopilotReasoningEffort; export const CodexModelOptions = Schema.Struct({ @@ -31,11 +28,6 @@ export const ClaudeModelOptions = Schema.Struct({ }); export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; -export const OpenClawModelOptions = Schema.Struct({ - reasoningEffort: Schema.optional(Schema.Literals(OPENCLAW_REASONING_EFFORT_OPTIONS)), -}); -export type OpenClawModelOptions = typeof OpenClawModelOptions.Type; - export const CopilotModelOptions = Schema.Struct({ reasoningEffort: Schema.optional(Schema.Literals(COPILOT_REASONING_EFFORT_OPTIONS)), }); @@ -47,7 +39,6 @@ export type GeminiModelOptions = typeof GeminiModelOptions.Type; export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), claudeAgent: Schema.optional(ClaudeModelOptions), - openclaw: Schema.optional(OpenClawModelOptions), copilot: Schema.optional(CopilotModelOptions), gemini: Schema.optional(GeminiModelOptions), }); @@ -75,7 +66,6 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, ], - openclaw: [], copilot: [ { slug: "gpt-5.5", name: "GPT-5.5" }, { slug: "gpt-5.5-mini", name: "GPT-5.5 mini" }, @@ -113,7 +103,6 @@ export type ModelSlug = BuiltInModelSlug | (string & {}); export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", - openclaw: "default", copilot: "gpt-5.3-codex", gemini: "auto-gemini-3", }; @@ -150,7 +139,6 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record; @@ -221,7 +208,6 @@ export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { codex: "high", claudeAgent: "high", - openclaw: "high", copilot: "high", gemini: "high", } as const satisfies Record; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 4331e58c9..eb3d75978 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -4,7 +4,6 @@ import { CodexModelOptions, CopilotModelOptions, GeminiModelOptions, - OpenClawModelOptions, ProviderModelOptions, } from "./model"; import { @@ -36,13 +35,7 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const ProviderKind = Schema.Literals([ - "codex", - "claudeAgent", - "openclaw", - "copilot", - "gemini", -]); +export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "copilot", "gemini"]); export type ProviderKind = typeof ProviderKind.Type; export const ThreadKind = Schema.Literals(["thread", "project-chat"]); export type ThreadKind = typeof ThreadKind.Type; @@ -72,11 +65,6 @@ export const ClaudeProviderStartOptions = Schema.Struct({ maxThinkingTokens: Schema.optional(NonNegativeInt), }); -export const OpenClawProviderStartOptions = Schema.Struct({ - gatewayUrl: Schema.optional(TrimmedNonEmptyString), - password: Schema.optional(TrimmedNonEmptyString), -}); - export const CopilotProviderStartOptions = Schema.Struct({ binaryPath: Schema.optional(TrimmedNonEmptyString), configDir: Schema.optional(TrimmedNonEmptyString), @@ -89,7 +77,6 @@ export const GeminiProviderStartOptions = Schema.Struct({ export const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), claudeAgent: Schema.optional(ClaudeProviderStartOptions), - openclaw: Schema.optional(OpenClawProviderStartOptions), copilot: Schema.optional(CopilotProviderStartOptions), gemini: Schema.optional(GeminiProviderStartOptions), }); @@ -109,13 +96,6 @@ export const ClaudeModelSelection = Schema.Struct({ }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; -export const OpenClawModelSelection = Schema.Struct({ - provider: Schema.Literal("openclaw"), - model: TrimmedNonEmptyString, - options: Schema.optional(OpenClawModelOptions), -}); -export type OpenClawModelSelection = typeof OpenClawModelSelection.Type; - export const CopilotModelSelection = Schema.Struct({ provider: Schema.Literal("copilot"), model: TrimmedNonEmptyString, @@ -133,7 +113,6 @@ export type GeminiModelSelection = typeof GeminiModelSelection.Type; export const ModelSelection = Schema.Union([ CodexModelSelection, ClaudeModelSelection, - OpenClawModelSelection, CopilotModelSelection, GeminiModelSelection, ]); diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index c96fa6f39..cdd56311d 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -24,10 +24,7 @@ const RuntimeEventRawSource = Schema.Literals([ "claude.sdk.message", "claude.sdk.permission", "codex.sdk.thread-event", - "openclaw.gateway.notification", "copilot.sdk.event", - "openclaw.gateway.event", - "openclaw.gateway.response", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9bc3f14a7..35e4ca788 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -175,33 +175,6 @@ export const ListTokensResult = Schema.Struct({ }); export type ListTokensResult = typeof ListTokensResult.Type; -// ── OpenClaw Gateway Config ───────────────────────────────────────── - -export const OpenclawGatewayConfigSummary = Schema.Struct({ - gatewayUrl: Schema.NullOr(TrimmedNonEmptyString), - hasSharedSecret: Schema.Boolean, - deviceId: Schema.NullOr(TrimmedNonEmptyString), - devicePublicKey: Schema.NullOr(TrimmedNonEmptyString), - deviceFingerprint: Schema.NullOr(TrimmedNonEmptyString), - hasDeviceToken: Schema.Boolean, - deviceTokenRole: Schema.NullOr(TrimmedNonEmptyString), - deviceTokenScopes: Schema.Array(TrimmedNonEmptyString), - updatedAt: Schema.NullOr(IsoDateTime), -}); -export type OpenclawGatewayConfigSummary = typeof OpenclawGatewayConfigSummary.Type; - -export const SaveOpenclawGatewayConfigInput = Schema.Struct({ - gatewayUrl: TrimmedNonEmptyString, - sharedSecret: Schema.optional(Schema.String), - clearSharedSecret: Schema.optional(Schema.Boolean), -}); -export type SaveOpenclawGatewayConfigInput = typeof SaveOpenclawGatewayConfigInput.Type; - -export const ResetOpenclawGatewayDeviceStateInput = Schema.Struct({ - regenerateIdentity: Schema.optional(Schema.Boolean), -}); -export type ResetOpenclawGatewayDeviceStateInput = typeof ResetOpenclawGatewayDeviceStateInput.Type; - // ── Companion Pairing (new model) ────────────────────────────────── // The companion pairing model replaces the single-token deep-link flow // with endpoint-aware bundles and device-scoped sessions. The legacy @@ -288,71 +261,3 @@ export const RevokePairedDeviceResult = Schema.Struct({ revoked: Schema.Boolean, }); export type RevokePairedDeviceResult = typeof RevokePairedDeviceResult.Type; - -// ── OpenClaw Gateway Test ─────────────────────────────────────────── - -export const TestOpenclawGatewayInput = Schema.Struct({ - gatewayUrl: Schema.optional(Schema.String), - password: Schema.optional(Schema.String), -}); -export type TestOpenclawGatewayInput = typeof TestOpenclawGatewayInput.Type; - -export const TestOpenclawGatewayStepStatus = Schema.Literals(["pass", "fail", "skip"]); -export type TestOpenclawGatewayStepStatus = typeof TestOpenclawGatewayStepStatus.Type; - -/** Individual step result in the gateway connection test. */ -export const TestOpenclawGatewayStep = Schema.Struct({ - name: Schema.String, - status: TestOpenclawGatewayStepStatus, - durationMs: Schema.Number, - detail: Schema.optional(Schema.String), -}); -export type TestOpenclawGatewayStep = typeof TestOpenclawGatewayStep.Type; - -export const TestOpenclawGatewayHostKind = Schema.Literals([ - "loopback", - "tailscale", - "private", - "public", - "unknown", -]); -export type TestOpenclawGatewayHostKind = typeof TestOpenclawGatewayHostKind.Type; - -export const TestOpenclawGatewayDiagnostics = Schema.Struct({ - normalizedUrl: Schema.optional(Schema.String), - host: Schema.optional(Schema.String), - pathname: Schema.optional(Schema.String), - hostKind: Schema.optional(TestOpenclawGatewayHostKind), - resolvedAddresses: Schema.Array(Schema.String), - healthUrl: Schema.optional(Schema.String), - healthStatus: TestOpenclawGatewayStepStatus, - healthDetail: Schema.optional(Schema.String), - socketCloseCode: Schema.optional(Schema.Number), - socketCloseReason: Schema.optional(Schema.String), - socketError: Schema.optional(Schema.String), - gatewayErrorCode: Schema.optional(Schema.String), - gatewayErrorDetailCode: Schema.optional(Schema.String), - gatewayErrorDetailReason: Schema.optional(Schema.String), - gatewayRecommendedNextStep: Schema.optional(Schema.String), - gatewayCanRetryWithDeviceToken: Schema.optional(Schema.Boolean), - observedNotifications: Schema.Array(Schema.String), - hints: Schema.Array(Schema.String), -}); -export type TestOpenclawGatewayDiagnostics = typeof TestOpenclawGatewayDiagnostics.Type; - -export const TestOpenclawGatewayResult = Schema.Struct({ - success: Schema.Boolean, - steps: Schema.Array(TestOpenclawGatewayStep), - /** Total wall-clock time for the entire test sequence. */ - totalDurationMs: Schema.Number, - /** Gateway-reported server info, if available. */ - serverInfo: Schema.optional( - Schema.Struct({ - version: Schema.optional(Schema.String), - sessionId: Schema.optional(Schema.String), - }), - ), - diagnostics: Schema.optional(TestOpenclawGatewayDiagnostics), - error: Schema.optional(Schema.String), -}); -export type TestOpenclawGatewayResult = typeof TestOpenclawGatewayResult.Type; diff --git a/packages/contracts/src/skillCatalog.ts b/packages/contracts/src/skillCatalog.ts index 54a33b9b9..b968dcc76 100644 --- a/packages/contracts/src/skillCatalog.ts +++ b/packages/contracts/src/skillCatalog.ts @@ -21,7 +21,6 @@ export const BundledSkillId = Schema.Literals([ "image-gen", "plugin-creator", "skill-installer", - "openclaw-docs", "openai-docs", "anthropic-docs", ]); diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index f444b9fe9..b7532ddd5 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -74,12 +74,9 @@ import { ProjectFileTreeChangedPayload } from "./project"; import { OpenInEditorInput, OpenPathInput } from "./editor"; import { GeneratePairingLinkInput, - ResetOpenclawGatewayDeviceStateInput, RevokeTokenInput, - SaveOpenclawGatewayConfigInput, ServerConfigUpdatedPayload, ServerReplaceKeybindingRulesInput, - TestOpenclawGatewayInput, } from "./server"; import { GitHubGetIssueInput, GitHubListIssuesInput, GitHubPostCommentInput } from "./github"; import { @@ -187,12 +184,6 @@ export const WS_METHODS = { serverRotateToken: "server.rotateToken", serverRevokeToken: "server.revokeToken", serverListTokens: "server.listTokens", - serverGetOpenclawGatewayConfig: "server.getOpenclawGatewayConfig", - serverSaveOpenclawGatewayConfig: "server.saveOpenclawGatewayConfig", - serverResetOpenclawGatewayDeviceState: "server.resetOpenclawGatewayDeviceState", - - // OpenClaw gateway - serverTestOpenclawGateway: "server.testOpenclawGateway", // Connection health serverPing: "server.ping", @@ -332,15 +323,6 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.serverRotateToken, Schema.Struct({})), tagRequestBody(WS_METHODS.serverRevokeToken, RevokeTokenInput), tagRequestBody(WS_METHODS.serverListTokens, Schema.Struct({})), - tagRequestBody(WS_METHODS.serverGetOpenclawGatewayConfig, Schema.Struct({})), - tagRequestBody(WS_METHODS.serverSaveOpenclawGatewayConfig, SaveOpenclawGatewayConfigInput), - tagRequestBody( - WS_METHODS.serverResetOpenclawGatewayDeviceState, - ResetOpenclawGatewayDeviceStateInput, - ), - - // OpenClaw gateway - tagRequestBody(WS_METHODS.serverTestOpenclawGateway, TestOpenclawGatewayInput), // Connection health tagRequestBody(WS_METHODS.serverPing, Schema.Struct({})), diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index f457128be..89f8898a5 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -2,7 +2,6 @@ import { CLAUDE_CODE_EFFORT_OPTIONS, COPILOT_REASONING_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS, - OPENCLAW_REASONING_EFFORT_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_REASONING_EFFORT_BY_PROVIDER, MODEL_OPTIONS_BY_PROVIDER, @@ -14,7 +13,6 @@ import { type CopilotReasoningEffort, type CodexModelOptions, type CodexReasoningEffort, - type OpenClawReasoningEffort, type ModelSlug, type ProviderReasoningEffort, type ProviderKind, @@ -23,7 +21,6 @@ import { const MODEL_SLUG_SET_BY_PROVIDER: Record> = { claudeAgent: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeAgent.map((option) => option.slug)), codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), - openclaw: new Set(), copilot: new Set(MODEL_OPTIONS_BY_PROVIDER.copilot.map((option) => option.slug)), gemini: new Set(MODEL_OPTIONS_BY_PROVIDER.gemini.map((option) => option.slug)), }; @@ -134,11 +131,9 @@ export function normalizeModelSlug( const providerNormalized = provider === "claudeAgent" && trimmed.toLowerCase().startsWith("anthropic/") ? trimmed.slice("anthropic/".length) - : provider === "openclaw" && trimmed.toLowerCase().startsWith("openclaw/") - ? trimmed.slice("openclaw/".length) - : provider === "copilot" && trimmed.toLowerCase().startsWith("copilot/") - ? trimmed.slice("copilot/".length) - : trimmed; + : provider === "copilot" && trimmed.toLowerCase().startsWith("copilot/") + ? trimmed.slice("copilot/".length) + : trimmed; const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] as Record; const aliased = Object.prototype.hasOwnProperty.call(aliases, providerNormalized) @@ -228,7 +223,6 @@ export function inferProviderForModel( if (typeof model === "string") { const trimmed = model.trim(); if (trimmed.startsWith("claude-")) return "claudeAgent"; - if (trimmed.startsWith("openclaw/")) return "openclaw"; if (trimmed.startsWith("copilot/")) return "copilot"; if (trimmed.startsWith("gemini-") || trimmed.startsWith("auto-gemini-")) return "gemini"; } @@ -240,9 +234,6 @@ export function getReasoningEffortOptions( provider: "claudeAgent", model?: string | null | undefined, ): ReadonlyArray; -export function getReasoningEffortOptions( - provider: "openclaw", -): ReadonlyArray; export function getReasoningEffortOptions( provider: "copilot", ): ReadonlyArray; @@ -269,7 +260,6 @@ export function getReasoningEffortOptions( export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort; export function getDefaultReasoningEffort(provider: "claudeAgent"): ClaudeCodeEffort; -export function getDefaultReasoningEffort(provider: "openclaw"): OpenClawReasoningEffort; export function getDefaultReasoningEffort(provider: "copilot"): CopilotReasoningEffort; export function getDefaultReasoningEffort(provider: "gemini"): ProviderReasoningEffort; export function getDefaultReasoningEffort(provider?: ProviderKind): ProviderReasoningEffort; @@ -287,10 +277,6 @@ export function resolveReasoningEffortForProvider( provider: "claudeAgent", effort: string | null | undefined, ): ClaudeCodeEffort | null; -export function resolveReasoningEffortForProvider( - provider: "openclaw", - effort: string | null | undefined, -): OpenClawReasoningEffort | null; export function resolveReasoningEffortForProvider( provider: "copilot", effort: string | null | undefined, @@ -451,5 +437,4 @@ export { CLAUDE_CODE_EFFORT_OPTIONS, COPILOT_REASONING_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS, - OPENCLAW_REASONING_EFFORT_OPTIONS, }; diff --git a/packages/shared/src/modelSelection.ts b/packages/shared/src/modelSelection.ts index 84228e2dc..f563b09e0 100644 --- a/packages/shared/src/modelSelection.ts +++ b/packages/shared/src/modelSelection.ts @@ -16,7 +16,6 @@ type SelectableModel = { const PROVIDER_MODEL_SET = { codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), claudeAgent: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeAgent.map((option) => option.slug)), - openclaw: new Set(), copilot: new Set(MODEL_OPTIONS_BY_PROVIDER.copilot.map((option) => option.slug)), gemini: new Set(MODEL_OPTIONS_BY_PROVIDER.gemini.map((option) => option.slug)), } as const satisfies Record>; @@ -138,7 +137,6 @@ export function normalizeModelSelectionWithCapabilities( : {}), }, }; - case "openclaw": case "copilot": case "gemini": return selection; diff --git a/packages/shared/src/skillCatalog.ts b/packages/shared/src/skillCatalog.ts index 8f7625652..8b50c7baf 100644 --- a/packages/shared/src/skillCatalog.ts +++ b/packages/shared/src/skillCatalog.ts @@ -174,24 +174,6 @@ export const BUNDLED_SKILLS: readonly BundledSkillAsset[] = [ skillName: "skill-installer", sourcePath: "system/skill-installer/SKILL.md", }, - { - entry: { - id: "openclaw-docs", - name: "OpenClaw Docs", - description: "Reference first-party OpenClaw and OK Code documentation.", - category: "docs", - tags: ["docs", "openclaw", "okcode"], - icon: "book-open", - installScopeDefault: "global", - system: true, - recommended: false, - immutable: true, - sourceType: "bundled", - sourceRef: "bundled:openclaw-docs", - }, - skillName: "openclaw-docs", - sourcePath: "docs/openclaw-docs/SKILL.md", - }, { entry: { id: "openai-docs", diff --git a/packages/shared/src/skills-catalog/docs/openclaw-docs/SKILL.md b/packages/shared/src/skills-catalog/docs/openclaw-docs/SKILL.md deleted file mode 100644 index 7b7030f67..000000000 --- a/packages/shared/src/skills-catalog/docs/openclaw-docs/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: openclaw-docs -description: Reference first-party OpenClaw and OK Code documentation. -catalog_id: openclaw-docs -origin: bundled -version: 1.0.0 -author: OK Code -tags: - - docs - - openclaw - - okcode ---- - -# OpenClaw Docs - -## When to use this skill - -- Use when the answer should be grounded in product or internal docs. - -## What this skill does - -- Prefers first-party documentation and repository guidance over memory. - -## Implementation - -- Cite the most relevant local or first-party docs available in the environment. - -## Best practices - -- Be explicit about what is documented versus inferred. diff --git a/plan-lm-studio-support.md b/plan-lm-studio-support.md index 588675754..2ac3da1b9 100644 --- a/plan-lm-studio-support.md +++ b/plan-lm-studio-support.md @@ -23,13 +23,12 @@ Specifically: - `claudeAgent` can run against LM Studio via LM Studio's Anthropic-compatible endpoint. - `codex` can run against LM Studio through Codex custom-provider mode, using an isolated `CODEX_HOME` managed by OK Code. -- `openclaw` stays unchanged. This is the least-widening design. Why: -- OK Code's provider model is agent-runtime-oriented today. `codex`, `claudeAgent`, and `openclaw` are not just model endpoints; they own approvals, streaming semantics, interrupts, recovery, and event normalization. +- OK Code's provider model is agent-runtime-oriented today. `codex` and `claudeAgent` are not just model endpoints; they own approvals, streaming semantics, interrupts, recovery, and event normalization. - LM Studio is an inference server, not a coding-agent runtime. A first-class `lmstudio` provider would force OK Code to invent a new agent loop or degrade existing agent semantics. - LM Studio's own docs make LM Link a routing mode behind the local server surface, not a separate API contract. From OK Code's perspective, the API target can stay local while diagnostics explain whether the actual model execution is local or routed over LM Link. @@ -57,7 +56,6 @@ Why: - A new `lmstudio` provider kind. - A generic "OpenAI-compatible provider marketplace" abstraction. - Writing into the user's global `~/.codex/config.toml`. -- Reworking OpenClaw. - Guaranteeing every local model can handle OK Code's full tool and attachment surface. - Automatic inference of every per-model capability on day one. @@ -193,7 +191,7 @@ Work: - [packages/contracts/src/orchestration.ts](/Users/buns/.okcode/worktrees/okcode/okcode-7e142d13/packages/contracts/src/orchestration.ts) - [packages/contracts/src/server.ts](/Users/buns/.okcode/worktrees/okcode/okcode-7e142d13/packages/contracts/src/server.ts) - Add a dedicated LM Studio diagnostics service on the server. -- Add a `server.testLmStudioConnection` RPC analogous to the OpenClaw gateway test. +- Add a `server.testLmStudioConnection` RPC for diagnostics. - Add a lightweight LM Studio status payload to `server.getConfig` or a dedicated query path. Important design rule: @@ -402,7 +400,7 @@ Mitigation: - Model selection comes from LM Studio when LM Studio is active. - Provider-native controls that are not stable through LM Studio are hidden or disabled. - Session recovery preserves the selected backend and fails closed on mismatch. -- Existing Codex, Claude Code, and OpenClaw behavior remains unchanged when LM Studio is not selected. +- Existing Codex and Claude Code behavior remains unchanged when LM Studio is not selected. ## Recommended Order of Execution diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts index 4d4a70841..7da46dff8 100644 --- a/scripts/prepare-release.ts +++ b/scripts/prepare-release.ts @@ -420,7 +420,7 @@ Structured validation plan for the highest-risk surfaces in v${version}. | Step | Expected | Pass | | ---- | -------- | ---- | | Configure each primary provider from Settings | Provider setup screens save cleanly and validation messages stay actionable | [ ] | -| Exercise Claude and OpenClaw auth flows after reload | Saved credentials and provider state restore without stale or conflicting UI | [ ] | +| Exercise Claude auth flows after reload | Saved credentials and provider state restore without stale or conflicting UI | [ ] | | Start a Codex or Copilot-backed conversation after provider setup | Turn creation, streaming, and provider selection remain consistent | [ ] | | Trigger an auth failure intentionally | Errors surface clearly without leaking secrets or breaking follow-up retries | [ ] |