diff --git a/.plans/README.md b/.plans/README.md index 9283e963..07fb7f10 100644 --- a/.plans/README.md +++ b/.plans/README.md @@ -32,6 +32,7 @@ - `web-i18n-rollout.md` - Multilingual i18n rollout *(in progress)* - `templates-community-system.md` - Community templates sharing, discovery, and customization - `workspace-file-tree.md` - Workspace file tree sidebar +- `sidebar-branch-enhancements.md` - Sidebar thread enrichment & branch picker enhancements *(planned)* ## Archived (completed or superseded) diff --git a/.plans/sidebar-branch-enhancements.md b/.plans/sidebar-branch-enhancements.md new file mode 100644 index 00000000..60564147 --- /dev/null +++ b/.plans/sidebar-branch-enhancements.md @@ -0,0 +1,723 @@ +# Sidebar & Branch Picker Enhancements + +## Summary + +Enrich the sidebar thread items and branch picker with information-dense metadata +inspired by Superset and VS Code branch management UIs. All 8 features use data +that already exists on the client β€” no new server APIs required. + +**Reference:** `DESIGN.md` (project root) for design rules and constraints. + +--- + +## Features + +| # | Feature | Component | Risk | New API? | +|---|---------|-----------|------|----------| +| 1 | Two-line thread items (branch subtitle) | Sidebar.tsx | Low | No | +| 2 | Diff stats per thread (+N -N) | Sidebar.tsx, Sidebar.logic.ts | Low | No | +| 3 | PR number badge inline | Sidebar.tsx | Low | No | +| 4 | Thread count in project header | Sidebar.tsx | Trivial | No | +| 5 | Recent branches at top of picker | BranchToolbarBranchSelector.tsx | Low | No | +| 6 | Fetch button in branch picker | BranchToolbarBranchSelector.tsx | Low | Yes (git.fetch) | +| 7 | Show remote branches grouped | BranchToolbarBranchSelector.tsx, BranchToolbar.logic.ts | Low-Med | No | +| 8 | "New Branch from X" base branch | BranchToolbarBranchSelector.tsx | Low | No | + +--- + +## Feature 1: Two-Line Thread Items (Branch Subtitle) + +### Goal + +Show the thread's git branch name as a second line below the title, making it +possible to identify which branch a thread operates on without clicking into it. + +### Data Source + +- `thread.branch` (type: `string | null`) β€” already on every Thread object +- Already passed to `MemoizedThreadRow` via the `thread` prop + +### Changes + +**File: `apps/web/src/components/Sidebar.tsx`** + +Modify `MemoizedThreadRow` (lines 375-446). Replace the current single-line layout: + +```tsx +// BEFORE (line 421-444): + +
+ +
+ +``` + +With a two-line layout: + +```tsx +// AFTER: + +
+ {/* Line 1: title + diff stats */} +
+ + {/* Feature 2: DiffStats go here */} +
+ {/* Line 2: branch + PR badge (only when branch is set) */} + {thread.branch ? ( +
+ + {thread.branch} + + {/* Feature 3: PR badge goes here */} +
+ ) : null} +
+``` + +Remove the `CloudUploadIcon` β€” it serves no function and will be replaced by +diff stats and PR badges. + +### Memo Comparator Update + +Add `thread.branch` to the memo equality check (line 449-465): + +```tsx +if (prev.thread.branch !== next.thread.branch) return false; +``` + +### Visual Result + +**Thread with branch:** +``` +[CircleDotIcon] My thread title +42 -7 + feature/add-login πŸ”— #123 +``` + +**Thread without branch (draft, no git):** +``` +[CircleDotIcon] My thread title +``` + +--- + +## Feature 2: Diff Stats Per Thread (+N -N) + +### Goal + +Show aggregate lines added/deleted by the thread, right-aligned on the first line. + +### Data Source + +- `thread.turnDiffSummaries: TurnDiffSummary[]` β€” already on every Thread object +- Each summary has `files: TurnDiffFileChange[]` with `additions?: number` and + `deletions?: number` +- Aggregate: sum all `additions` and `deletions` across all turns and files + +### Changes + +**File: `apps/web/src/components/Sidebar.logic.ts`** + +Add a new pure function: + +```tsx +export function aggregateThreadDiffStats( + turnDiffSummaries: ReadonlyArray, +): { additions: number; deletions: number } | null { + let additions = 0; + let deletions = 0; + for (const summary of turnDiffSummaries) { + for (const file of summary.files) { + additions += file.additions ?? 0; + deletions += file.deletions ?? 0; + } + } + return additions === 0 && deletions === 0 ? null : { additions, deletions }; +} +``` + +**File: `apps/web/src/components/Sidebar.tsx`** + +Inside `MemoizedThreadRow`, compute and render: + +```tsx +const diffStats = aggregateThreadDiffStats(thread.turnDiffSummaries); + +// In the JSX, after : +{diffStats ? ( + + +{diffStats.additions} + -{diffStats.deletions} + +) : null} +``` + +### Memo Comparator Update + +Add `thread.turnDiffSummaries` to the memo equality check: + +```tsx +if (prev.thread.turnDiffSummaries !== next.thread.turnDiffSummaries) return false; +``` + +### Edge Cases + +- Threads with no turns yet: `turnDiffSummaries` is empty β†’ `null` β†’ nothing rendered +- Threads with only additions: show `+42 -0` +- Very large numbers: use compact formatting if > 9999 (e.g., `+12.3k`) + +--- + +## Feature 3: PR Number Badge Inline + +### Goal + +Show the PR number as a small clickable badge on line 2 of the thread row, +replacing the current icon-only indicator that hides details behind a tooltip. + +### Data Source + +- `prByThreadId: Map` β€” already computed and passed as prop +- `ThreadPr` includes `number`, `url`, `state`, `title` + +### Changes + +**File: `apps/web/src/components/Sidebar.tsx`** + +Inside `MemoizedThreadRow`, after the branch name on line 2: + +```tsx +{prStatus ? ( + event.stopPropagation()} + className={cn( + "inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5", + "text-[10px] leading-none transition-colors hover:underline", + prStatus.label === "PR open" && "text-emerald-600", + prStatus.label === "PR merged" && "text-violet-600", + prStatus.label === "PR closed" && "text-muted-foreground/50", + )} + title={prStatus.tooltip} + > + + #{prByThreadId.get(thread.id)?.number} + +) : null} +``` + +### ThreadIcon Simplification + +With the PR badge now visible inline, the `ThreadIcon` at the start of the row +should revert to always using the **thread status icon** (Working, Error, +Completed, etc.) regardless of PR state. The PR icon was overloading the status +icon because there was nowhere else to show PR info β€” that constraint is now gone. + +Update the icon resolution (lines 354-367): + +```tsx +// BEFORE: PR icon overrides thread status icon +const ThreadIcon = prStatus ? prStatus.icon : ... + +// AFTER: Always use thread status icon +const ThreadIcon = threadStatus?.label === "Completed" + ? CheckCircleIcon + : threadStatus?.label === "Error" + ? XCircleIcon + : ... // (rest unchanged, remove prStatus override) + +const threadIconColor = threadStatus ? threadStatus.colorClass : "text-muted-foreground/50"; +``` + +--- + +## Feature 4: Thread Count in Project Header + +### Goal + +Show the number of threads per project next to the project name, like `superset (10)`. + +### Data Source + +- `projectThreads` (line 1319) β€” already computed as + `sortedThreadsByProjectId.get(project.id)` + +### Changes + +**File: `apps/web/src/components/Sidebar.tsx`** + +In `renderProjectItem`, after the project name span (line 1389-1404): + +```tsx +// BEFORE: + + {project.name} + + +// AFTER: + + {project.name} + {projectThreads.length > 0 ? ( + + ({projectThreads.length}) + + ) : null} + +``` + +### Notes + +- Count includes draft threads (already merged into `sidebarThreads`) +- When collapsed, count gives a quick sense of project activity +- No conditional logic β€” always shown when threads exist + +--- + +## Feature 5: Recent Branches at Top of Picker + +### Goal + +Show the 5 most recently used branches at the top of the branch picker dropdown, +separated from the full list by a subtle divider. + +### Data Source + +- New: `localStorage` key `okcode:recent-branches:v1` +- Format: `Record` keyed by project cwd, values are branch names + (most recent first, max 5) + +### Changes + +**File: `apps/web/src/components/BranchToolbar.logic.ts`** + +Add recent branches storage helpers: + +```tsx +const RECENT_BRANCHES_KEY = "okcode:recent-branches:v1"; +const MAX_RECENT_BRANCHES = 5; + +export function getRecentBranches(cwd: string): ReadonlyArray { + try { + const stored = localStorage.getItem(RECENT_BRANCHES_KEY); + if (!stored) return []; + const parsed = JSON.parse(stored) as Record; + return parsed[cwd]?.slice(0, MAX_RECENT_BRANCHES) ?? []; + } catch { + return []; + } +} + +export function trackRecentBranch(cwd: string, branchName: string): void { + try { + const stored = localStorage.getItem(RECENT_BRANCHES_KEY); + const parsed: Record = stored ? JSON.parse(stored) : {}; + const existing = parsed[cwd] ?? []; + const updated = [branchName, ...existing.filter((name) => name !== branchName)] + .slice(0, MAX_RECENT_BRANCHES); + parsed[cwd] = updated; + localStorage.setItem(RECENT_BRANCHES_KEY, JSON.stringify(parsed)); + } catch { + // Silent fail β€” non-critical feature + } +} +``` + +**File: `apps/web/src/components/BranchToolbarBranchSelector.tsx`** + +1. Call `trackRecentBranch(branchCwd, selectedBranchName)` inside `selectBranch` + and `createBranch` after successful operations. + +2. Partition `filteredBranchPickerItems` into recent and rest: + +```tsx +const recentBranchNames = useMemo( + () => (branchQueryCwd ? getRecentBranches(branchQueryCwd) : []), + [branchQueryCwd, isBranchMenuOpen], // re-read when picker opens +); + +const { recentItems, remainingItems } = useMemo(() => { + if (normalizedDeferredBranchQuery.length > 0 || recentBranchNames.length === 0) { + return { recentItems: [], remainingItems: filteredBranchPickerItems }; + } + const recentSet = new Set(recentBranchNames); + const recent = filteredBranchPickerItems.filter( + (item) => recentSet.has(item) && !item.startsWith("__"), + ); + const rest = filteredBranchPickerItems.filter( + (item) => !recentSet.has(item) || item.startsWith("__"), + ); + return { recentItems: recent, remainingItems: rest }; +}, [filteredBranchPickerItems, normalizedDeferredBranchQuery, recentBranchNames]); +``` + +3. Render with divider in the `ComboboxList`: + +```tsx +{/* Special items (PR checkout) */} +{/* Recent branches section */} +{recentItems.length > 0 && ( + <> +
+ Recent +
+ {recentItems.map((item, index) => renderPickerItem(item, index))} +
+ +)} +{/* All branches */} +{remainingItems.map((item, index) => + renderPickerItem(item, recentItems.length + index) +)} +``` + +### Notes + +- When searching, recent grouping is suppressed β€” filter applies to flat list +- Recent branches that no longer exist in the branch list are silently skipped +- Virtual scrolling index math accounts for the section header + divider + +--- + +## Feature 6: Fetch Button in Branch Picker + +### Goal + +Add a fetch button in the branch picker header that refreshes remote refs, +so users can discover new remote branches without leaving the picker. + +### Server API + +This is the **only feature requiring a new server API**. + +**File: `packages/contracts/src/git.ts`** + +Add input/result schemas: + +```tsx +export const GitFetchInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, +}); +export type GitFetchInput = typeof GitFetchInput.Type; + +export const GitFetchResult = Schema.Struct({ + status: Schema.Literal("fetched", "failed"), +}); +export type GitFetchResult = typeof GitFetchResult.Type; +``` + +**File: `apps/server/src/...` (git service)** + +Implement `git.fetch`: + +```tsx +async fetch(input: GitFetchInput): Promise { + // Run: git fetch --prune + // Return { status: "fetched" } or { status: "failed" } +} +``` + +**File: `apps/web/src/lib/gitReactQuery.ts`** + +Add mutation: + +```tsx +export function gitFetchMutationOptions(input: { + cwd: string | null; + queryClient: QueryClient; +}) { + return { + mutationKey: ["git", "mutation", "fetch", input.cwd], + mutationFn: async () => { + const api = readNativeApi(); + if (!api || !input.cwd) throw new Error("No API or CWD"); + return api.git.fetch({ cwd: input.cwd }); + }, + onSettled: () => invalidateGitQueries(input.queryClient), + }; +} +``` + +### UI Changes + +**File: `apps/web/src/components/BranchToolbarBranchSelector.tsx`** + +Add a fetch button next to the search input in the picker header: + +```tsx +
+ setBranchQuery(event.target.value)} + /> + + fetchMutation.mutate()} + disabled={fetchMutation.isPending} + > + {fetchMutation.isPending ? ( + + ) : ( + + )} + + } + /> + Fetch remote branches + +
+``` + +--- + +## Feature 7: Show Remote Branches Grouped + +### Goal + +Show remote-only branches (those without a local counterpart) in a separate +section of the branch picker, making remote branches discoverable. + +### Data Source + +- `dedupeRemoteBranchesWithLocalMatches()` β€” **already exists** in + `BranchToolbar.logic.ts` (line 115-141) but is not wired into the picker +- `filterSelectableBranches()` currently filters out all remote branches (line 74-78) + +### Changes + +**File: `apps/web/src/components/BranchToolbarBranchSelector.tsx`** + +Replace the current filtering logic: + +```tsx +// BEFORE (line 98-101): +const branches = useMemo( + () => filterSelectableBranches(branchesQuery.data?.branches ?? []), + [branchesQuery.data?.branches], +); + +// AFTER: +const allBranches = branchesQuery.data?.branches ?? []; +const localBranches = useMemo( + () => filterSelectableBranches(allBranches), + [allBranches], +); +const remoteOnlyBranches = useMemo( + () => dedupeRemoteBranchesWithLocalMatches(allBranches).filter((b) => b.isRemote), + [allBranches], +); +``` + +Build two separate name lists and combine them with a sentinel separator: + +```tsx +const REMOTE_DIVIDER = "__remote_divider__"; + +const branchPickerItems = useMemo(() => { + const items: string[] = []; + // Special items (PR checkout) + if (checkoutPullRequestItemValue) items.push(checkoutPullRequestItemValue); + // Local branches + items.push(...localBranchNames); + // Create branch action + if (createBranchItemValue && !hasExactBranchMatch) items.push(createBranchItemValue); + // Remote divider + remote branches + if (remoteOnlyBranches.length > 0) { + items.push(REMOTE_DIVIDER); + items.push(...remoteOnlyBranches.map((b) => b.name)); + } + return items; +}, [localBranchNames, remoteOnlyBranches, ...]); +``` + +Render the divider in `renderPickerItem`: + +```tsx +if (itemValue === REMOTE_DIVIDER) { + return ( +
+ Remote +
+ ); +} +``` + +Update the branch lookup map to include remote branches: + +```tsx +const branchByName = useMemo( + () => new Map([...localBranches, ...remoteOnlyBranches].map((b) => [b.name, b] as const)), + [localBranches, remoteOnlyBranches], +); +``` + +### Filtering Behavior + +When the user types a search query, filter applies across both local and remote +branches. The "Remote" divider is hidden during search (same as "Recent" in +Feature 5). + +--- + +## Feature 8: "New Branch from X" Base Branch + +### Goal + +When creating a new branch, use the currently highlighted branch in the picker +as the starting point instead of always branching from HEAD. + +### Data Source + +- Highlighted branch tracked by Combobox via `onItemHighlighted` callback + (line 419-422) +- `git.createBranch` API β€” check if it supports a `startPoint` parameter + +### Changes + +**File: `packages/contracts/src/git.ts`** + +Check/extend `GitCreateBranchInput`: + +```tsx +export const GitCreateBranchInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + branch: TrimmedNonEmptyStringSchema, + startPoint: Schema.optional(TrimmedNonEmptyStringSchema), // NEW β€” base branch or commit +}); +``` + +**File: Server git implementation** + +Update `createBranch` to pass `startPoint`: + +```bash +# BEFORE: git branch +# AFTER: git branch [startPoint] +``` + +**File: `apps/web/src/components/BranchToolbarBranchSelector.tsx`** + +1. Track the highlighted branch: + +```tsx +const [highlightedBranchName, setHighlightedBranchName] = useState(null); + +// In Combobox: +onItemHighlighted={(value, eventDetails) => { + setHighlightedBranchName(value && !value.startsWith("__") ? value : null); + // ... existing scroll logic +}} +``` + +2. Pass the highlighted branch as startPoint in `createBranch`: + +```tsx +const createBranch = (rawName: string) => { + // ...existing validation... + const startPoint = highlightedBranchName ?? undefined; + + runBranchAction(async () => { + setOptimisticBranch(name); + try { + await api.git.createBranch({ cwd: branchCwd, branch: name, startPoint }); + // ...rest unchanged... + } + }); +}; +``` + +3. Update the "Create new branch" item label to show the base: + +```tsx + + + Create "{trimmedBranchQuery}" + {highlightedBranchName ? ( + from {highlightedBranchName} + ) : null} + + +``` + +--- + +## Implementation Order + +Recommended sequence to minimize conflicts and enable incremental review: + +### Phase 1: Sidebar Thread Enrichment (Features 1-4) + +These four changes are all in `Sidebar.tsx` + `Sidebar.logic.ts` and can be done +in a single PR. They are purely additive β€” no behavior changes, no new APIs. + +1. **Feature 4** first (thread count) β€” trivial, 1-line change, validates the PR workflow +2. **Feature 1** (two-line layout) β€” structural change to ThreadRow, needed before 2 and 3 +3. **Feature 2** (diff stats) β€” adds `aggregateThreadDiffStats` + renders on line 1 +4. **Feature 3** (PR badge) β€” adds badge on line 2, simplifies ThreadIcon + +### Phase 2: Branch Picker Enhancements (Features 5-8) + +These four changes are in `BranchToolbarBranchSelector.tsx` + `BranchToolbar.logic.ts` +and can be done in a second PR. + +5. **Feature 7** (remote branches grouped) β€” biggest structural change to the picker, + do first so 5 and 6 build on top +6. **Feature 5** (recent branches) β€” adds localStorage tracking + section grouping +7. **Feature 8** (new branch from X) β€” extends create-branch with startPoint +8. **Feature 6** (fetch button) β€” only feature needing a new server API, do last + +### Phase 3: Tests & Polish + +- Add unit tests for `aggregateThreadDiffStats` and `getRecentBranches`/`trackRecentBranch` +- Add test for `dedupeRemoteBranchesWithLocalMatches` integration into picker items +- Verify memo comparator correctness with branch/diff changes +- Verify virtual scrolling still works with section dividers +- Cross-theme visual QA (all 6 themes, light + dark) + +--- + +## Files Modified (Summary) + +| File | Features | Type of Change | +|------|----------|---------------| +| `apps/web/src/components/Sidebar.tsx` | 1, 2, 3, 4 | Layout, rendering | +| `apps/web/src/components/Sidebar.logic.ts` | 2 | New pure function | +| `apps/web/src/components/BranchToolbarBranchSelector.tsx` | 5, 6, 7, 8 | Layout, data, state | +| `apps/web/src/components/BranchToolbar.logic.ts` | 5 | New localStorage helpers | +| `packages/contracts/src/git.ts` | 6, 8 | Schema additions | +| `apps/web/src/lib/gitReactQuery.ts` | 6 | New mutation | +| Server git service | 6, 8 | New fetch endpoint, extend createBranch | + +--- + +## Testing Checklist + +- [ ] Sidebar thread with branch shows two-line layout +- [ ] Sidebar thread without branch shows single-line layout (no empty second line) +- [ ] Diff stats show correct aggregate across all turns +- [ ] Diff stats handle zero additions or zero deletions gracefully +- [ ] PR badge is clickable and opens PR URL +- [ ] PR badge shows correct state color (open/merged/closed) +- [ ] Thread count updates when threads are added/removed +- [ ] Thread count includes draft threads +- [ ] Recent branches section appears with correct ordering +- [ ] Recent branches are suppressed during search +- [ ] Fetch button spins during fetch and refreshes branch list +- [ ] Remote branches appear below "Remote" divider +- [ ] Remote branches with local counterparts are hidden +- [ ] "Create branch from X" shows highlighted branch name +- [ ] New branch created from highlighted base is correct (`git log` check) +- [ ] Virtual scrolling works with section dividers +- [ ] All features render correctly in all 6 themes (light + dark) +- [ ] Memo comparator prevents unnecessary re-renders +- [ ] Keyboard navigation through recent β†’ all β†’ remote sections works diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..177311a1 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,293 @@ +# OK Code Design System + +This document is the authoritative reference for OK Code's visual design philosophy, +component patterns, and UI rules. Every contributor and AI agent should consult this +before making interface changes. + +--- + +## 1. Design Philosophy + +OK Code is a **desktop-first orchestration platform for interactive coding agents**. +The interface exists to get out of the way and let the developer focus on the +conversation with the agent. Every pixel must earn its place. + +### Core Principles + +1. **Clarity over decoration.** Remove anything that doesn't help the user make a + decision or understand state. No gratuitous gradients, no ornamental dividers, + no filler icons. + +2. **Information density over whitespace.** Developers tolerate β€” and prefer β€” dense + interfaces. Pack useful information in, but keep it scannable. Two lines of + meaningful metadata per thread row is better than one line with padding. + +3. **Keyboard-first, pointer-friendly.** Every primary action must be reachable via + keyboard. Pointer interactions are a convenience layer, never the only path. + Command palette (`Cmd+K`) is the universal escape hatch. + +4. **State visibility at a glance.** The user should never have to click into something + to learn its status. Branch name, diff stats, PR state, sync status β€” surface + them where the user already is (sidebar, toolbar), not behind a hover or modal. + +5. **Progressive disclosure.** Show the 80% case by default; let the 20% reveal on + interaction. Tooltips, expandable sections, and context menus are the right homes + for secondary actions. + +6. **Performance is a feature.** Virtual scrolling for long lists. Deferred values for + search. Memoized components with explicit equality checks. Never block the main + thread for a pretty animation. + +7. **Theme parity.** Every theme (light and dark) must receive equal visual care. + Never design for dark-only and bolt on a light variant. All 6 premium themes are + first-class citizens. + +8. **Composability.** Small, focused components with clear props boundaries. + Composite patterns (`Select > SelectTrigger > SelectValue > SelectPopup`) over + monolithic components with flag props. + +--- + +## 2. Visual Identity + +### Typography + +| Role | Size | Weight | Tracking | Font Stack | +|------|------|--------|----------|------------| +| Thread title (sidebar) | `text-xs` (0.75rem) | `font-normal` | default | Inter, system-ui, sans-serif | +| Thread subtitle / metadata | `text-[10px]` | `font-normal` | default | Inter, system-ui, sans-serif | +| Badge text | `text-[10px]` | `font-medium` | default | Inter, system-ui, sans-serif | +| Button text | `text-sm` (0.875rem) | `font-medium` | default | Inter, system-ui, sans-serif | +| Heading / dialog title | `text-lg` (1.125rem) | `font-semibold` | `-0.01em` | Inter, system-ui, sans-serif | +| Code / terminal | `text-sm` | `font-normal` | default | SF Mono, Consolas, monospace | +| Project name | `text-xs` | `font-semibold` | default | Inter, system-ui, sans-serif | + +### Color Semantics + +Colors are referenced through CSS custom properties, never hardcoded hex values. + +| Token | Usage | +|-------|-------| +| `text-foreground` | Primary text | +| `text-muted-foreground` | Secondary/deemphasized text | +| `text-muted-foreground/50` | Tertiary/metadata text (branch names, timestamps) | +| `bg-background` | Page background | +| `bg-accent` | Hover state, active row highlight | +| `bg-accent/60` | Active sidebar item | +| `bg-accent/40` | Selected sidebar item | +| `text-emerald-600` | Additions / success (green) | +| `text-rose-500` | Deletions / error (red) | +| `text-warning` | Warning states, behind-upstream | +| `text-destructive` | Destructive actions (delete) | +| `border-border/60` | Subtle badge borders | + +### Spacing Rules + +- **Sidebar item height:** `min-h-7` (28px) minimum, `h-auto` for multi-line +- **Sidebar item padding:** `px-2 py-1` (8px horizontal, 4px vertical) +- **Icon size in sidebar:** `size-3.5` (14px) +- **Badge size in sidebar:** `text-[10px]`, `px-1.5 py-0.5` +- **Gap between icon and content:** `gap-2` (8px) +- **Gap between metadata items:** `gap-1` (4px) or `gap-1.5` (6px) +- **Border radius:** Use `rounded-md` (6px) for sidebar items, `rounded-full` for badges + +### Themes + +Five premium themes, each with light and dark variants: + +| Theme | Vibe | +|-------|------| +| **Iridescent Void** | Futuristic, expensive, slightly alien | +| **Carbon** | Stark, modern, performance-focused | +| **Vapor** | Refined, fluid, purposeful | +| **Cotton Candy** | Sweet, dreamy, pink and blue | +| **Cathedral Circuit** | Sacred machine, techno-gothic | + +All themes define the same set of CSS custom properties. Components must use semantic +tokens (`bg-accent`, `text-muted-foreground`) β€” never theme-specific values. + +--- + +## 3. Component Patterns + +### API Conventions + +Every UI primitive follows these rules: + +```tsx +// 1. data-slot for DOM identification +
+ +// 2. CVA for variant management +const buttonVariants = cva("base classes", { + variants: { variant: { ... }, size: { ... } }, + defaultVariants: { variant: "default", size: "default" }, +}); + +// 3. cn() for conditional class merging +
+ +// 4. Composite pattern for complex components + +``` + +### Focus States + +All interactive elements use the same focus ring: +``` +focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] +``` + +### Disabled States + +``` +disabled:pointer-events-none disabled:opacity-50 +``` + +### Animation Rules + +- **Transitions:** `transition-colors` or `transition-shadow` β€” never `transition-all` + unless specifically needed for layout shifts. +- **Durations:** 150ms for hover, 200ms for modals/drawers. +- **Reduced motion:** Always respect `prefers-reduced-motion`. Use Tailwind's + `motion-reduce:` prefix or the existing `.no-transitions` guard. +- **No decorative animation.** Animations must communicate state change (opening, + closing, loading). Pulse is reserved for "Working" status indicators. + +--- + +## 4. Sidebar Rules + +The sidebar is the primary navigation surface. These rules are non-negotiable: + +### Thread Row Layout + +``` +[StatusIcon] [Title] [DiffStats] + [branch-name] [PR badge] +``` + +- **Line 1:** Status icon (3.5px) + editable title + right-aligned diff stats +- **Line 2:** Branch name in muted text + right-aligned PR badge (if applicable) +- Single-line fallback when no branch is set (title-only row) +- Active row: `bg-accent/60 text-foreground` +- Selected row: `bg-accent/40 text-foreground` +- Default row: `text-muted-foreground hover:bg-accent/40` + +### Project Header Layout + +``` +[ProjectName] (threadCount) [+ New Thread] +``` + +- Thread count shown as `(N)` in muted text next to the project name +- Color-coded background per project via `getProjectColor()` +- Collapsible with "Show more" / "Show less" for 10+ threads + +### Data Freshness + +- Thread metadata (branch, PR, diff stats) rendered from existing store data +- No additional network requests from the sidebar β€” use data already fetched +- PR status polled per-cwd at 60s intervals (existing `threadGitStatusQueries`) + +--- + +## 5. Branch Picker Rules + +The branch picker is a `Combobox` dropdown. These rules apply: + +### Structure + +``` +[Search input] [Fetch button] +───────────────────────────────────────────────── +Recent Branches + main [current] [default] + feature/foo [worktree] +───────────────────────────────────────────────── +All Branches + bugfix/bar + feature/baz [remote] +───────────────────────────────────────────────── +Create new branch "typed-query" (when search has no exact match) +Checkout Pull Request #123 (when search matches PR ref pattern) +``` + +- **Recent branches** at top (3-5 most recently switched-to, tracked in localStorage) +- **All branches** below, with local branches first, then remote-only branches + separated by a subtle divider +- **Remote branches** shown only when they have no local counterpart + (via existing `dedupeRemoteBranchesWithLocalMatches`) +- **Badges** per branch: `current`, `default`, `worktree`, `remote`, `stash N` +- **Create branch** allows specifying a base: when a branch is highlighted, + creating a new branch uses the highlighted branch as the starting point +- **Fetch button** in the picker header refreshes remote refs + +### Data Rules + +- Branch list virtualized at 40+ items (existing behavior) +- No per-branch ahead/behind counts (too expensive to compute for all branches) +- Ahead/behind shown only for the current branch in the toolbar (existing behavior) + +--- + +## 6. Git Actions Rules + +Git operations follow the "stacked action" pattern. The user always works with +a single flow: + +``` +[Quick action button] [Dropdown menu with all actions] +``` + +Quick action resolves automatically based on git state: +- Has changes + no PR β†’ "Commit, push & PR" +- Has changes + existing PR β†’ "Commit & push" +- No changes + ahead β†’ "Push & create PR" +- Behind upstream β†’ "Pull" or "Sync branch" +- Conflicts β†’ "Resolve conflicts" + +**Never fragment git actions** into multiple surfaces. The branch picker handles +navigation (switching, creating, fetching). The git actions control handles +mutations (commit, push, PR). + +--- + +## 7. Right Panel Rules + +The right panel (`useRightPanelStore`) hosts context-dependent content: + +- **Code viewer** β€” file browsing and editing +- **Diff panel** β€” unified diff for file changes +- **Workspace panel** β€” file tree + +New panels should use the same toggle mechanism and respect the existing +split/stacked responsive layout (split at 600px+, stacked below). + +--- + +## 8. Don'ts + +1. **Don't add new modals** for information that could be inline or in a tooltip. +2. **Don't hide status behind clicks.** If the user needs to know it, show it. +3. **Don't add loading spinners** for operations under 200ms. Use optimistic updates. +4. **Don't add new Zustand stores** without justification. Prefer extending existing + stores or using React Query for server state. +5. **Don't add new polling intervals.** Reuse existing git status queries (5s stale, + 15s refetch) or branch queries (15s stale, 60s refetch). +6. **Don't hardcode colors.** Use semantic tokens from the theme system. +7. **Don't break the memo contract.** When adding props to `MemoizedThreadRow`, add + corresponding equality checks to the memo comparator. +8. **Don't add destructive actions to compact pickers.** Delete belongs in context + menus with confirmation, not in branch dropdowns. +9. **Don't duplicate git actions.** Push belongs in GitActionsControl, not in the + branch picker. Commit belongs in GitActionsControl, not in a right panel. +10. **Don't add features that require new server APIs** when the data already exists + on the client. Compute derived values from existing queries. diff --git a/apps/server/package.json b/apps/server/package.json index 35384a01..dce10f4f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -26,7 +26,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.77", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@pierre/diffs": "^1.1.0-beta.16", + "@pierre/diffs": "1.1.13", "effect": "catalog:", "node-pty": "^1.1.0", "open": "^10.1.0", diff --git a/apps/server/src/openclaw/GatewayClient.ts b/apps/server/src/openclaw/GatewayClient.ts new file mode 100644 index 00000000..734bbee2 --- /dev/null +++ b/apps/server/src/openclaw/GatewayClient.ts @@ -0,0 +1,553 @@ +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 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("shared"); + } catch (error) { + const parsedError = + error instanceof OpenclawGatewayClientError ? error.gatewayError : undefined; + const shouldRetryWithDeviceToken = + canUseStoredDeviceToken && + parsedError?.canRetryWithDeviceToken === true && + this.options.sharedSecret !== undefined; + if (!shouldRetryWithDeviceToken) { + throw error; + } + + await this.closeCurrentSocket(); + return await this.performConnectAttempt("deviceToken"); + } + } + + private async performConnectAttempt( + authMode: "shared" | "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 ?? "") + : (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 === "shared" && authToken.length > 0 ? { auth: { token: authToken } } : {}), + ...(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 new file mode 100644 index 00000000..0e814218 --- /dev/null +++ b/apps/server/src/openclaw/deviceAuth.ts @@ -0,0 +1,82 @@ +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 new file mode 100644 index 00000000..76755fc5 --- /dev/null +++ b/apps/server/src/openclaw/protocol.ts @@ -0,0 +1,155 @@ +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 new file mode 100644 index 00000000..04af2f73 --- /dev/null +++ b/apps/server/src/openclaw/sessionIdentity.ts @@ -0,0 +1,34 @@ +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 index 7c6546db..9b28327d 100644 --- a/apps/server/src/openclawGatewayTest.test.ts +++ b/apps/server/src/openclawGatewayTest.test.ts @@ -5,6 +5,12 @@ import { OpenclawGatewayTestInternals, runOpenclawGatewayTest } from "./openclaw const servers = new Set(); +type GatewayRequestFrame = { + type?: unknown; + id?: unknown; + method?: unknown; +}; + afterEach(async () => { await Promise.all( [...servers].map( @@ -36,8 +42,18 @@ async function createGatewayServer( 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 auth timeouts", () => { + it("captures Tailscale-oriented hints for modern handshake timeouts", () => { const hostKind = OpenclawGatewayTestInternals.classifyGatewayHost("vals-mini.example.ts.net", [ "100.90.12.34", ]); @@ -50,27 +66,71 @@ describe("runOpenclawGatewayTest", () => { resolvedAddresses: ["100.90.12.34"], hostKind, healthStatus: "skip", - observedNotifications: [], + observedNotifications: ["connect.challenge"], hints: [], }, - "Authentication", - "RPC 'auth.authenticate' timed out after 10000ms.", + "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 JSON-RPC gateway endpoint"))).toBe( + expect(hints.some((hint) => hint.includes("actual OpenClaw WebSocket gateway endpoint"))).toBe( true, ); expect(hints.some((hint) => hint.includes("reverse proxy"))).toBe(true); }); - it("reports socket-close details when auth fails mid-handshake", async () => { + it("passes when the modern connect handshake succeeds", 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: 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"); + }); + + 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 { method?: string }; - if (message.method === "auth.authenticate") { - socket.close(4401, "gateway auth unavailable"); + 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", + }, + }, + }), + ); } }); }); @@ -83,12 +143,13 @@ describe("runOpenclawGatewayTest", () => { expect(result.success).toBe(false); expect(result.steps.find((step) => step.name === "WebSocket connect")?.status).toBe("pass"); - const authStep = result.steps.find((step) => step.name === "Authentication"); - expect(authStep?.status).toBe("fail"); - expect(authStep?.detail).toContain("WebSocket closed before RPC 'auth.authenticate' completed"); + const handshakeStep = result.steps.find((step) => step.name === "Gateway handshake"); + expect(handshakeStep?.status).toBe("fail"); + expect(handshakeStep?.detail).toContain("PAIRING_REQUIRED"); - expect(result.diagnostics?.socketCloseCode).toBe(4401); - expect(result.diagnostics?.socketCloseReason).toBe("gateway auth unavailable"); - expect(result.diagnostics?.hints.some((hint) => hint.includes("loopback-only"))).toBe(true); + 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); }); }); diff --git a/apps/server/src/openclawGatewayTest.ts b/apps/server/src/openclawGatewayTest.ts index 2fbf1366..9eb6c33d 100644 --- a/apps/server/src/openclawGatewayTest.ts +++ b/apps/server/src/openclawGatewayTest.ts @@ -10,18 +10,31 @@ import type { TestOpenclawGatewayStepStatus, } from "@okcode/contracts"; import NodeWebSocket from "ws"; +import { serverBuildInfo } from "./buildInfo.ts"; +import { + generateOpenclawDeviceIdentity, + signOpenclawDeviceChallenge, +} from "./openclaw/deviceAuth.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; - -type JsonRpcEnvelope = { - id?: number | string | null; - method?: string; - result?: unknown; - error?: { code: number; message: string }; +const OPENCLAW_PROTOCOL_VERSION = 3; +const OPENCLAW_OPERATOR_SCOPES = ["operator.read", "operator.write"] as const; + +type GatewayEnvelope = { + type?: unknown; + id?: unknown; + ok?: unknown; + event?: unknown; + payload?: unknown; + error?: { + code?: unknown; + message?: unknown; + details?: unknown; + }; }; interface GatewayHealthProbe { @@ -42,10 +55,24 @@ interface MutableGatewayDiagnostics { socketCloseCode?: number; socketCloseReason?: string; socketError?: string; + gatewayErrorCode?: string; + gatewayErrorDetailCode?: string; + gatewayErrorDetailReason?: string; + gatewayRecommendedNextStep?: string; + gatewayCanRetryWithDeviceToken?: boolean; observedNotifications: string[]; hints: string[]; } +interface ParsedGatewayError { + message: string; + code?: string; + detailCode?: string; + detailReason?: string; + recommendedNextStep?: string; + canRetryWithDeviceToken?: boolean; +} + function withTimeout(promise: Promise, timeoutMs: number, fallback: T): Promise { return new Promise((resolve) => { const timeout = setTimeout(() => resolve(fallback), timeoutMs); @@ -76,6 +103,105 @@ function bufferToString(data: NodeWebSocket.Data): string { return data.toString("utf8"); } +function parseGatewayEnvelope(data: NodeWebSocket.Data): GatewayEnvelope | null { + try { + const parsed = JSON.parse(bufferToString(data)); + if (typeof parsed === "object" && parsed !== null) { + return parsed as GatewayEnvelope; + } + } catch { + // Ignore non-JSON websocket messages from intermediaries. + } + return null; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function readBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function parseGatewayError(error: GatewayEnvelope["error"]): ParsedGatewayError { + const details = + typeof error?.details === "object" && error.details !== null + ? (error.details as Record) + : undefined; + const parsed: ParsedGatewayError = { + message: readString(error?.message) ?? "Gateway request failed.", + }; + const code = + typeof error?.code === "string" || typeof error?.code === "number" + ? String(error.code) + : undefined; + const detailCode = readString(details?.code); + const detailReason = readString(details?.reason); + const recommendedNextStep = readString(details?.recommendedNextStep); + const canRetryWithDeviceToken = readBoolean(details?.canRetryWithDeviceToken); + + if (code) { + parsed.code = code; + } + if (detailCode) { + parsed.detailCode = detailCode; + } + if (detailReason) { + parsed.detailReason = detailReason; + } + if (recommendedNextStep) { + parsed.recommendedNextStep = recommendedNextStep; + } + if (canRetryWithDeviceToken !== undefined) { + parsed.canRetryWithDeviceToken = canRetryWithDeviceToken; + } + + return parsed; +} + +function recordGatewayError( + diagnostics: MutableGatewayDiagnostics, + error: ParsedGatewayError | undefined, +): void { + if (error?.code) { + diagnostics.gatewayErrorCode = error.code; + } else { + delete diagnostics.gatewayErrorCode; + } + if (error?.detailCode) { + diagnostics.gatewayErrorDetailCode = error.detailCode; + } else { + delete diagnostics.gatewayErrorDetailCode; + } + if (error?.detailReason) { + diagnostics.gatewayErrorDetailReason = error.detailReason; + } else { + delete diagnostics.gatewayErrorDetailReason; + } + if (error?.recommendedNextStep) { + diagnostics.gatewayRecommendedNextStep = error.recommendedNextStep; + } else { + delete diagnostics.gatewayRecommendedNextStep; + } + if (error?.canRetryWithDeviceToken !== undefined) { + diagnostics.gatewayCanRetryWithDeviceToken = error.canRetryWithDeviceToken; + } else { + delete diagnostics.gatewayCanRetryWithDeviceToken; + } +} + +function formatGatewayError(error: ParsedGatewayError): string { + const detailParts = [ + 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((part): part is string => part !== null); + + return detailParts.length > 0 ? `${error.message} (${detailParts.join(", ")})` : error.message; +} + function pushUnique(items: string[], value: string): void { if (items.includes(value) || items.length >= MAX_CAPTURED_NOTIFICATIONS) return; items.push(value); @@ -226,8 +352,8 @@ function formatSocketClose(code: number | undefined, reason: string | undefined) return reason && reason.length > 0 ? `code ${code}: ${reason}` : `code ${code}`; } -function buildTimeoutDetail(method: string, diagnostics: TestOpenclawGatewayDiagnostics): string { - const parts = [`RPC '${method}' timed out after ${OPENCLAW_TEST_RPC_TIMEOUT_MS}ms.`]; +function buildTimeoutDetail(subject: string, diagnostics: TestOpenclawGatewayDiagnostics): string { + const parts = [`${subject} timed out after ${OPENCLAW_TEST_RPC_TIMEOUT_MS}ms.`]; const closeDetail = formatSocketClose(diagnostics.socketCloseCode, diagnostics.socketCloseReason); if (closeDetail) { parts.push(`Socket closed with ${closeDetail}.`); @@ -236,7 +362,7 @@ function buildTimeoutDetail(method: string, diagnostics: TestOpenclawGatewayDiag parts.push(`Last socket error: ${diagnostics.socketError}.`); } if (diagnostics.observedNotifications.length > 0) { - parts.push(`Observed notifications: ${diagnostics.observedNotifications.join(", ")}.`); + parts.push(`Observed gateway events: ${diagnostics.observedNotifications.join(", ")}.`); } return parts.join(" "); } @@ -251,16 +377,22 @@ function buildHints( | "observedNotifications" | "hints" | "resolvedAddresses" + | "gatewayErrorCode" + | "gatewayErrorDetailCode" + | "gatewayErrorDetailReason" + | "gatewayRecommendedNextStep" + | "gatewayCanRetryWithDeviceToken" >, failedStepName: string | null, error: string | undefined, - passwordProvided: boolean, + sharedSecretProvided: boolean, ): string[] { const hints: string[] = []; - const authFailure = failedStepName === "Authentication"; + const handshakeFailure = failedStepName === "Gateway handshake"; const websocketFailure = failedStepName === "WebSocket connect"; - const sessionFailure = failedStepName === "Session create"; const errorLower = error?.toLowerCase() ?? ""; + const detailCode = diagnostics.gatewayErrorDetailCode; + const gatewayRecommendedNextStep = diagnostics.gatewayRecommendedNextStep; if (diagnostics.hostKind === "loopback") { hints.push( @@ -286,29 +418,39 @@ function buildHints( ); } - if (authFailure) { + if (handshakeFailure) { hints.push( - "The WebSocket handshake succeeded, so DNS/TLS/basic routing are working. The missing piece is the gateway’s JSON-RPC auth response.", + "The WebSocket handshake succeeded, so DNS/TLS/basic routing are working. The remaining failure is inside the OpenClaw `connect` handshake.", ); - if (errorLower.includes("timed out")) { + if (errorLower.includes("connect.challenge")) { hints.push( - "A timeout during `auth.authenticate` usually means this URL is not the actual OpenClaw JSON-RPC gateway endpoint, the gateway auth handler is stalled, or a proxy is accepting WebSockets without forwarding gateway traffic correctly.", + "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 wrong password normally returns an RPC error quickly. A timeout is more consistent with the gateway never replying than with a simple credential mismatch.", + "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 (!passwordProvided && sessionFailure) { + if ( + !sharedSecretProvided && + (detailCode === "AUTH_TOKEN_MISSING" || errorLower.includes("auth_token_missing")) + ) { hints.push( - "No password was provided for this test. If your OpenClaw gateway requires authentication, add the shared secret and test again.", + "No shared secret was provided for this test. If your OpenClaw gateway uses token/password auth, add the configured secret and test again.", ); } - if (errorLower.includes("rpc error")) { + if ( + sharedSecretProvided && + (detailCode === "AUTH_TOKEN_MISMATCH" || + detailCode === "AUTH_DEVICE_TOKEN_MISMATCH" || + errorLower.includes("auth_token_mismatch")) + ) { hints.push( - "The gateway returned an RPC error, which usually means the request reached the OpenClaw service. Re-check the shared secret and any gateway-side auth configuration.", + "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.", ); } @@ -318,15 +460,53 @@ function buildHints( ); } - if (parsedUrl.protocol === "wss:" && (websocketFailure || authFailure || sessionFailure)) { + 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 ( + 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 && authFailure) { + if (diagnostics.observedNotifications.length > 0 && handshakeFailure) { hints.push( - "The gateway sent notifications before auth completed. Check the gateway logs around the same time to see why it never answered the `auth.authenticate` request.", + "The gateway sent events before `connect` completed. Check the gateway logs around the same time to see why it never answered the handshake successfully.", ); } @@ -351,6 +531,8 @@ export async function runOpenclawGatewayTest( let rpcId = 1; const serverInfo: { version?: string; sessionId?: string } = {}; const diagnostics: MutableGatewayDiagnostics = createDiagnostics(); + const earlyGatewayEvents: GatewayEnvelope[] = []; + let captureEarlyGatewayEvents = true; const pushStep = ( name: string, @@ -381,7 +563,7 @@ export async function runOpenclawGatewayTest( diagnostics, failedStepName, error, - Boolean(input.password), + Boolean(input.password?.trim()), ); const diagnosticsResult: TestOpenclawGatewayDiagnostics = { ...diagnostics, @@ -400,14 +582,111 @@ export async function runOpenclawGatewayTest( }; let parsedUrlForHints: URL | null = null; + const testDeviceIdentity = generateOpenclawDeviceIdentity(); + + const waitForGatewayEvent = ( + socket: NodeWebSocket, + eventName: string, + ): Promise | undefined> => + new Promise((resolve, reject) => { + const bufferedIndex = earlyGatewayEvents.findIndex( + (message) => message.type === "event" && message.event === eventName, + ); + if (bufferedIndex >= 0) { + const [message] = earlyGatewayEvents.splice(bufferedIndex, 1); + resolve( + typeof message?.payload === "object" && message.payload !== null + ? (message.payload as Record) + : undefined, + ); + return; + } + + let settled = false; + let timeout: ReturnType | undefined; + + const cleanup = () => { + if (timeout) { + clearTimeout(timeout); + } + socket.off("message", onMessage); + socket.off("close", onClose); + socket.off("error", onError); + }; + + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + cleanup(); + callback(); + }; - const sendRpc = ( + const onMessage = (data: NodeWebSocket.Data) => { + const message = parseGatewayEnvelope(data); + if (!message) { + return; + } + if (message.type === "event" && typeof message.event === "string") { + pushUnique(diagnostics.observedNotifications, message.event); + if (message.event === eventName) { + settle(() => + resolve( + typeof message.payload === "object" && message.payload !== null + ? (message.payload as Record) + : undefined, + ), + ); + } + } + }; + + const onClose = (code: number, reasonBuffer: Buffer) => { + diagnostics.socketCloseCode = code; + const reason = reasonBuffer.toString("utf8"); + if (reason.length > 0) { + diagnostics.socketCloseReason = reason; + } + const closeDetail = formatSocketClose(code, reason); + settle(() => + reject( + new Error( + `WebSocket closed before gateway event '${eventName}' arrived${ + closeDetail ? ` (${closeDetail})` : "" + }.`, + ), + ), + ); + }; + + const onError = (cause: Error) => { + diagnostics.socketError = toMessage(cause, "WebSocket error."); + settle(() => + reject( + new Error( + `WebSocket error while waiting for gateway event '${eventName}': ${diagnostics.socketError}`, + ), + ), + ); + }; + + socket.on("message", onMessage); + socket.on("close", onClose); + socket.on("error", onError); + + timeout = setTimeout(() => { + settle(() => + reject(new Error(buildTimeoutDetail(`Gateway event '${eventName}'`, diagnostics))), + ); + }, OPENCLAW_TEST_RPC_TIMEOUT_MS); + }); + + const sendGatewayRequest = ( socket: NodeWebSocket, method: string, params?: Record, - ): Promise<{ result?: unknown; error?: { code: number; message: string } }> => + ): Promise<{ payload?: unknown; error?: ParsedGatewayError }> => new Promise((resolve, reject) => { - const id = rpcId++; + const id = String(rpcId++); let settled = false; let timeout: ReturnType | undefined; @@ -428,21 +707,30 @@ export async function runOpenclawGatewayTest( }; const onMessage = (data: NodeWebSocket.Data) => { - try { - const message = JSON.parse(bufferToString(data)) as JsonRpcEnvelope; - if (typeof message.method === "string") { - pushUnique(diagnostics.observedNotifications, message.method); - } - if (message.id === id) { + const message = parseGatewayEnvelope(data); + if (!message) { + return; + } + if (message.type === "event" && typeof message.event === "string") { + pushUnique(diagnostics.observedNotifications, message.event); + return; + } + if (message.type === "res" && message.id === id) { + if (message.ok === true) { + recordGatewayError(diagnostics, undefined); settle(() => - resolve({ - ...(message.result !== undefined ? { result: message.result } : {}), - ...(message.error !== undefined ? { error: message.error } : {}), - }), + resolve( + message.payload !== undefined + ? { payload: message.payload } + : { payload: undefined }, + ), ); + return; } - } catch { - // Ignore non-JSON websocket messages from intermediaries. + + const parsedError = parseGatewayError(message.error); + recordGatewayError(diagnostics, parsedError); + settle(() => resolve({ error: parsedError })); } }; @@ -456,7 +744,7 @@ export async function runOpenclawGatewayTest( settle(() => reject( new Error( - `WebSocket closed before RPC '${method}' completed${ + `WebSocket closed before gateway request '${method}' completed${ closeDetail ? ` (${closeDetail})` : "" }.`, ), @@ -467,7 +755,11 @@ export async function runOpenclawGatewayTest( const onError = (cause: Error) => { diagnostics.socketError = toMessage(cause, "WebSocket error."); settle(() => - reject(new Error(`WebSocket error during RPC '${method}': ${diagnostics.socketError}`)), + reject( + new Error( + `WebSocket error during gateway request '${method}': ${diagnostics.socketError}`, + ), + ), ); }; @@ -476,16 +768,18 @@ export async function runOpenclawGatewayTest( socket.on("error", onError); timeout = setTimeout(() => { - settle(() => reject(new Error(buildTimeoutDetail(method, diagnostics)))); + settle(() => + reject(new Error(buildTimeoutDetail(`Gateway request '${method}'`, diagnostics))), + ); }, OPENCLAW_TEST_RPC_TIMEOUT_MS); try { socket.send( JSON.stringify({ - jsonrpc: "2.0", + type: "req", + id, method, ...(params !== undefined ? { params } : {}), - id, }), ); } catch (cause) { @@ -494,9 +788,56 @@ export async function runOpenclawGatewayTest( } }); + const buildConnectParams = ( + sharedSecret: string | undefined, + challenge: Record | undefined, + ): Record => { + const nonce = + typeof challenge?.nonce === "string" && challenge.nonce.length > 0 ? challenge.nonce : ""; + const signedAt = + typeof challenge?.ts === "number" && Number.isFinite(challenge.ts) + ? challenge.ts + : Date.now(); + const authToken = sharedSecret ?? ""; + const signedDevice = signOpenclawDeviceChallenge(testDeviceIdentity, { + clientId: "okcode", + clientMode: "operator", + role: "operator", + scopes: [...OPENCLAW_OPERATOR_SCOPES], + token: authToken, + nonce, + signedAt, + }); + return { + minProtocol: OPENCLAW_PROTOCOL_VERSION, + maxProtocol: OPENCLAW_PROTOCOL_VERSION, + client: { + id: "okcode", + version: serverBuildInfo.version, + platform: + process.platform === "darwin" + ? "macos" + : process.platform === "win32" + ? "windows" + : process.platform, + mode: "operator", + }, + role: "operator", + scopes: [...OPENCLAW_OPERATOR_SCOPES], + caps: [], + commands: [], + permissions: {}, + locale: Intl.DateTimeFormat().resolvedOptions().locale || "en-US", + userAgent: `okcode/${serverBuildInfo.version}`, + ...(authToken.length > 0 ? { auth: { token: authToken } } : {}), + device: signedDevice, + }; + }; + try { const urlStart = Date.now(); - const gatewayUrl = input.gatewayUrl.trim(); + 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"); @@ -540,6 +881,18 @@ export async function runOpenclawGatewayTest( try { ws = await new Promise((resolve, reject) => { const socket = new NodeWebSocket(gatewayUrl); + socket.on("message", (data: NodeWebSocket.Data) => { + const message = parseGatewayEnvelope(data); + if (!message) { + return; + } + if (message.type === "event" && typeof message.event === "string") { + pushUnique(diagnostics.observedNotifications, message.event); + } + if (captureEarlyGatewayEvents) { + earlyGatewayEvents.push(message); + } + }); const timeout = setTimeout(() => { socket.close(); reject(new Error(`Connection timed out after ${OPENCLAW_TEST_CONNECT_TIMEOUT_MS}ms`)); @@ -564,16 +917,6 @@ export async function runOpenclawGatewayTest( ws.on("error", (cause: Error) => { diagnostics.socketError = toMessage(cause, "WebSocket error."); }); - ws.on("message", (data: NodeWebSocket.Data) => { - try { - const message = JSON.parse(bufferToString(data)) as JsonRpcEnvelope; - if (typeof message.method === "string") { - pushUnique(diagnostics.observedNotifications, message.method); - } - } catch { - // Ignore non-JSON websocket messages from intermediaries. - } - }); pushStep( "WebSocket connect", "pass", @@ -589,61 +932,31 @@ export async function runOpenclawGatewayTest( applyHealthProbe(await healthPromise); - if (input.password) { - const authStart = Date.now(); - try { - const authResult = await sendRpc(ws, "auth.authenticate", { - password: input.password, - }); - if (authResult.error) { - const detail = `RPC error ${authResult.error.code}: ${authResult.error.message}`; - pushStep("Authentication", "fail", Date.now() - authStart, detail); - return finalize( - false, - `Authentication failed: ${authResult.error.message}`, - "Authentication", - ); - } - pushStep("Authentication", "pass", Date.now() - authStart, "Authenticated."); - } catch (cause) { - const detail = toMessage(cause, "Authentication request failed."); - pushStep("Authentication", "fail", Date.now() - authStart, detail); - return finalize(false, detail, "Authentication"); - } - } - - const sessionStart = Date.now(); + const handshakeStart = Date.now(); try { - const sessionResult = await sendRpc(ws, "session.create"); - if (sessionResult.error) { - const detail = `RPC error ${sessionResult.error.code}: ${sessionResult.error.message}`; - pushStep("Session create", "fail", Date.now() - sessionStart, detail); - return finalize( - false, - `Session creation failed: ${sessionResult.error.message}`, - "Session create", - ); - } - - const result = (sessionResult.result ?? {}) as Record; - const sessionId = typeof result.sessionId === "string" ? result.sessionId : undefined; - const version = typeof result.version === "string" ? result.version : undefined; - if (version !== undefined) { - serverInfo.version = version; - } - if (sessionId !== undefined) { - serverInfo.sessionId = sessionId; + const challenge = await waitForGatewayEvent(ws, "connect.challenge"); + captureEarlyGatewayEvents = false; + earlyGatewayEvents.length = 0; + if (typeof challenge?.nonce !== "string" || challenge.nonce.length === 0) { + const detail = "Gateway challenge did not include a nonce."; + pushStep("Gateway handshake", "fail", Date.now() - handshakeStart, detail); + return finalize(false, detail, "Gateway handshake"); } - pushStep( - "Session create", - "pass", - Date.now() - sessionStart, - sessionId ? `Session ID: ${sessionId}` : "Session created.", + const connectResult = await sendGatewayRequest( + ws, + "connect", + buildConnectParams(sharedSecret, challenge), ); + if (connectResult.error) { + const detail = formatGatewayError(connectResult.error); + pushStep("Gateway handshake", "fail", Date.now() - handshakeStart, detail); + return finalize(false, detail, "Gateway handshake"); + } + pushStep("Gateway handshake", "pass", Date.now() - handshakeStart, "Connected."); } catch (cause) { - const detail = toMessage(cause, "Session creation failed."); - pushStep("Session create", "fail", Date.now() - sessionStart, detail); - return finalize(false, detail, "Session create"); + const detail = toMessage(cause, "Gateway handshake failed."); + pushStep("Gateway handshake", "fail", Date.now() - handshakeStart, detail); + return finalize(false, detail, "Gateway handshake"); } return finalize(true); diff --git a/apps/server/src/persistence/Layers/OpenclawGatewayConfig.ts b/apps/server/src/persistence/Layers/OpenclawGatewayConfig.ts new file mode 100644 index 00000000..8c502b8a --- /dev/null +++ b/apps/server/src/persistence/Layers/OpenclawGatewayConfig.ts @@ -0,0 +1,486 @@ +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].sort((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 identity = + existing ?? + (() => { + const generatedIdentity = fromGeneratedIdentity(generateOpenclawDeviceIdentity()); + return { + ...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 7f244932..ad7a1500 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -32,6 +32,7 @@ import Migration0017 from "./Migrations/017_EnvironmentVariables.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsGithubRef.ts"; import Migration0019 from "./Migrations/019_SmeKnowledgeBase.ts"; import Migration0020 from "./Migrations/020_SmeConversationProviderAuth.ts"; +import Migration0021 from "./Migrations/021_OpenclawGatewayConfig.ts"; import { Effect } from "effect"; /** @@ -65,6 +66,7 @@ const loader = Migrator.fromRecord({ "18_ProjectionThreadsGithubRef": Migration0018, "19_SmeKnowledgeBase": Migration0019, "20_SmeConversationProviderAuth": Migration0020, + "21_OpenclawGatewayConfig": Migration0021, }); /** diff --git a/apps/server/src/persistence/Migrations/021_OpenclawGatewayConfig.ts b/apps/server/src/persistence/Migrations/021_OpenclawGatewayConfig.ts new file mode 100644 index 00000000..a09c92b8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/021_OpenclawGatewayConfig.ts @@ -0,0 +1,23 @@ +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/Services/EnvironmentVariables.ts b/apps/server/src/persistence/Services/EnvironmentVariables.ts index 2b26065c..678bb4ea 100644 --- a/apps/server/src/persistence/Services/EnvironmentVariables.ts +++ b/apps/server/src/persistence/Services/EnvironmentVariables.ts @@ -7,8 +7,6 @@ * * @module EnvironmentVariables */ -import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; -import fs from "node:fs/promises"; import path from "node:path"; import { @@ -32,6 +30,7 @@ import { toPersistenceDecodeError, toPersistenceSqlError, } from "../Errors.ts"; +import { decodeVaultPayload, encodeVaultPayload, readOrCreateVaultKey } from "../vault.ts"; export interface EnvironmentVariablesShape { readonly getGlobal: () => Effect.Effect< @@ -62,10 +61,6 @@ export type EnvironmentVariablesError = | PersistenceDecodeError | PersistenceCryptoError; -const SECRET_PAYLOAD_VERSION = "v1"; -const SECRET_KEY_BYTES = 32; -const SECRET_IV_BYTES = 12; - const GlobalEnvironmentVariableRow = Schema.Struct({ key: Schema.String, encryptedValue: Schema.String, @@ -111,18 +106,11 @@ function encodeSecretPayload(input: { readonly envKey: string; readonly value: string; }): string { - const iv = randomBytes(SECRET_IV_BYTES); - const cipher = createCipheriv("aes-256-gcm", input.key, iv); - cipher.setAAD(Buffer.from([input.scope, input.projectId ?? "", input.envKey].join("\0"), "utf8")); - - const ciphertext = Buffer.concat([cipher.update(input.value, "utf8"), cipher.final()]); - const authTag = cipher.getAuthTag(); - return [ - SECRET_PAYLOAD_VERSION, - iv.toString("base64"), - authTag.toString("base64"), - ciphertext.toString("base64"), - ].join(":"); + return encodeVaultPayload({ + key: input.key, + aad: [input.scope, input.projectId ?? "", input.envKey], + value: input.value, + }); } function decodeSecretPayload(input: { @@ -132,63 +120,11 @@ function decodeSecretPayload(input: { readonly envKey: string; readonly encryptedValue: string; }): string { - const parts = input.encryptedValue.split(":"); - if (parts.length !== 4 || parts[0] !== SECRET_PAYLOAD_VERSION) { - throw new Error("Unsupported secret payload version."); - } - - const [, ivRaw, authTagRaw, ciphertextRaw] = parts; - const iv = Buffer.from(ivRaw ?? "", "base64"); - const authTag = Buffer.from(authTagRaw ?? "", "base64"); - const ciphertext = Buffer.from(ciphertextRaw ?? "", "base64"); - if (iv.byteLength !== SECRET_IV_BYTES || authTag.byteLength !== 16) { - throw new Error("Invalid encrypted payload."); - } - - const decipher = createDecipheriv("aes-256-gcm", input.key, iv); - decipher.setAAD( - Buffer.from([input.scope, input.projectId ?? "", input.envKey].join("\0"), "utf8"), - ); - decipher.setAuthTag(authTag); - return `${decipher.update(ciphertext, undefined, "utf8")}${decipher.final("utf8")}`; -} - -async function readOrCreateSecretKey(secretKeyPath: string): Promise { - try { - const existing = await fs.readFile(secretKeyPath, "utf8"); - const decoded = Buffer.from(existing.trim(), "base64"); - if (decoded.byteLength !== SECRET_KEY_BYTES) { - throw new Error("Invalid vault key length."); - } - return decoded; - } catch (error) { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - if (code !== "ENOENT") { - throw error; - } - - await fs.mkdir(path.dirname(secretKeyPath), { recursive: true }); - const key = randomBytes(SECRET_KEY_BYTES); - try { - await fs.writeFile(secretKeyPath, `${key.toString("base64")}\n`, { - encoding: "utf8", - flag: "wx", - mode: 0o600, - }); - return key; - } catch (writeError) { - const writeCode = (writeError as NodeJS.ErrnoException | undefined)?.code; - if (writeCode === "EEXIST") { - const existing = await fs.readFile(secretKeyPath, "utf8"); - const decoded = Buffer.from(existing.trim(), "base64"); - if (decoded.byteLength !== SECRET_KEY_BYTES) { - throw new Error("Invalid vault key length.", { cause: writeError }); - } - return decoded; - } - throw writeError; - } - } + return decodeVaultPayload({ + key: input.key, + aad: [input.scope, input.projectId ?? "", input.envKey], + encryptedValue: input.encryptedValue, + }); } function toEnvironmentError(operation: string, error: unknown): EnvironmentVariablesError { @@ -221,7 +157,7 @@ export const EnvironmentVariablesLive = Layer.effect( const getSecretKey = () => { if (!secretKeyPromise) { - secretKeyPromise = readOrCreateSecretKey(secretKeyPath).catch((error) => { + secretKeyPromise = readOrCreateVaultKey(secretKeyPath).catch((error) => { secretKeyPromise = null; throw error; }); diff --git a/apps/server/src/persistence/Services/OpenclawGatewayConfig.ts b/apps/server/src/persistence/Services/OpenclawGatewayConfig.ts new file mode 100644 index 00000000..f28374cf --- /dev/null +++ b/apps/server/src/persistence/Services/OpenclawGatewayConfig.ts @@ -0,0 +1,72 @@ +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/persistence/vault.ts b/apps/server/src/persistence/vault.ts new file mode 100644 index 00000000..f2d21b1a --- /dev/null +++ b/apps/server/src/persistence/vault.ts @@ -0,0 +1,92 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export const VAULT_PAYLOAD_VERSION = "v1"; +export const VAULT_KEY_BYTES = 32; +export const VAULT_IV_BYTES = 12; + +export interface EncodeVaultPayloadInput { + readonly key: Buffer; + readonly aad: ReadonlyArray; + readonly value: string; +} + +export interface DecodeVaultPayloadInput { + readonly key: Buffer; + readonly aad: ReadonlyArray; + readonly encryptedValue: string; +} + +export function encodeVaultPayload(input: EncodeVaultPayloadInput): string { + const iv = randomBytes(VAULT_IV_BYTES); + const cipher = createCipheriv("aes-256-gcm", input.key, iv); + cipher.setAAD(Buffer.from(input.aad.join("\0"), "utf8")); + + const ciphertext = Buffer.concat([cipher.update(input.value, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + return [ + VAULT_PAYLOAD_VERSION, + iv.toString("base64"), + authTag.toString("base64"), + ciphertext.toString("base64"), + ].join(":"); +} + +export function decodeVaultPayload(input: DecodeVaultPayloadInput): string { + const parts = input.encryptedValue.split(":"); + if (parts.length !== 4 || parts[0] !== VAULT_PAYLOAD_VERSION) { + throw new Error("Unsupported secret payload version."); + } + + const [, ivRaw, authTagRaw, ciphertextRaw] = parts; + const iv = Buffer.from(ivRaw ?? "", "base64"); + const authTag = Buffer.from(authTagRaw ?? "", "base64"); + const ciphertext = Buffer.from(ciphertextRaw ?? "", "base64"); + if (iv.byteLength !== VAULT_IV_BYTES || authTag.byteLength !== 16) { + throw new Error("Invalid encrypted payload."); + } + + const decipher = createDecipheriv("aes-256-gcm", input.key, iv); + decipher.setAAD(Buffer.from(input.aad.join("\0"), "utf8")); + decipher.setAuthTag(authTag); + return `${decipher.update(ciphertext, undefined, "utf8")}${decipher.final("utf8")}`; +} + +export async function readOrCreateVaultKey(secretKeyPath: string): Promise { + try { + const existing = await fs.readFile(secretKeyPath, "utf8"); + const decoded = Buffer.from(existing.trim(), "base64"); + if (decoded.byteLength !== VAULT_KEY_BYTES) { + throw new Error("Invalid vault key length."); + } + return decoded; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code !== "ENOENT") { + throw error; + } + + await fs.mkdir(path.dirname(secretKeyPath), { recursive: true }); + const key = randomBytes(VAULT_KEY_BYTES); + try { + await fs.writeFile(secretKeyPath, `${key.toString("base64")}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + return key; + } catch (writeError) { + const writeCode = (writeError as NodeJS.ErrnoException | undefined)?.code; + if (writeCode === "EEXIST") { + const existing = await fs.readFile(secretKeyPath, "utf8"); + const decoded = Buffer.from(existing.trim(), "base64"); + if (decoded.byteLength !== VAULT_KEY_BYTES) { + throw new Error("Invalid vault key length.", { cause: writeError }); + } + return decoded; + } + throw writeError; + } + } +} diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 2b24becb..3cbfb84a 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -14,20 +14,12 @@ import type { ServerProviderStatus, ServerProviderStatusState, } from "@okcode/contracts"; -import { - Array, - Data, - Effect, - Fiber, - FileSystem, - Layer, - Option, - Path, - Result, - Stream, -} from "effect"; +import { Array, Data, Effect, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { serverBuildInfo } from "../../buildInfo.ts"; +import { OpenclawGatewayClient, OpenclawGatewayClientError } from "../../openclaw/GatewayClient.ts"; +import { OpenclawGatewayConfig } from "../../persistence/Services/OpenclawGatewayConfig.ts"; import { formatCodexCliUpgradeMessage, isCodexCliVersionSupported, @@ -43,6 +35,14 @@ class OpenClawHealthProbeError extends Data.TaggedError("OpenClawHealthProbeErro cause: unknown; }> {} +const OPENCLAW_HEALTH_REQUIRED_METHODS = [ + "sessions.create", + "sessions.get", + "sessions.send", + "sessions.abort", + "sessions.messages.subscribe", +] as const; + // ── Pure helpers ──────────────────────────────────────────────────── export interface CommandResult { @@ -608,93 +608,170 @@ export const checkClaudeProviderStatus: Effect.Effect< const OPENCLAW_PROVIDER = "openclaw" as const; -const checkOpenClawProviderStatus: Effect.Effect = Effect.gen( - function* () { - const checkedAt = new Date().toISOString(); - const gatewayUrl = process.env.OPENCLAW_GATEWAY_URL; +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 (!gatewayUrl) { - return { - provider: OPENCLAW_PROVIDER, - status: "warning" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: - "OpenClaw gateway URL is not configured. Set OPENCLAW_GATEWAY_URL or configure in settings.", - } satisfies ServerProviderStatus; - } + if (!resolvedConfigResult.ok) { + const reason = + resolvedConfigResult.cause instanceof Error + ? resolvedConfigResult.cause.message + : String(resolvedConfigResult.cause); - // Derive HTTP health URL from the gateway URL (replace ws:// with http://). - const healthUrl = gatewayUrl - .replace(/^ws:\/\//, "http://") - .replace(/^wss:\/\//, "https://") - .replace(/\/$/, "") - .concat("/health"); - - const probeResult = yield* Effect.tryPromise({ - try: async () => { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); - try { - const response = await fetch(healthUrl, { - signal: controller.signal, - }); - return { ok: response.ok, status: response.status }; - } finally { - clearTimeout(timeout); - } - }, - catch: (cause) => new OpenClawHealthProbeError({ cause }), - }).pipe(Effect.result); + return { + provider: OPENCLAW_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: `OpenClaw gateway configuration could not be read. ${reason}`, + } satisfies ServerProviderStatus; + } - if (Result.isFailure(probeResult)) { - return { - provider: OPENCLAW_PROVIDER, - status: "warning" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: `Cannot reach OpenClaw gateway at ${gatewayUrl}. Check the URL and ensure the gateway is running.`, - } satisfies ServerProviderStatus; - } + const resolvedConfig = resolvedConfigResult.resolvedConfig; - const probe = probeResult.success; - if (!probe.ok) { - return { - provider: OPENCLAW_PROVIDER, - status: "warning" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: `OpenClaw gateway at ${gatewayUrl} returned HTTP ${probe.status}.`, - } satisfies ServerProviderStatus; - } + if (!resolvedConfig) { + return { + provider: OPENCLAW_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unauthenticated" as const, + checkedAt, + message: "OpenClaw gateway URL is not configured. Save it in Settings to enable OpenClaw.", + } satisfies ServerProviderStatus; + } + 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 { provider: OPENCLAW_PROVIDER, status: "ready" as const, available: true, - authStatus: "unknown" as const, + authStatus: "authenticated" as const, checkedAt, } satisfies ServerProviderStatus; - }, -); + } + + 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 ( + detailCode === "PAIRING_REQUIRED" || + detailCode === "AUTH_TOKEN_MISSING" || + detailCode === "AUTH_TOKEN_MISMATCH" || + detailCode === "AUTH_DEVICE_TOKEN_MISMATCH" || + detailCode?.startsWith("DEVICE_AUTH_") + ) { + return { + provider: OPENCLAW_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unauthenticated" as const, + checkedAt, + message: gatewayMessage, + } satisfies ServerProviderStatus; + } + } + } + + return { + provider: OPENCLAW_PROVIDER, + status: "warning" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: `Cannot complete the OpenClaw gateway handshake at ${resolvedConfig.gatewayUrl}. Check connectivity, proxying, and pairing/device auth state.`, + } satisfies ServerProviderStatus; +}); // ── Layer ─────────────────────────────────────────────────────────── export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const statusesFiber = yield* Effect.all( - [checkCodexProviderStatus, checkClaudeProviderStatus, checkOpenClawProviderStatus], - { - concurrency: "unbounded", - }, - ).pipe(Effect.forkScoped); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const openclawGatewayConfig = yield* OpenclawGatewayConfig; return { - getStatuses: Fiber.join(statusesFiber), + getStatuses: Effect.all( + [checkCodexProviderStatus, checkClaudeProviderStatus, checkOpenClawProviderStatus], + { + concurrency: "unbounded", + }, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(OpenclawGatewayConfig, openclawGatewayConfig), + ), } satisfies ProviderHealthShape; }), ); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index b7ee3bb6..fa855c8b 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -20,12 +20,14 @@ import { ProviderUnsupportedError } from "./provider/Errors"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { makeOpenClawAdapterLive } from "./provider/Layers/OpenClawAdapter"; +import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; import { ProviderService } from "./provider/Services/ProviderService"; import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; import { EnvironmentVariablesLive } from "./persistence/Services/EnvironmentVariables"; +import { OpenclawGatewayConfigLive } from "./persistence/Layers/OpenclawGatewayConfig"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { TerminalRuntimeEnvResolverLive } from "./terminal/Layers/RuntimeEnvResolver"; @@ -94,7 +96,7 @@ export function makeServerProviderLayer(): Layer.Layer< ); const openclawAdapterLayer = makeOpenClawAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, - ); + ).pipe(Layer.provideMerge(OpenclawGatewayConfigLive)); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), @@ -124,6 +126,7 @@ export function makeServerRuntimeServicesLayer() { const runtimeServicesLayer = Layer.empty.pipe( Layer.provideMerge(EnvironmentVariablesLive), + Layer.provideMerge(OpenclawGatewayConfigLive), Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), Layer.provideMerge(orchestrationLayer), Layer.provideMerge(checkpointStoreLayer), @@ -167,6 +170,8 @@ export function makeServerRuntimeServicesLayer() { const smeChatLayer = SmeChatServiceLive.pipe( Layer.provideMerge(EnvironmentVariablesLive), + Layer.provideMerge(OpenclawGatewayConfigLive), + Layer.provideMerge(ProviderHealthLive.pipe(Layer.provideMerge(OpenclawGatewayConfigLive))), Layer.provide(SmeKnowledgeDocumentRepositoryLive), Layer.provide(SmeConversationRepositoryLive), Layer.provide(SmeMessageRepositoryLive), @@ -182,6 +187,7 @@ export function makeServerRuntimeServicesLayer() { TerminalRuntimeEnvResolverLive, KeybindingsLive, SkillServiceLive, + OpenclawGatewayConfigLive, smeChatLayer, ).pipe(Layer.provideMerge(NodeServices.layer)); } diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts index c45980ef..7e0fef60 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts @@ -6,6 +6,7 @@ import { EnvironmentVariables, type EnvironmentVariablesShape, } from "../../persistence/Services/EnvironmentVariables.ts"; +import { OpenclawGatewayConfig } from "../../persistence/Services/OpenclawGatewayConfig.ts"; import { SmeKnowledgeDocumentRepository, type SmeKnowledgeDocumentRepositoryShape, @@ -21,6 +22,10 @@ import { type SmeMessageRepositoryShape, type SmeMessageRow, } from "../../persistence/Services/SmeMessages.ts"; +import { + ProviderHealth, + type ProviderHealthShape, +} from "../../provider/Services/ProviderHealth.ts"; import { ProviderService, type ProviderServiceShape, @@ -173,6 +178,46 @@ function makeProviderService(): ProviderServiceShape { }; } +function makeOpenclawGatewayConfig() { + return { + 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, + }), + }; +} + +function makeProviderHealth(): ProviderHealthShape { + return { + getStatuses: Effect.succeed([]), + }; +} + describe("SmeChatServiceLive", () => { it("uses persisted Anthropic credentials for a successful send and stores the final reply", async () => { setAnthropicEnv({ @@ -230,6 +275,8 @@ describe("SmeChatServiceLive", () => { Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])), ), Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)), + Layer.provideMerge(Layer.succeed(OpenclawGatewayConfig, makeOpenclawGatewayConfig())), + Layer.provideMerge(Layer.succeed(ProviderHealth, makeProviderHealth())), Layer.provideMerge(Layer.succeed(ProviderService, makeProviderService())), ); @@ -326,6 +373,8 @@ describe("SmeChatServiceLive", () => { Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])), ), Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)), + Layer.provideMerge(Layer.succeed(OpenclawGatewayConfig, makeOpenclawGatewayConfig())), + Layer.provideMerge(Layer.succeed(ProviderHealth, makeProviderHealth())), Layer.provideMerge(Layer.succeed(ProviderService, makeProviderService())), ); diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.ts index 4e627115..833d6a2f 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.ts @@ -22,9 +22,11 @@ import { DateTime, Effect, Layer, Option, Random, Ref } from "effect"; import crypto from "node:crypto"; import { EnvironmentVariables } from "../../persistence/Services/EnvironmentVariables.ts"; +import { OpenclawGatewayConfig } from "../../persistence/Services/OpenclawGatewayConfig.ts"; import { SmeConversationRepository } from "../../persistence/Services/SmeConversations.ts"; import { SmeKnowledgeDocumentRepository } from "../../persistence/Services/SmeKnowledgeDocuments.ts"; import { SmeMessageRepository } from "../../persistence/Services/SmeMessages.ts"; +import { ProviderHealth } from "../../provider/Services/ProviderHealth.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { isValidSmeAuthMethod, @@ -120,6 +122,8 @@ const makeSmeChatService = (options: SmeChatServiceLiveOptions = {}) => const conversationRepo = yield* SmeConversationRepository; const messageRepo = yield* SmeMessageRepository; const environmentVariables = yield* EnvironmentVariables; + const openclawGatewayConfig = yield* OpenclawGatewayConfig; + const providerHealth = yield* ProviderHealth; const providerService = yield* ProviderService; const createClient = options.createClient ?? @@ -193,12 +197,21 @@ const makeSmeChatService = (options: SmeChatServiceLiveOptions = {}) => }); case "openclaw": + const openclawSummary = yield* openclawGatewayConfig + .getSummary() + .pipe(Effect.mapError((e) => new SmeChatError("validateSetup", e.message))); + const openclawStatus = (yield* providerHealth.getStatuses).find( + (status) => status.provider === "openclaw", + ); return validateOpenClawSetup({ authMethod: conversation.authMethod as Extract< SmeAuthMethod, "auto" | "password" | "none" >, - providerOptions, + gatewayUrl: openclawSummary.gatewayUrl, + hasSharedSecret: openclawSummary.hasSharedSecret, + hasDeviceToken: openclawSummary.hasDeviceToken, + ...(openclawStatus ? { providerStatus: openclawStatus } : {}), }); } }); diff --git a/apps/server/src/sme/authValidation.ts b/apps/server/src/sme/authValidation.ts index 235cdaa5..dfcdbe7c 100644 --- a/apps/server/src/sme/authValidation.ts +++ b/apps/server/src/sme/authValidation.ts @@ -2,6 +2,7 @@ import { type SmeAuthMethod, type SmeValidateSetupResult, type ProviderKind, + type ServerProviderStatus, } from "@okcode/contracts"; import { compactNodeProcessEnv } from "@okcode/shared/environment"; import { homedir } from "node:os"; @@ -368,12 +369,12 @@ export async function validateCodexSetup(input: { export function validateOpenClawSetup(input: { readonly authMethod: Extract; - readonly providerOptions?: CodexAppServerStartSessionInput["providerOptions"]; + readonly gatewayUrl: string | null; + readonly hasSharedSecret: boolean; + readonly hasDeviceToken: boolean; + readonly providerStatus?: ServerProviderStatus; }): SmeValidateSetupResult { - const gatewayUrl = normalizeOptionalValue(input.providerOptions?.openclaw?.gatewayUrl); - const password = normalizeOptionalValue(input.providerOptions?.openclaw?.password); - - if (!gatewayUrl) { + if (!input.gatewayUrl) { return { ok: false, severity: "error", @@ -383,13 +384,45 @@ export function validateOpenClawSetup(input: { } const resolvedAuthMethod = - input.authMethod === "auto" ? (password ? "password" : "none") : input.authMethod; + input.authMethod === "auto" ? (input.hasSharedSecret ? "password" : "none") : input.authMethod; + + if (resolvedAuthMethod === "password" && !input.hasSharedSecret) { + return { + ok: false, + severity: "error", + message: "OpenClaw shared-secret auth is selected, but no shared secret is configured.", + resolvedAuthMethod, + }; + } - if (resolvedAuthMethod === "password" && !password) { + if (input.providerStatus?.authStatus === "unauthenticated") { return { ok: false, severity: "error", - message: "OpenClaw password auth is selected, but no gateway password is configured.", + message: + input.providerStatus.message ?? + "OpenClaw is configured, but pairing or device authentication is not complete.", + resolvedAuthMethod, + }; + } + + if (input.providerStatus?.status === "warning") { + return { + ok: false, + severity: "warning", + message: + input.providerStatus.message ?? + "OpenClaw gateway health could not be verified. Test the gateway in Settings.", + resolvedAuthMethod, + }; + } + + if (!input.hasDeviceToken) { + return { + ok: false, + severity: "warning", + message: + "OpenClaw gateway settings are saved, but no device token is cached yet. Test the gateway in Settings and approve the device if prompted.", resolvedAuthMethod, }; } @@ -399,8 +432,8 @@ export function validateOpenClawSetup(input: { severity: "ready", message: resolvedAuthMethod === "password" - ? "OpenClaw gateway URL and password are configured." - : "OpenClaw gateway URL is configured.", + ? "OpenClaw gateway, shared secret, and device pairing are configured." + : "OpenClaw gateway and device pairing are configured.", resolvedAuthMethod, }; } diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 1be33c80..7afdcb1a 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -95,6 +95,7 @@ import { PrReview } from "./prReview/Services/PrReview.ts"; import { GitHub } from "./github/Services/GitHub.ts"; import { GitActionExecutionError } from "./git/Errors.ts"; import { EnvironmentVariables } from "./persistence/Services/EnvironmentVariables.ts"; +import { OpenclawGatewayConfig } from "./persistence/Services/OpenclawGatewayConfig.ts"; import { SkillService } from "./skills/SkillService.ts"; import { SmeChatService } from "./sme/Services/SmeChatService.ts"; import { TokenManager } from "./tokenManager.ts"; @@ -317,7 +318,8 @@ export type ServerRuntimeServices = | SkillService | SmeChatService | Open - | EnvironmentVariables; + | EnvironmentVariables + | OpenclawGatewayConfig; export class ServerLifecycleError extends Schema.TaggedErrorClass()( "ServerLifecycleError", @@ -355,6 +357,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const terminalManager = yield* TerminalManager; 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; @@ -369,8 +372,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ), ); - const providerStatuses = yield* providerHealth.getStatuses; - const clients = yield* Ref.make(new Set()); const logger = createLogger("ws"); const readiness = yield* makeServerReadiness; @@ -774,6 +775,19 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const skillService = yield* SkillService; const smeChatService = yield* SmeChatService; + const loadProviderStatuses = () => providerHealth.getStatuses; + + const publishServerConfigUpdated = () => + Effect.gen(function* () { + const keybindingsConfig = yield* keybindingsManager.loadConfigState; + const providers = yield* loadProviderStatuses(); + yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { + issues: keybindingsConfig.issues, + providers, + }); + return providers; + }); + const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); @@ -782,9 +796,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ).pipe(Effect.forkIn(subscriptionsScope)); yield* Stream.runForEach(keybindingsManager.streamChanges, (event) => - pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { - issues: event.issues, - providers: providerStatuses, + Effect.gen(function* () { + const providers = yield* loadProviderStatuses(); + yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { + issues: event.issues, + providers, + }); }), ).pipe(Effect.forkIn(subscriptionsScope)); @@ -1476,7 +1493,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, - providers: providerStatuses, + providers: yield* loadProviderStatuses(), availableEditors, buildInfo: serverBuildInfo, }; @@ -1564,10 +1581,42 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { tokens }; } + case WS_METHODS.serverGetOpenclawGatewayConfig: + return yield* openclawGatewayConfig.getSummary(); + + case WS_METHODS.serverSaveOpenclawGatewayConfig: { + const body = stripRequestTag(request.body); + const summary = yield* openclawGatewayConfig.save(body); + yield* publishServerConfigUpdated(); + return summary; + } + + case WS_METHODS.serverResetOpenclawGatewayDeviceState: { + const body = stripRequestTag(request.body); + const summary = yield* openclawGatewayConfig.resetDeviceState(body); + yield* publishServerConfigUpdated(); + return summary; + } + // ── OpenClaw gateway test ──────────────────────────────────────── case WS_METHODS.serverTestOpenclawGateway: { const body = stripRequestTag(request.body); - return yield* testOpenclawGateway(body); + const resolvedConfig = yield* openclawGatewayConfig.resolveForConnect({ + ...(body.gatewayUrl ? { gatewayUrl: body.gatewayUrl } : {}), + ...(body.password ? { sharedSecret: body.password } : {}), + allowEphemeralIdentity: body.gatewayUrl !== undefined, + }); + if (!resolvedConfig) { + return yield* new RouteRequestError({ + message: + "OpenClaw gateway URL is not configured. Save it in Settings or provide a test override.", + }); + } + const result = yield* testOpenclawGateway({ + gatewayUrl: resolvedConfig.gatewayUrl, + password: body.password ?? resolvedConfig.sharedSecret, + }); + return result; } // ── Connection health ─────────────────────────────────────────── diff --git a/apps/web/package.json b/apps/web/package.json index 275116f0..be689e2b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,7 +30,7 @@ "@lexical/react": "^0.41.0", "@okcode/contracts": "workspace:*", "@okcode/shared": "workspace:*", - "@pierre/diffs": "1.1.0-beta.16", + "@pierre/diffs": "1.1.13", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", "@tanstack/react-router": "^1.160.2", @@ -42,7 +42,7 @@ "html-to-image": "^1.11.13", "lexical": "^0.41.0", "lucide-react": "^0.564.0", - "oxfmt": "^0.42.0", + "oxfmt": "^0.44.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-intl": "^10.1.1", diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index a7381818..bfbf272c 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -296,14 +296,7 @@ export function getCustomModelOptionsByProvider( } export function getProviderStartOptions( - settings: Pick< - AppSettings, - | "claudeBinaryPath" - | "codexBinaryPath" - | "codexHomePath" - | "openclawGatewayUrl" - | "openclawPassword" - >, + settings: Pick, ): ProviderStartOptions | undefined { const providerOptions: ProviderStartOptions = { ...(settings.codexBinaryPath || settings.codexHomePath @@ -321,14 +314,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/PreviewPanel.tsx b/apps/web/src/components/PreviewPanel.tsx index ac867873..e7174858 100644 --- a/apps/web/src/components/PreviewPanel.tsx +++ b/apps/web/src/components/PreviewPanel.tsx @@ -610,7 +610,7 @@ export function PreviewPanel({ projectId, threadId, onClose }: PreviewPanelProps onBlur={commitCustomViewport} onKeyDown={(e) => e.key === "Enter" && commitCustomViewport()} aria-label="Viewport width" - className="h-5 w-14 text-center text-[10px] tabular-nums" + className="h-5 w-[4.5rem] text-center text-[10px] tabular-nums" min={320} max={3840} /> @@ -622,7 +622,7 @@ export function PreviewPanel({ projectId, threadId, onClose }: PreviewPanelProps onBlur={commitCustomViewport} onKeyDown={(e) => e.key === "Enter" && commitCustomViewport()} aria-label="Viewport height" - className="h-5 w-14 text-center text-[10px] tabular-nums" + className="h-5 w-[4.5rem] text-center text-[10px] tabular-nums" min={320} max={2160} /> diff --git a/apps/web/src/lib/serverReactQuery.ts b/apps/web/src/lib/serverReactQuery.ts index 6d0be949..ec5b7d76 100644 --- a/apps/web/src/lib/serverReactQuery.ts +++ b/apps/web/src/lib/serverReactQuery.ts @@ -4,6 +4,7 @@ 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, }; @@ -31,3 +32,14 @@ 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/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 3519926a..cc3b1451 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -328,7 +328,7 @@ function EventRouter() { // don't produce duplicate toasts. let subscribed = false; const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { - void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); if (!subscribed) return; const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); if (!issue) { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 9a404367..928597e7 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -79,7 +79,11 @@ import { setStoredRadiusOverride, type CustomThemeData, } from "../lib/customTheme"; -import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { + openclawGatewayConfigQueryOptions, + serverConfigQueryOptions, + serverQueryKeys, +} from "../lib/serverReactQuery"; import { cn } from "../lib/utils"; import { ensureNativeApi, readNativeApi } from "../nativeApi"; import { useStore } from "../store"; @@ -236,8 +240,25 @@ function formatOpenclawGatewayDebugReport(result: TestOpenclawGatewayResult): st 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 notifications: ${diagnostics.observedNotifications.join(", ")}`); + lines.push(`- Gateway events: ${diagnostics.observedNotifications.join(", ")}`); } if (diagnostics.hints.length > 0) { lines.push(""); @@ -555,6 +576,7 @@ function SettingsRouteView() { const { theme, setTheme, colorTheme, setColorTheme, fontFamily, setFontFamily } = useTheme(); const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const openclawGatewayConfigQuery = useQuery(openclawGatewayConfigQueryOptions()); const queryClient = useQueryClient(); const projects = useStore((state) => state.projects); const [selectedProjectId, setSelectedProjectId] = useState( @@ -594,6 +616,12 @@ function SettingsRouteView() { null, ); const [openclawTestLoading, setOpenclawTestLoading] = useState(false); + const [openclawGatewayDraft, setOpenclawGatewayDraft] = useState(null); + const [openclawSharedSecretDraft, setOpenclawSharedSecretDraft] = useState(""); + const [openclawSaveLoading, setOpenclawSaveLoading] = useState(false); + const [openclawResetLoading, setOpenclawResetLoading] = useState<"token" | "identity" | null>( + null, + ); const { copyToClipboard: copyOpenclawDebugReport, isCopied: openclawDebugReportCopied } = useCopyToClipboard(); @@ -659,9 +687,16 @@ function SettingsRouteView() { settings.claudeBinaryPath !== defaults.claudeBinaryPath || settings.codexBinaryPath !== defaults.codexBinaryPath || settings.codexHomePath !== defaults.codexHomePath; + const savedOpenclawGatewayUrl = openclawGatewayConfigQuery.data?.gatewayUrl ?? ""; + const savedOpenclawHasSharedSecret = openclawGatewayConfigQuery.data?.hasSharedSecret ?? false; + const effectiveOpenclawGatewayUrl = openclawGatewayDraft ?? savedOpenclawGatewayUrl; const isOpenClawSettingsDirty = - settings.openclawGatewayUrl !== defaults.openclawGatewayUrl || - settings.openclawPassword !== defaults.openclawPassword; + (openclawGatewayDraft !== null && openclawGatewayDraft !== savedOpenclawGatewayUrl) || + openclawSharedSecretDraft.length > 0; + const canImportLegacyOpenclawSettings = + openclawGatewayConfigQuery.isSuccess && + !savedOpenclawGatewayUrl && + Boolean(settings.openclawGatewayUrl?.trim()); const changedSettingLabels = [ ...(theme !== "system" ? ["Theme"] : []), ...(colorTheme !== DEFAULT_COLOR_THEME ? ["Color theme"] : []), @@ -775,9 +810,11 @@ function SettingsRouteView() { setOpenclawTestResult(null); try { const api = ensureNativeApi(); + const gatewayUrl = effectiveOpenclawGatewayUrl.trim(); + const sharedSecret = openclawSharedSecretDraft.trim(); const result = await api.server.testOpenclawGateway({ - gatewayUrl: settings.openclawGatewayUrl, - password: settings.openclawPassword || undefined, + ...(gatewayUrl ? { gatewayUrl } : {}), + ...(sharedSecret ? { password: sharedSecret } : {}), }); setOpenclawTestResult(result); } catch (err) { @@ -790,13 +827,111 @@ function SettingsRouteView() { } finally { setOpenclawTestLoading(false); } - }, [openclawTestLoading, settings.openclawGatewayUrl, settings.openclawPassword]); + }, [effectiveOpenclawGatewayUrl, openclawSharedSecretDraft, openclawTestLoading]); const handleCopyOpenclawDebugReport = useCallback(() => { if (!openclawTestResult) return; copyOpenclawDebugReport(formatOpenclawGatewayDebugReport(openclawTestResult), undefined); }, [copyOpenclawDebugReport, openclawTestResult]); + const saveOpenclawGatewayConfig = useCallback(async () => { + if (openclawSaveLoading) return; + const gatewayUrl = effectiveOpenclawGatewayUrl.trim(); + if (!gatewayUrl) { + throw new Error("Gateway URL is required."); + } + setOpenclawSaveLoading(true); + try { + const api = ensureNativeApi(); + const sharedSecret = openclawSharedSecretDraft.trim(); + const summary = await api.server.saveOpenclawGatewayConfig({ + gatewayUrl, + ...(sharedSecret ? { sharedSecret } : {}), + }); + queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); + setOpenclawGatewayDraft(null); + setOpenclawSharedSecretDraft(""); + setOpenclawTestResult(null); + } finally { + setOpenclawSaveLoading(false); + } + }, [effectiveOpenclawGatewayUrl, openclawSaveLoading, openclawSharedSecretDraft, queryClient]); + + const clearSavedOpenclawSharedSecret = useCallback(async () => { + const gatewayUrl = effectiveOpenclawGatewayUrl.trim(); + if (!gatewayUrl) { + throw new Error("Gateway URL is required."); + } + setOpenclawSaveLoading(true); + try { + const api = ensureNativeApi(); + const summary = await api.server.saveOpenclawGatewayConfig({ + gatewayUrl, + clearSharedSecret: true, + }); + queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); + setOpenclawSharedSecretDraft(""); + setOpenclawTestResult(null); + } finally { + setOpenclawSaveLoading(false); + } + }, [effectiveOpenclawGatewayUrl, queryClient]); + + const resetOpenclawDeviceState = useCallback( + async (regenerateIdentity: boolean) => { + if (openclawResetLoading) return; + setOpenclawResetLoading(regenerateIdentity ? "identity" : "token"); + try { + const api = ensureNativeApi(); + const summary = await api.server.resetOpenclawGatewayDeviceState({ + regenerateIdentity, + }); + queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); + setOpenclawTestResult(null); + } finally { + setOpenclawResetLoading(null); + } + }, + [openclawResetLoading, queryClient], + ); + + const importLegacyOpenclawSettings = useCallback(async () => { + const gatewayUrl = settings.openclawGatewayUrl.trim(); + if (!gatewayUrl) { + throw new Error("Legacy OpenClaw settings do not contain a gateway URL."); + } + setOpenclawSaveLoading(true); + try { + const api = ensureNativeApi(); + const sharedSecret = settings.openclawPassword.trim(); + const summary = await api.server.saveOpenclawGatewayConfig({ + gatewayUrl, + ...(sharedSecret ? { sharedSecret } : {}), + }); + queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); + updateSettings({ + openclawGatewayUrl: defaults.openclawGatewayUrl, + openclawPassword: defaults.openclawPassword, + }); + setOpenclawGatewayDraft(null); + setOpenclawSharedSecretDraft(""); + setOpenclawTestResult(null); + } finally { + setOpenclawSaveLoading(false); + } + }, [ + defaults.openclawGatewayUrl, + defaults.openclawPassword, + queryClient, + settings.openclawGatewayUrl, + settings.openclawPassword, + updateSettings, + ]); + const addCustomModel = useCallback( (provider: ProviderKind) => { const customModelInput = customModelInputByProvider[provider]; @@ -2378,21 +2513,40 @@ function SettingsRouteView() { title="OpenClaw gateway" description="Connect to an OpenClaw gateway for remote agent sessions." resetAction={ - settings.openclawGatewayUrl !== defaults.openclawGatewayUrl || - settings.openclawPassword !== defaults.openclawPassword ? ( + isOpenClawSettingsDirty ? ( - updateSettings({ - openclawGatewayUrl: defaults.openclawGatewayUrl, - openclawPassword: defaults.openclawPassword, - }) - } + onClick={() => { + setOpenclawGatewayDraft(null); + setOpenclawSharedSecretDraft(""); + setOpenclawTestResult(null); + }} /> ) : null } >
+ {canImportLegacyOpenclawSettings ? ( +
+
+ + Legacy browser-local OpenClaw settings were found. Import them to + the server to make them active. + + +
+
+ ) : null} - {/* Test Connection Button */} -
+
+
+ Saved URL:{" "} + + {openclawGatewayConfigQuery.data?.gatewayUrl ?? "Not saved"} + +
+
+ Saved shared secret:{" "} + + {savedOpenclawHasSharedSecret ? "Configured" : "Not configured"} + +
+
+ Device fingerprint:{" "} + + {openclawGatewayConfigQuery.data?.deviceFingerprint ?? "Not created"} + +
+
+ Cached device token:{" "} + + {openclawGatewayConfigQuery.data?.hasDeviceToken + ? "Present" + : "Not cached"} + +
+
+ +
+ + {savedOpenclawHasSharedSecret ? ( + + ) : null} + +
{/* Debug / Results Panel */} @@ -2621,10 +2871,54 @@ function SettingsRouteView() {
)} + {openclawTestResult.diagnostics.gatewayErrorCode && ( +
+ Gateway error code:{" "} + + {openclawTestResult.diagnostics.gatewayErrorCode} + +
+ )} + {openclawTestResult.diagnostics.gatewayErrorDetailCode && ( +
+ Gateway detail code:{" "} + + {openclawTestResult.diagnostics.gatewayErrorDetailCode} + +
+ )} + {openclawTestResult.diagnostics.gatewayErrorDetailReason && ( +
+ Gateway detail reason:{" "} + + {openclawTestResult.diagnostics.gatewayErrorDetailReason} + +
+ )} + {openclawTestResult.diagnostics.gatewayRecommendedNextStep && ( +
+ Gateway next step:{" "} + + {openclawTestResult.diagnostics.gatewayRecommendedNextStep} + +
+ )} + {openclawTestResult.diagnostics.gatewayCanRetryWithDeviceToken !== + undefined && ( +
+ Device-token retry available:{" "} + + {openclawTestResult.diagnostics + .gatewayCanRetryWithDeviceToken + ? "Yes" + : "No"} + +
+ )} {openclawTestResult.diagnostics.observedNotifications.length > 0 && (
- Gateway notifications:{" "} + Gateway events:{" "} {openclawTestResult.diagnostics.observedNotifications.join( ", ", diff --git a/apps/web/src/themes.css b/apps/web/src/themes.css index e0235c00..77cd4471 100644 --- a/apps/web/src/themes.css +++ b/apps/web/src/themes.css @@ -63,64 +63,92 @@ --warning-foreground: #f1a10d; } -/* ─── Solar Witch ─── magical, cozy, ritualistic ─── */ +/* ─── Solar Witch ─── mystical, radiant, high-contrast sunset magic ─── */ :root.theme-solar-witch { color-scheme: light; - --background: #faf5ee; - --foreground: #2d2118; - --card: #f5efe4; - --card-foreground: #2d2118; - --popover: #f5efe4; - --popover-foreground: #2d2118; - --primary: oklch(0.62 0.18 55); + --background: #fff7ed; + --foreground: #3b0764; + --card: #ffedd5; + --card-foreground: #3b0764; + --popover: #fff7ed; + --popover-foreground: #3b0764; + --primary: oklch(0.68 0.23 35); --primary-foreground: #ffffff; - --secondary: rgba(160, 100, 40, 0.06); - --secondary-foreground: #2d2118; - --muted: rgba(160, 100, 40, 0.06); - --muted-foreground: #8a7560; - --accent: rgba(160, 100, 40, 0.08); - --accent-foreground: #2d2118; - --destructive: #e5484d; - --destructive-foreground: #cd2b31; - --border: rgba(160, 100, 40, 0.1); - --input: rgba(160, 100, 40, 0.12); - --ring: oklch(0.62 0.18 55); - --info: #b47a2b; - --info-foreground: #96631e; - --success: #46a758; - --success-foreground: #2d8a3e; - --warning: #e5a836; - --warning-foreground: #ad7a18; + --secondary: rgba(251, 146, 60, 0.12); + --secondary-foreground: #7c2d12; + --muted: rgba(245, 158, 11, 0.12); + --muted-foreground: #9a3412; + --accent: rgba(236, 72, 153, 0.12); + --accent-foreground: #831843; + --destructive: #dc2626; + --destructive-foreground: #b91c1c; + --border: rgba(234, 88, 12, 0.18); + --input: rgba(234, 88, 12, 0.14); + --ring: oklch(0.68 0.23 35); + --info: #7c3aed; + --info-foreground: #6d28d9; + --success: #16a34a; + --success-foreground: #15803d; + --warning: #f59e0b; + --warning-foreground: #b45309; } :root.theme-solar-witch.dark { color-scheme: dark; - --background: #120e0a; - --foreground: #f0e6d6; - --card: #1a140e; - --card-foreground: #f0e6d6; - --popover: #1a140e; - --popover-foreground: #f0e6d6; - --primary: oklch(0.72 0.17 60); - --primary-foreground: #120e0a; - --secondary: rgba(220, 170, 100, 0.06); - --secondary-foreground: #f0e6d6; - --muted: rgba(220, 170, 100, 0.06); - --muted-foreground: #9a8a78; - --accent: rgba(220, 170, 100, 0.08); - --accent-foreground: #f0e6d6; - --destructive: #ff6369; - --destructive-foreground: #ff9592; - --border: rgba(220, 170, 100, 0.08); - --input: rgba(220, 170, 100, 0.1); - --ring: oklch(0.72 0.17 60); - --info: #e8a860; - --info-foreground: #f0c088; - --success: #4cc38a; - --success-foreground: #3dd68c; - --warning: #ffb224; - --warning-foreground: #f1a10d; + --background: #140b1f; + --foreground: #f8e8ff; + --card: #1f102b; + --card-foreground: #f8e8ff; + --popover: #1f102b; + --popover-foreground: #f8e8ff; + --primary: oklch(0.74 0.21 55); + --primary-foreground: #1a1022; + --secondary: rgba(251, 146, 60, 0.12); + --secondary-foreground: #ffd7a3; + --muted: rgba(236, 72, 153, 0.1); + --muted-foreground: #d8b4fe; + --accent: rgba(168, 85, 247, 0.14); + --accent-foreground: #f3e8ff; + --destructive: #fb7185; + --destructive-foreground: #fda4af; + --border: rgba(251, 146, 60, 0.14); + --input: rgba(251, 146, 60, 0.18); + --ring: oklch(0.74 0.21 55); + --info: #a78bfa; + --info-foreground: #c4b5fd; + --success: #4ade80; + --success-foreground: #86efac; + --warning: #fbbf24; + --warning-foreground: #fcd34d; +} +:root.theme-solar-witch.dark { + color-scheme: dark; + --background: #140b1f; + --foreground: #f8e8ff; + --card: #1f102b; + --card-foreground: #f8e8ff; + --popover: #1f102b; + --popover-foreground: #f8e8ff; + --primary: oklch(0.74 0.21 55); + --primary-foreground: #1a1022; + --secondary: rgba(251, 146, 60, 0.12); + --secondary-foreground: #ffd7a3; + --muted: rgba(236, 72, 153, 0.1); + --muted-foreground: #d8b4fe; + --accent: rgba(168, 85, 247, 0.14); + --accent-foreground: #f3e8ff; + --destructive: #fb7185; + --destructive-foreground: #fda4af; + --border: rgba(251, 146, 60, 0.14); + --input: rgba(251, 146, 60, 0.18); + --ring: oklch(0.74 0.21 55); + --info: #a78bfa; + --info-foreground: #c4b5fd; + --success: #4ade80; + --success-foreground: #86efac; + --warning: #fbbf24; + --warning-foreground: #fcd34d; } /* ─── Carbon ─── stark, modern, performance-focused (Vercel-inspired) ─── */ diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index cc8897ee..93a840f6 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -382,6 +382,11 @@ 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), testOpenclawGateway: (input) => transport.request(WS_METHODS.serverTestOpenclawGateway, input), }, diff --git a/bun.lock b/bun.lock index 71dd7255..d2b1b8d0 100644 --- a/bun.lock +++ b/bun.lock @@ -4,16 +4,16 @@ "": { "name": "@okcode/monorepo", "dependencies": { - "@pierre/diffs": "1.1.0-beta.16", - "lucide-react": "^1.7.0", + "@pierre/diffs": "1.1.13", + "lucide-react": "^1.8.0", }, "devDependencies": { "@types/node": "catalog:", "husky": "^9.1.7", "lint-staged": "^16.4.0", - "oxfmt": "^0.42.0", - "oxlint": "^1.58.0", - "turbo": "^2.9.4", + "oxfmt": "^0.44.0", + "oxlint": "^1.59.0", + "turbo": "^2.9.5", "vitest": "catalog:", }, }, @@ -131,7 +131,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.77", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@pierre/diffs": "^1.1.0-beta.16", + "@pierre/diffs": "1.1.13", "effect": "catalog:", "node-pty": "^1.1.0", "open": "^10.1.0", @@ -172,7 +172,7 @@ "@lexical/react": "^0.41.0", "@okcode/contracts": "workspace:*", "@okcode/shared": "workspace:*", - "@pierre/diffs": "1.1.0-beta.16", + "@pierre/diffs": "1.1.13", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", "@tanstack/react-router": "^1.160.2", @@ -184,7 +184,7 @@ "html-to-image": "^1.11.13", "lexical": "^0.41.0", "lucide-react": "^0.564.0", - "oxfmt": "^0.42.0", + "oxfmt": "^0.44.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-intl": "^10.1.1", @@ -760,85 +760,85 @@ "@oxc-project/types": ["@oxc-project/types@0.112.0", "", {}, "sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.42.0", "", { "os": "android", "cpu": "arm" }, "sha512-dsqPTYsozeokRjlrt/b4E7Pj0z3eS3Eg74TWQuuKbjY4VttBmA88rB7d50Xrd+TZ986qdXCNeZRPEzZHAe+jow=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.44.0", "", { "os": "android", "cpu": "arm" }, "sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ=="], - "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.42.0", "", { "os": "android", "cpu": "arm64" }, "sha512-t+aAjHxcr5eOBphFHdg1ouQU9qmZZoRxnX7UOJSaTwSoKsb6TYezNKO0YbWytGXCECObRqNcUxPoPr0KaraAIg=="], + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.44.0", "", { "os": "android", "cpu": "arm64" }, "sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A=="], - "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.42.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ulpSEYMKg61C5bRMZinFHrKJYRoKGVbvMEXA5zM1puX3O9T6Q4XXDbft20yrDijpYWeuG59z3Nabt+npeTsM1A=="], + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.44.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eWCLAIKAHfx88EqEP1Ga2yz7qVcqDU5lemn4xck+07bH182hDdprOHjbogyk0In1Djys3T0/pO2JepFnRJ41Mg=="], - "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.42.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ttxLKhQYPdFiM8I/Ri37cvqChE4Xa562nNOsZFcv1CKTVLeEozXjKuYClNvxkXmNlcF55nzM80P+CQkdFBu+uQ=="], + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.44.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eHTBznHLM49++dwz07MblQ2cOXyIgeedmE3Wgy4ptUESj38/qYZyRi1MPwC9olQJWssMeY6WI3UZ7YmU5ggvyQ=="], - "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.42.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Og7QS3yI3tdIKYZ58SXik0rADxIk2jmd+/YvuHRyKULWpG4V2fR5V4hvKm624Mc0cQET35waPXiCQWvjQEjwYQ=="], + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.44.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jLMmbj0u0Ft43QpkUVr/0v1ZfQCGWAvU+WznEHcN3wZC/q6ox7XeSJtk9P36CCpiDSUf3sGnzbIuG1KdEMEDJQ=="], - "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.42.0", "", { "os": "linux", "cpu": "arm" }, "sha512-jwLOw/3CW4H6Vxcry4/buQHk7zm9Ne2YsidzTL1kpiMe4qqrRCwev3dkyWe2YkFmP+iZCQ7zku4KwjcLRoh8ew=="], + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.44.0", "", { "os": "linux", "cpu": "arm" }, "sha512-n+A/u/ByK1qV8FVGOwyaSpw5NPNl0qlZfgTBqHeGIqr8Qzq1tyWZ4lAaxPoe5mZqE3w88vn3+jZtMxriHPE7tg=="], - "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.42.0", "", { "os": "linux", "cpu": "arm" }, "sha512-XwXu2vkMtiq2h7tfvN+WA/9/5/1IoGAVCFPiiQUvcAuG3efR97KNcRGM8BetmbYouFotQ2bDal3yyjUx6IPsTg=="], + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.44.0", "", { "os": "linux", "cpu": "arm" }, "sha512-5eax+FkxyCqAi3Rw0mrZFr7+KTt/XweFsbALR+B5ljWBLBl8nHe4ADrUnb1gLEfQCJLl+Ca5FIVD4xEt95AwIw=="], - "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.42.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ea7s/XUJoT7ENAtUQDudFe3nkSM3e3Qpz4nJFRdzO2wbgXEcjnchKLEsV3+t4ev3r8nWxIYr9NRjPWtnyIFJVA=="], + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-58l8JaHxSGOmOMOG2CIrNsnkRJAj0YcHQCmvNACniOa/vd1iRHhlPajczegzS5jwMENlqgreyiTR9iNlke8qCw=="], - "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.42.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-+JA0YMlSdDqmacygGi2REp57c3fN+tzARD8nwsukx9pkCHK+6DkbAA9ojS4lNKsiBjIW8WWa0pBrBWhdZEqfuw=="], + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg=="], - "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.42.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VfnET0j4Y5mdfCzh5gBt0NK28lgn5DKx+8WgSMLYYeSooHhohdbzwAStLki9pNuGy51y4I7IoW8bqwAaCMiJQg=="], + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.44.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A=="], - "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.42.0", "", { "os": "linux", "cpu": "none" }, "sha512-gVlCbmBkB0fxBWbhBj9rcxezPydsQHf4MFKeHoTSPicOQ+8oGeTQgQ8EeesSybWeiFPVRx3bgdt4IJnH6nOjAA=="], + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.44.0", "", { "os": "linux", "cpu": "none" }, "sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ=="], - "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.42.0", "", { "os": "linux", "cpu": "none" }, "sha512-zN5OfstL0avgt/IgvRu0zjQzVh/EPkcLzs33E9LMAzpqlLWiPWeMDZyMGFlSRGOdDjuNmlZBCgj0pFnK5u32TQ=="], + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.44.0", "", { "os": "linux", "cpu": "none" }, "sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw=="], - "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.42.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-9X6+H2L0qMc2sCAgO9HS03bkGLMKvOFjmEdchaFlany3vNZOjnVui//D8k/xZAtQv2vaCs1reD5KAgPoIU4msA=="], + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.44.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA=="], - "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.42.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BajxJ6KQvMMdpXGPWhBGyjb2Jvx4uec0w+wi6TJZ6Tv7+MzPwe0pO8g5h1U0jyFgoaF7mDl6yKPW3ykWcbUJRw=="], + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA=="], - "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.42.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0wV284I6vc5f0AqAhgAbHU2935B4bVpncPoe5n/WzVZY/KnHgqxC8iSFGeSyLWEgstFboIcWkOPck7tqbdHkzA=="], + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A=="], - "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.42.0", "", { "os": "none", "cpu": "arm64" }, "sha512-p4BG6HpGnhfgHk1rzZfyR6zcWkE7iLrWxyehHfXUy4Qa5j3e0roglFOdP/Nj5cJJ58MA3isQ5dlfkW2nNEpolw=="], + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.44.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw=="], - "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.42.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-mn//WV60A+IetORDxYieYGAoQso4KnVRRjORDewMcod4irlRe0OSC7YPhhwaexYNPQz/GCFk+v9iUcZ2W22yxQ=="], + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.44.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-yWzB+oCpSnP/dmw85eFLAT5o35Ve5pkGS2uF/UCISpIwDqf1xa7OpmtomiqY/Vzg8VyvMbuf6vroF2khF/+1Vg=="], - "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.42.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-3gWltUrvuz4LPJXWivoAxZ28Of2O4N7OGuM5/X3ubPXCEV8hmgECLZzjz7UYvSDUS3grfdccQwmjynm+51EFpw=="], + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.44.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-TcWpo18xEIE3AmIG2kpr3kz5IEhQgnx0lazl2+8L+3eTopOAUevQcmlr4nhguImNWz0OMeOZrYZOhJNCf16nlQ=="], - "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.42.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Wg4TMAfQRL9J9AZevJ/ZNy3uyyDztDYQtGr4P8UyyzIhLhFrdSmz1J/9JT+rv0fiCDLaFOBQnj3f3K3+a5PzDQ=="], + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.44.0", "", { "os": "win32", "cpu": "x64" }, "sha512-oj8aLkPJZppIM4CMQNsyir9ybM1Xw/CfGPTSsTnzpVGyljgfbdP0EVUlURiGM0BDrmw5psQ6ArmGCcUY/yABaQ=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.58.0", "", { "os": "android", "cpu": "arm" }, "sha512-1T7UN3SsWWxpWyWGn1cT3ASNJOo+pI3eUkmEl7HgtowapcV8kslYpFQcYn431VuxghXakPNlbjRwhqmR37PFOg=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-etYDw/UaEv936AQUd/CRMBVd+e+XuuU6wC+VzOv1STvsTyZenLChepLWqLtnyTTp4YMlM22ypzogDDwqYxv5cg=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.58.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GryzujxuiRv2YFF7bRy8mKcxlbuAN+euVUtGJt9KKbLT8JBUIosamVhcthLh+VEr6KE6cjeVMAQxKAzJcoN7dg=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TgLc7XVLKH2a4h8j3vn1MDjfK33i9MY60f/bKhRGWyVzbk5LCZ4X01VZG7iHrMmi5vYbAp8//Ponigx03CLsdw=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.58.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7/bRSJIwl4GxeZL9rPZ11anNTyUO9epZrfEJH/ZMla3+/gbQ6xZixh9nOhsZ0QwsTW7/5J2A/fHbD1udC5DQQA=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DXyFPf5ZKldMLloRHx/B9fsxsiTQomaw7cmEW3YIJko2HgCh+GUhp9gGYwHrqlLJPsEe3dYj9JebjX92D3j3AA=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.58.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EqdtJSiHweS2vfILNrpyJ6HUwpEq2g7+4Zx1FPi4hu3Hu7tC3znF6ufbXO8Ub2LD4mGgznjI7kSdku9NDD1Mkg=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-LgvrsdgVLX1qWqIEmNsSmMXJhpAWdtUQ0M+oR0CySwi+9IHWyOGuIL8w8+u/kbZNMyZr4WUyYB5i0+D+AKgkLg=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.58.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-VQt5TH4M42mY20F545G637RKxV/yjwVtKk2vfXuazfReSIiuvWBnv+FVSvIV5fKVTJNjt3GSJibh6JecbhGdBw=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bOJhqX/ny4hrFuTPlyk8foSRx/vLRpxJh0jOOKN2NWW6FScXHPAA5rQbrwdQPcgGB5V8Ua51RS03fke8ssBcug=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.58.0", "", { "os": "linux", "cpu": "arm" }, "sha512-fBYcj4ucwpAtjJT3oeBdFBYKvNyjRSK+cyuvBOTQjh0jvKp4yeA4S/D0IsCHus/VPaNG5L48qQkh+Vjy3HL2/Q=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-vVUXxYMF9trXCsz4m9H6U0IjehosVHxBzVgJUxly1uz4W1PdDyicaBnpC0KRXsHYretLVe+uS9pJy8iM57Kujw=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.58.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0BeuFfwlUHlJ1xpEdSD1YO3vByEFGPg36uLjK1JgFaxFb4W6w17F8ET8sz5cheZ4+x5f2xzdnRrrWv83E3Yd8g=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-TULQW8YBPGRWg5yZpFPL54HLOnJ3/HiX6VenDPi6YfxB/jlItwSMFh3/hCeSNbh+DAMaE1Py0j5MOaivHkI/9Q=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TXlZgnPTlxrQzxG9ZXU7BNwx1Ilrr17P3GwZY0If2EzrinqRH3zXPc3HrRcBJgcsoZNMuNL5YivtkJYgp467UQ=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Gt54Y4eqSgYJ90xipm24xeyaPV854706o/kiT8oZvUt3VDY7qqxdqyGqchMaujd87ib+/MXvnl9WkK8Cc1BExg=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zSoYRo5dxHLcUx93Stl2hW3hSNjPt99O70eRVWt5A1zwJ+FPjeCCANCD2a9R4JbHsdcl11TIQOjyigcRVOH2mw=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3CtsKp7NFB3OfqQzbuAecrY7GIZeiv7AD+xutU4tefVQzlfmTI7/ygWLrvkzsDEjTlMq41rYHxgsn6Yh8tybmA=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.58.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NQ0U/lqxH2/VxBYeAIvMNUK1y0a1bJ3ZicqkF2c6wfakbEciP9jvIE4yNzCFpZaqeIeRYaV7AVGqEO1yrfVPjA=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-K0diOpT3ncDmOfl9I1HuvpEsAuTxkts0VYwIv/w6Xiy9CdwyPBVX88Ga9l8VlGgMrwBMnSY4xIvVlVY/fkQk7Q=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-X9J+kr3gIC9FT8GuZt0ekzpNUtkBVzMVU4KiKDSlocyQuEgi3gBbXYN8UkQiV77FTusLDPsovjo95YedHr+3yg=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-xAU7+QDU6kTJJ7mJLOGgo7oOjtAtkKyFZ0Yjdb5cEo3DiCCPFLvyr08rWiQh6evZ7RiUTf+o65NY/bqttzJiQQ=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-CDze3pi1OO3Wvb/QsXjmLEY4XPKGM6kIo82ssNOgmcl1IdndF9VSGAE38YLhADWmOac7fjqhBw82LozuUVxD0Q=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-KUmZmKlTTyauOnvUNVxK7G40sSSx0+w5l1UhaGsC6KPpOYHenx2oqJTnabmpLJicok7IC+3Y6fXAUOMyexaeJQ=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.58.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-b/89glbxFaEAcA6Uf1FvCNecBJEgcUTsV1quzrqXM/o4R1M4u+2KCVuyGCayN2UpsRWtGGLb+Ver0tBBpxaPog=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-4usRxC8gS0PGdkHnRmwJt/4zrQNZyk6vL0trCxwZSsAKM+OxhB8nKiR+mhjdBbl8lbMh2gc3bZpNN/ik8c4c2A=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0/yYpkq9VJFCEcuRlrViGj8pJUFFvNS4EkEREaN7CB1EcLXJIaVSSa5eCihwBGXtOZxhnblWgxks9juRdNQI7w=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-s/rNE2gDmbwAOOP493xk2X7M8LZfI1LJFSSW1+yanz3vuQCFPiHkx4GY+O1HuLUDtkzGlhtMrIcxxzyYLv308w=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hr6FNvmcAXiH+JxSvaJ4SJ1HofkdqEElXICW9sm3/Rd5eC3t7kzvmLyRAB3NngKO2wzXRCAm4Z/mGWfrsS4X8w=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+yYj1udJa2UvvIUmEm0IcKgc0UlPMgz0nsSTvkPL2y6n0uU5LgIHSwVu4AHhrve6j9BpVSoRksnz8c9QcvITJA=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.58.0", "", { "os": "none", "cpu": "arm64" }, "sha512-R+O368VXgRql1K6Xar+FEo7NEwfo13EibPMoTv3sesYQedRXd6m30Dh/7lZMxnrQVFfeo4EOfYIP4FpcgWQNHg=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bUplUb48LYsB3hHlQXP2ZMOenpieWoOyppLAnnAhuPag3MGPnt+7caxE3w/Vl9wpQsTA3gzLntQi9rxWrs7Xqg=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.58.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q0FZiAY/3c4YRj4z3h9K1PgaByrifrfbBoODSeX7gy97UtB7pySPUQfC2B/GbxWU6k7CzQrRy5gME10PltLAFQ=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-/HLsLuz42rWl7h7ePdmMTpHm2HIDmPtcEMYgm5BBEHiEiuNOrzMaUpd2z7UnNni5LGN9obJy2YoAYBLXQwazrA=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.58.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Y8FKBABrSPp9H0QkRLHDHOSUgM/309a3IvOVgPcVxYcX70wxJrk608CuTg7w+C6vEd724X5wJoNkBcGYfH7nNQ=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-rUPy+JnanpPwV/aJCPnxAD1fW50+XPI0VkWr7f0vEbqcdsS8NpB24Rw6RsS7SdpFv8Dw+8ugCwao5nCFbqOUSg=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.58.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bCn5rbiz5My+Bj7M09sDcnqW0QJyINRVxdZ65x1/Y2tGrMwherwK/lpk+HRQCKvXa8pcaQdF5KY5j54VGZLwNg=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xkE7puteDS/vUyRngLXW0t8WgdWoS/tfxXjhP/P7SMqPDx+hs44SpssO3h3qmTqECYEuXBUPzcAw5257Ka+ofA=="], - "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.16", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-McjTuEPuacSIcXdoI2O9W6VSHIOs9ApEHnEUwONKZnKqIo2GGv1vNg9Pr8tgBOL7lgBWNEHX5ROJ5z1X74sENQ=="], + "@pierre/diffs": ["@pierre/diffs@1.1.13", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-lnX9Fy5eC+07b8g+D8krC3txOY6LRN5VNR1qr9bph9XEyLxbwwfGN7SFRu4HGozpkDdA76JARgxgWHN+uAihmg=="], - "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], + "@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], @@ -1074,17 +1074,17 @@ "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="], - "@turbo/darwin-64": ["@turbo/darwin-64@2.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZSlPqJ5Vqg/wgVw8P3AOVCIosnbBilOxLq7TMz3MN/9U46DUYfdG2jtfevNDufyxyrg98pcPs/GBgDRaaids6g=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.9.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-qPxhKsLMQP+9+dsmPgAGidi5uNifD4AoAOnEnljab3Qgn0QZRR31Hp+/CgW3Ia5AanWj6JuLLTBYvuQj4mqTWg=="], - "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9cjTWe4OiNlFMSRggPNh+TJlRs7MS5FWrHc96MOzft5vESWjjpvaadYPv5ykDW7b45mVHOF2U/W+48LoX9USWw=="], + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vkF/9F/l3aWd4bHxTui5Hh0F5xrTZ4e3rbBsc57zA6O8gNbmHN3B6eZ5psAIP2CnJRZ8ZxRjV3WZHeNXMXkPBw=="], - "@turbo/linux-64": ["@turbo/linux-64@2.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Cl1GjxqBXQ+r9KKowmXG+lhD1gclLp48/SE7NxL//66iaMytRw0uiphWGOkccD92iPiRjHLRUaA9lOTtgr5OCA=="], + "@turbo/linux-64": ["@turbo/linux-64@2.9.5", "", { "os": "linux", "cpu": "x64" }, "sha512-z/Get5NUaUxm5HSGFqVMICDRjFNsCUhSc4wnFa/PP1QD0NXCjr7bu9a2EM6md/KMCBW0Qe393Ac+UM7/ryDDTw=="], - "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2hPAKVmGNN2EsKigEWD+43y9m7zaPhNAs6ptsyfq0u7evHHBAXAwOfv86OEMg/gvC+pwGip0i1CIm1bR1vYug=="], + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-jyBifaNoI5/NheyswomiZXJvjdAdvT7hDRYzQ4meP0DKGvpXUjnqsD+4/J2YSDQ34OHxFkL30FnSCUIVOh2PHw=="], - "@turbo/windows-64": ["@turbo/windows-64@2.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-1jWPjCe9ZRmsDTXE7uzqfySNQspnUx0g6caqvwps+k/sc+fm9hC/4zRQKlXZLbVmP3Xxp601Ju71boegHdnYGw=="], + "@turbo/windows-64": ["@turbo/windows-64@2.9.5", "", { "os": "win32", "cpu": "x64" }, "sha512-ph24K5uPtvo7UfuyDXnBiB/8XvrO+RQWbbw5zkA/bVNoy9HDiNoIJJj3s62MxT9tjEb6DnPje5PXSz1UR7QAyg=="], - "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-dlko15TQVu/BFYmIY018Y3covWMRQlUgAkD+OOk+Rokcfj6VY02Vv4mCfT/Zns6B4q8jGbOd6IZhnCFYsE8Viw=="], + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-6c5RccT/+iR39SdT1G5HyZaD2n57W77o+l0TTfxG/cVlhV94Acyg2gTQW7zUOhW1BeQpBjHzu9x8yVBZwrHh7g=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -1746,7 +1746,7 @@ "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], - "lucide-react": ["lucide-react@1.7.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg=="], + "lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1928,9 +1928,9 @@ "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], - "oxfmt": ["oxfmt@0.42.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.42.0", "@oxfmt/binding-android-arm64": "0.42.0", "@oxfmt/binding-darwin-arm64": "0.42.0", "@oxfmt/binding-darwin-x64": "0.42.0", "@oxfmt/binding-freebsd-x64": "0.42.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.42.0", "@oxfmt/binding-linux-arm-musleabihf": "0.42.0", "@oxfmt/binding-linux-arm64-gnu": "0.42.0", "@oxfmt/binding-linux-arm64-musl": "0.42.0", "@oxfmt/binding-linux-ppc64-gnu": "0.42.0", "@oxfmt/binding-linux-riscv64-gnu": "0.42.0", "@oxfmt/binding-linux-riscv64-musl": "0.42.0", "@oxfmt/binding-linux-s390x-gnu": "0.42.0", "@oxfmt/binding-linux-x64-gnu": "0.42.0", "@oxfmt/binding-linux-x64-musl": "0.42.0", "@oxfmt/binding-openharmony-arm64": "0.42.0", "@oxfmt/binding-win32-arm64-msvc": "0.42.0", "@oxfmt/binding-win32-ia32-msvc": "0.42.0", "@oxfmt/binding-win32-x64-msvc": "0.42.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-QhejGErLSMReNuZ6vxgFHDyGoPbjTRNi6uGHjy0cvIjOQFqD6xmr/T+3L41ixR3NIgzcNiJ6ylQKpvShTgDfqg=="], + "oxfmt": ["oxfmt@0.44.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.44.0", "@oxfmt/binding-android-arm64": "0.44.0", "@oxfmt/binding-darwin-arm64": "0.44.0", "@oxfmt/binding-darwin-x64": "0.44.0", "@oxfmt/binding-freebsd-x64": "0.44.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.44.0", "@oxfmt/binding-linux-arm-musleabihf": "0.44.0", "@oxfmt/binding-linux-arm64-gnu": "0.44.0", "@oxfmt/binding-linux-arm64-musl": "0.44.0", "@oxfmt/binding-linux-ppc64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-musl": "0.44.0", "@oxfmt/binding-linux-s390x-gnu": "0.44.0", "@oxfmt/binding-linux-x64-gnu": "0.44.0", "@oxfmt/binding-linux-x64-musl": "0.44.0", "@oxfmt/binding-openharmony-arm64": "0.44.0", "@oxfmt/binding-win32-arm64-msvc": "0.44.0", "@oxfmt/binding-win32-ia32-msvc": "0.44.0", "@oxfmt/binding-win32-x64-msvc": "0.44.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w=="], - "oxlint": ["oxlint@1.58.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.58.0", "@oxlint/binding-android-arm64": "1.58.0", "@oxlint/binding-darwin-arm64": "1.58.0", "@oxlint/binding-darwin-x64": "1.58.0", "@oxlint/binding-freebsd-x64": "1.58.0", "@oxlint/binding-linux-arm-gnueabihf": "1.58.0", "@oxlint/binding-linux-arm-musleabihf": "1.58.0", "@oxlint/binding-linux-arm64-gnu": "1.58.0", "@oxlint/binding-linux-arm64-musl": "1.58.0", "@oxlint/binding-linux-ppc64-gnu": "1.58.0", "@oxlint/binding-linux-riscv64-gnu": "1.58.0", "@oxlint/binding-linux-riscv64-musl": "1.58.0", "@oxlint/binding-linux-s390x-gnu": "1.58.0", "@oxlint/binding-linux-x64-gnu": "1.58.0", "@oxlint/binding-linux-x64-musl": "1.58.0", "@oxlint/binding-openharmony-arm64": "1.58.0", "@oxlint/binding-win32-arm64-msvc": "1.58.0", "@oxlint/binding-win32-ia32-msvc": "1.58.0", "@oxlint/binding-win32-x64-msvc": "1.58.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-t4s9leczDMqlvOSjnbCQe7gtoLkWgBGZ7sBdCJ9EOj5IXFSG/X7OAzK4yuH4iW+4cAYe8kLFbC8tuYMwWZm+Cg=="], + "oxlint": ["oxlint@1.59.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.59.0", "@oxlint/binding-android-arm64": "1.59.0", "@oxlint/binding-darwin-arm64": "1.59.0", "@oxlint/binding-darwin-x64": "1.59.0", "@oxlint/binding-freebsd-x64": "1.59.0", "@oxlint/binding-linux-arm-gnueabihf": "1.59.0", "@oxlint/binding-linux-arm-musleabihf": "1.59.0", "@oxlint/binding-linux-arm64-gnu": "1.59.0", "@oxlint/binding-linux-arm64-musl": "1.59.0", "@oxlint/binding-linux-ppc64-gnu": "1.59.0", "@oxlint/binding-linux-riscv64-gnu": "1.59.0", "@oxlint/binding-linux-riscv64-musl": "1.59.0", "@oxlint/binding-linux-s390x-gnu": "1.59.0", "@oxlint/binding-linux-x64-gnu": "1.59.0", "@oxlint/binding-linux-x64-musl": "1.59.0", "@oxlint/binding-openharmony-arm64": "1.59.0", "@oxlint/binding-win32-arm64-msvc": "1.59.0", "@oxlint/binding-win32-ia32-msvc": "1.59.0", "@oxlint/binding-win32-x64-msvc": "1.59.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-0xBLeGGjP4vD9pygRo8iuOkOzEU1MqOnfiOl7KYezL/QvWL8NUg6n03zXc7ZVqltiOpUxBk2zgHI3PnRIEdAvw=="], "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], @@ -2238,7 +2238,7 @@ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - "turbo": ["turbo@2.9.4", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.4", "@turbo/darwin-arm64": "2.9.4", "@turbo/linux-64": "2.9.4", "@turbo/linux-arm64": "2.9.4", "@turbo/windows-64": "2.9.4", "@turbo/windows-arm64": "2.9.4" }, "bin": { "turbo": "bin/turbo" } }, "sha512-wZ/kMcZCuK5oEp7sXSSo/5fzKjP9I2EhoiarZjyCm2Ixk0WxFrC/h0gF3686eHHINoFQOOSWgB/pGfvkR8rkgQ=="], + "turbo": ["turbo@2.9.5", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.5", "@turbo/darwin-arm64": "2.9.5", "@turbo/linux-64": "2.9.5", "@turbo/linux-arm64": "2.9.5", "@turbo/windows-64": "2.9.5", "@turbo/windows-arm64": "2.9.5" }, "bin": { "turbo": "bin/turbo" } }, "sha512-JXNkRe6H6MjSlk5UQRTjyoKX5YN2zlc2632xcSlSFBao5yvbMWTpv9SNolOZlZmUlcDOHuszPLItbKrvcXnnZA=="], "tw-animate-css": ["tw-animate-css@1.3.3", "", {}, "sha512-tXE2TRWrskc4TU3RDd7T8n8Np/wCfoeH9gz22c7PzYqNPQ9FBGFbWWzwL0JyHcFp+jHozmF76tbHfPAx22ua2Q=="], @@ -2468,7 +2468,6 @@ "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], - "okcodes/@pierre/diffs": ["@pierre/diffs@1.1.8", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-eS9cardFDJ9B8Z2V+c2YIcPphkS/aY4TA4o8xgItcXEX+Bq2vZ/3BKGlY4r4BdfSLvo+VHenI2HhOSl8Ax1jgg=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], diff --git a/package.json b/package.json index d34a8da5..784ae995 100644 --- a/package.json +++ b/package.json @@ -66,16 +66,16 @@ "prepare": "husky && node scripts/patch-effect-language-service.ts && node scripts/patch-effect-smol-peer-installs.mjs" }, "dependencies": { - "@pierre/diffs": "1.1.0-beta.16", - "lucide-react": "^1.7.0" + "@pierre/diffs": "1.1.13", + "lucide-react": "^1.8.0" }, "devDependencies": { "@types/node": "catalog:", "husky": "^9.1.7", "lint-staged": "^16.4.0", - "oxfmt": "^0.42.0", - "oxlint": "^1.58.0", - "turbo": "^2.9.4", + "oxfmt": "^0.44.0", + "oxlint": "^1.59.0", + "turbo": "^2.9.5", "vitest": "catalog:" }, "overrides": { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index d533f0f6..0c7d0563 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -70,7 +70,14 @@ import type { GitHubPostCommentInput, GitHubPostCommentResult, } from "./github"; -import type { ServerConfig, TestOpenclawGatewayInput, TestOpenclawGatewayResult } from "./server"; +import type { + OpenclawGatewayConfigSummary, + ResetOpenclawGatewayDeviceStateInput, + SaveOpenclawGatewayConfigInput, + ServerConfig, + TestOpenclawGatewayInput, + TestOpenclawGatewayResult, +} from "./server"; import type { GlobalEnvironmentVariablesResult, ProjectEnvironmentVariablesInput, @@ -450,6 +457,13 @@ export interface NativeApi { input: SaveProjectEnvironmentVariablesInput, ) => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; + getOpenclawGatewayConfig: () => Promise; + saveOpenclawGatewayConfig: ( + input: SaveOpenclawGatewayConfigInput, + ) => Promise; + resetOpenclawGatewayDeviceState: ( + input?: ResetOpenclawGatewayDeviceStateInput, + ) => Promise; testOpenclawGateway: (input: TestOpenclawGatewayInput) => Promise; }; orchestration: { diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 8e7efd10..64c5f68a 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -25,6 +25,8 @@ const RuntimeEventRawSource = Schema.Literals([ "claude.sdk.permission", "codex.sdk.thread-event", "openclaw.gateway.notification", + "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 e436de05..5ceae98f 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -135,10 +135,37 @@ 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; + // ── OpenClaw Gateway Test ─────────────────────────────────────────── export const TestOpenclawGatewayInput = Schema.Struct({ - gatewayUrl: Schema.String, + gatewayUrl: Schema.optional(Schema.String), password: Schema.optional(Schema.String), }); export type TestOpenclawGatewayInput = typeof TestOpenclawGatewayInput.Type; @@ -176,6 +203,11 @@ export const TestOpenclawGatewayDiagnostics = Schema.Struct({ 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), }); diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 185d03a0..a0687800 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -70,7 +70,9 @@ import { ProjectFileTreeChangedPayload } from "./project"; import { OpenInEditorInput, OpenPathInput } from "./editor"; import { GeneratePairingLinkInput, + ResetOpenclawGatewayDeviceStateInput, RevokeTokenInput, + SaveOpenclawGatewayConfigInput, ServerConfigUpdatedPayload, TestOpenclawGatewayInput, } from "./server"; @@ -192,6 +194,9 @@ 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", @@ -354,6 +359,12 @@ 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),