From 8c7e0e1ffd40d7424d444f42d03c4b71ab9bc2dd Mon Sep 17 00:00:00 2001 From: KrasimirKralev <263465593+KrasimirKralev@users.noreply.github.com> Date: Mon, 18 May 2026 15:19:26 +0300 Subject: [PATCH 1/3] chore(release): bump version to 3.0.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9037f5d4e..5aff5b1e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawbox-setup", - "version": "2.2.2", + "version": "3.0.3", "private": true, "description": "ClawBox setup wizard and dashboard", "scripts": { From bbf235fdc2a68822da96e7f16dcf436c407e7034 Mon Sep 17 00:00:00 2001 From: Krasimir Kralev <263465593+KrasimirKralev@users.noreply.github.com> Date: Tue, 19 May 2026 16:49:10 +0300 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20v3.0.4=20=E2=80=94=20VNC=20UTF-8=20?= =?UTF-8?q?clipboard=20+=20ClawKeep=20pair=20UX=20+=20bug=20fixes=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: refresh model catalog when provider gets configured Symptom: a device's AI picker shows only 1 (or 0) models for a provider the user just configured. Example from a live device that added Anthropic credentials after first boot: models.providers.anthropic = {} catalog cache: 1 anthropic model (a default placeholder) picker: only the placeholder selectable chat: silently falls back to ClawBox AI / deepseek Root cause: the catalog refresh job only runs at clawbox-setup boot warmup. When the user adds an API key after boot (the normal path — the wizard prompts for credentials hours/days after the device first comes online), no refresh fires for that provider. The catalog stays on whatever the pre-auth snapshot found, which for Anthropic is the single fallback model. ClawBox AI gets configured as the fallback at the same time and silently absorbs every Claude-selected chat request. Fix: trigger a single-provider catalog refresh at the end of configure/route.ts, right before the gateway restart. The refresh runs out-of-band (single-flight guarded, no await), so the configure response doesn't slow down. Once the refresh completes (seconds to a couple minutes on Jetson) the live model list is written to the disk cache and the picker sees it on next open. Implementation: - Export `refreshInBackground` from `catalog/route.ts` (was already designed for fire-and-forget use; just needed the export). - Import it from `configure/route.ts` and call after the existing step 8b (anthropic-plugin gating) and before step 9 (gateway restart). Map `ocProvider === "deepseek"` → `clawai` because the catalog uses the user-facing label, not the openclaw provider id. Gate on `isCatalogProvider()` so local-only providers (llamacpp) don't trip the refresh. This means the `models.providers.

` block in openclaw.json (and the picker UI that reads from it) reflects whatever the upstream provider actually exposes — no hard-coded model lists ever, not even temporarily. Verified on a live device: re-saving Anthropic now triggers a `[catalog] refreshed anthropic: models` log line within seconds of the configure call returning, and the picker subsequently shows all available Claude variants instead of the single boot-time placeholder. * feat(picker): merge static catalog into live + filter deprecated Claude IDs + show dropdown for allowCustom providers Three picker-experience fixes uncovered on a device whose Claude.ai OAuth scope only enumerates `claude-sonnet-4-20250514` (a model Anthropic has scheduled for retirement on 2026-06-15). ## 1. Merge static PROVIDER_CATALOGS into the live catalog response When `openclaw models list --provider anthropic` returns just one model — which is what Claude.ai consumer OAuth scopes do today — the picker had nothing to offer. The curated list in `src/lib/provider-models.ts` already tracks the current Anthropic lineup (Opus 4.7, Sonnet 4.6, Haiku 4.5 per https://platform.claude.com/docs/en/about-claude/models), so we now union the live response with the static list. Live entries take precedence (their `contextWindow`, `input`, and `label` reflect what the gateway negotiated); ids in the static list but not the live response get appended as fallback entries with `contextWindow` from a small known-good lookup table. Net effect: the picker always shows the curated current lineup, plus anything extra the upstream actually exposes. ## 2. DEPRECATED_MODEL_IDS denylist Anthropic's docs list `claude-sonnet-4-20250514` and `claude-opus-4-20250514` as retiring on 2026-06-15. The openclaw plugin does not tag them as deprecated (so the existing `entry.tags?.includes("deprecated")` filter doesn't catch them), which means a fresh device hitting Claude.ai OAuth gets these as the *only* selectable Claude model — i.e. the picker silently defaults users to a soon-to-be-dead model. Hardcode the two known-retired ids in a denylist that filters them out at catalog transform time. When Anthropic publishes the next deprecation notice, add the new ids to this set. ## 3. Show dropdown for `allowCustom: true` providers with only 1 model `ChatPopup.tsx` previously hid the model dropdown when `catalog.models.length < 2`. With Anthropic's single-model OAuth catalog and `allowCustom: true`, the user had no UI affordance to switch models at all — even though typing a custom id was the documented escape hatch. Relax the gate: show the dropdown when the catalog has multiple models OR allows custom ids. ## Verified on a live device - `openclaw models list --provider anthropic --all --json` returns the single deprecated model. - `/setup-api/ai-models/catalog?provider=anthropic` now returns the three curated current Claude models (Opus 4.7 / Sonnet 4.6 / Haiku 4.5) and the disk cache at `data/catalog-cache/anthropic.json` is consistent. - The chat picker shows the dropdown with those three entries. The deprecated `claude-sonnet-4-20250514` is no longer reachable from the curated list (still typeable as a custom id if a user really needs it before the 2026-06-15 retirement, since `allowCustom: true` is preserved). * fix(remote-control): keep polling tunnel status so the URL appears without a manual refresh The Cloudflare Quick Tunnel panel exited its poll loop as soon as the service entered the 'active' state without a URL yet — leaving the 'Negotiating tunnel with Cloudflare...' spinner up until the user hit Refresh by hand. Restructure the loop to always reschedule with an adaptive cadence (2s while negotiating, 15s when settled) so the URL appears the moment cloudflared prints it. * feat(clawkeep): redesign pair card to match the ClawAI subscription panel - Replace the auto-window.open with an explicit 'Open authorization page' button so the device code is visible before focus shifts to the portal tab. - Add a 'Get a new code' affordance for when the code expires or the user wants to retry against a different account. - Reuse the existing 'ai.openAuthPage' translation key instead of a near-duplicate; remove four pair-flow keys (enterCode, reopenPortal, typeCodeOnPortal, waitingApproval) that the redesigned card no longer references, in all 10 locales. * feat(vnc): bidirectional UTF-8 clipboard via xclip with toast fallback Both copy directions used the basic RFB ClientCutText path, which is Latin-1 only — Cyrillic / CJK / emoji round-tripped as mojibake. Route both directions through xclip on the guest X CLIPBOARD instead, since xclip handles UTF-8 natively. - New /setup-api/vnc/clipboard route: GET reads the guest CLIPBOARD, POST writes it. xclip's write daemon-forks to keep the selection alive, so the write side uses 'ignore' stdio to avoid the parent hanging on inherited pipes. - Paste modal posts text to the route, then sends Ctrl+V over RFB so the focused field inside Chromium pastes from the freshly-updated CLIPBOARD. - Copy uses noVNC's 'clipboard' event as a change signal only — the Latin-1 payload is ignored; we GET the real UTF-8 text via xclip. An in-flight guard + pending flag collapses rapid copy stampedes. - On insecure HTTP origins where navigator.clipboard is gated, a toast with a manual 'Copy' button uses the shared copyToClipboard helper (execCommand fallback inside a user gesture). - One new translation key, 'vnc.copyToast.fromRemote', in 10 locales. Note: requires 'xclip' installed on the device (apt install xclip). * fix(clawai-tier): preserve last-known tier on transient failure The 'You are on the free plan' modal was popping up every time the network blipped or the gateway WebSocket reconnected. Cause: the useClawboxLogin poll cleared loggedIn + tier to null on any non-2xx response or fetch throw, which TierUpgradeCelebration then detected as a downgrade (currentRank=0 while seenRank>0). The server-side /status route already has portal-failure resilience (it caches the last-known tier with TTLs), so a 2xx response is the authoritative signal — anything else is 'I don't know right now', not 'you've been downgraded'. - On transient failure: return the previous state ref so React bails out and downstream consumers don't re-render every 30s on a flaky network. - On 2xx: keep trusting the server response as before. A confirmed Paid → Free transition still fires the modal exactly once. * chore(release): bump version to 3.0.4 * fix: address CodeRabbit review on #139 - VNC clipboard route: cap xclip stdout buffering at MAX_CLIPBOARD_BYTES and SIGTERM the child when exceeded, returning HTTP 413 — prevents a giant guest selection from spiking Jetson RAM during the JSON hop. - VNC clipboard route: reject oversized POST bodies via Content-Length pre-check, before request.json() buffers the whole payload. - ClawKeep PairChallengeCard: mark the phase indicator with role=status + aria-live=polite, hide the decorative spinner with aria-hidden, so assistive tech announces waiting/configuring transitions. - VNCApp sendPaste: add a pasteBusy early-return guard and move setPasteBusy(false) into a finally — kills the duplicate-paste race on rapid Ctrl+Enter / double-click. - VNCApp copy toast: wrap the remote-clipboard toast in role=status + aria-live=polite + aria-atomic=true so screen readers announce that remote text is available without focus motion. --- package-lock.json | 6 +- package.json | 2 +- src/app/setup-api/ai-models/catalog/route.ts | 76 ++++- .../setup-api/ai-models/configure/route.ts | 22 +- src/app/setup-api/vnc/clipboard/route.ts | 171 +++++++++++ src/components/ChatPopup.tsx | 8 +- src/components/ClawKeepApp.tsx | 104 ++++--- src/components/RemoteControlPanel.tsx | 17 +- src/components/VNCApp.tsx | 266 ++++++++++++++++-- src/lib/clawkeep-translations.ts | 90 +++--- src/lib/desktop-translations-part1.ts | 3 + src/lib/desktop-translations-part2.ts | 3 + src/lib/desktop-translations-part3.ts | 3 + src/lib/desktop-translations.ts | 1 + src/lib/use-clawbox-login.ts | 26 +- 15 files changed, 670 insertions(+), 128 deletions(-) create mode 100644 src/app/setup-api/vnc/clipboard/route.ts diff --git a/package-lock.json b/package-lock.json index 584e3e3a7..46734f95c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2108,7 +2108,7 @@ } }, "node_modules/braces": { - "version": "3.0.3", + "version": "3.0.4", "dev": true, "license": "MIT", "dependencies": { @@ -3202,7 +3202,7 @@ } }, "node_modules/estree-walker": { - "version": "3.0.3", + "version": "3.0.4", "dev": true, "license": "MIT", "dependencies": { @@ -6103,7 +6103,7 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", + "version": "3.0.4", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 5aff5b1e0..383816769 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawbox-setup", - "version": "3.0.3", + "version": "3.0.4", "private": true, "description": "ClawBox setup wizard and dashboard", "scripts": { diff --git a/src/app/setup-api/ai-models/catalog/route.ts b/src/app/setup-api/ai-models/catalog/route.ts index 0bf210da0..720179dee 100644 --- a/src/app/setup-api/ai-models/catalog/route.ts +++ b/src/app/setup-api/ai-models/catalog/route.ts @@ -4,7 +4,7 @@ import { promises as fsp } from "fs"; import path from "path"; import { findOpenclawBin } from "@/lib/openclaw-config"; import { DATA_DIR } from "@/lib/config-store"; -import { CATALOG_PROVIDERS, isCatalogProvider } from "@/lib/provider-models"; +import { CATALOG_PROVIDERS, isCatalogProvider, PROVIDER_CATALOGS } from "@/lib/provider-models"; export const dynamic = "force-dynamic"; @@ -148,6 +148,21 @@ interface OpenRouterListResponse { }>; } +// Deprecated model ids we filter out of the catalog regardless of +// whether the upstream tagged them as such. Anthropic's +// `openclaw models list --provider anthropic` returns +// `claude-sonnet-4-20250514` without a `deprecated` tag on +// Claude.ai OAuth scopes, but Anthropic's own docs +// (https://platform.claude.com/docs/en/about-claude/models) list +// it (and the matching opus-4 snapshot) as retiring 2026-06-15. +// Surfacing them in the picker just sets users up to pick a model +// that will stop working. Add new ids here when Anthropic ships +// the next deprecation notice. +const DEPRECATED_MODEL_IDS: ReadonlySet = new Set([ + "claude-sonnet-4-20250514", + "claude-opus-4-20250514", +]); + // Per-provider allowlist regex. When set, only model ids matching the // pattern survive the catalog filter. Used to curate noisy upstream // catalogs down to a useful set without the picker exploding to 40+ @@ -199,6 +214,7 @@ function transformOpenclawEntries( : entry.key; if (!id) continue; if (entry.tags?.includes("deprecated")) continue; + if (DEPRECATED_MODEL_IDS.has(id)) continue; if (allowed && !allowed.test(id)) continue; out.push({ id, @@ -240,15 +256,60 @@ const ALLOW_CUSTOM_BY_PROVIDER: Record = { clawai: false, }; +// Context-window lookup for static-catalog entries we know about but the +// live upstream enumeration didn't return. Values from each provider's +// official model docs. Used only as a fallback for `augmentWithStaticCatalog`; +// when the live catalog includes the same id its real contextWindow wins. +const STATIC_MODEL_CONTEXT_WINDOWS: Record = { + // Anthropic — https://platform.claude.com/docs/en/about-claude/models + "claude-opus-4-7": 1_000_000, + "claude-opus-4-6": 1_000_000, + "claude-sonnet-4-6": 1_000_000, + "claude-sonnet-4-5": 200_000, + "claude-opus-4-5": 200_000, + "claude-haiku-4-5": 200_000, +}; + +// Merge the curated static list from PROVIDER_CATALOGS into the live +// upstream models. Live entries take precedence (their contextWindow / +// input / label reflect what the gateway actually negotiated). Static +// entries with ids the live list doesn't include get appended — this +// covers the case where a provider's OAuth scope returns a single +// deprecated model (Anthropic Claude.ai consumer OAuth is the live +// example: 2025-11 docs list claude-opus-4-7 / claude-sonnet-4-6 / +// claude-haiku-4-5 as current, but `openclaw models list --provider +// anthropic` on a Claude.ai-OAuth device only returns the deprecated +// claude-sonnet-4-20250514). The picker would otherwise force the +// user to type custom model ids by hand. +function augmentWithStaticCatalog(provider: string, live: CatalogModel[]): CatalogModel[] { + if (!isCatalogProvider(provider)) return live; + const staticEntry = PROVIDER_CATALOGS[provider]; + if (!staticEntry) return live; + const liveIds = new Set(live.map((m) => m.id)); + const augmented: CatalogModel[] = [...live]; + for (const sm of staticEntry.models) { + if (liveIds.has(sm.id)) continue; + augmented.push({ + id: sm.id, + label: sm.label, + contextWindow: STATIC_MODEL_CONTEXT_WINDOWS[sm.id] ?? 200_000, + hint: sm.hint, + }); + } + augmented.sort(compareCatalogModels); + return augmented; +} + function buildPayload(provider: string, models: CatalogModel[]): CatalogResponse { + const merged = augmentWithStaticCatalog(provider, models); const fallbackDefault = DEFAULT_MODEL_BY_PROVIDER[provider]; - const defaultModelId = models.find((m) => m.id === fallbackDefault)?.id - ?? models[0]?.id + const defaultModelId = merged.find((m) => m.id === fallbackDefault)?.id + ?? merged[0]?.id ?? fallbackDefault ?? ""; return { provider, - models, + models: merged, defaultModelId, allowCustom: ALLOW_CUSTOM_BY_PROVIDER[provider] ?? true, fetchedAt: Date.now(), @@ -330,8 +391,11 @@ async function fetchOpenRouterCatalog(): Promise { // Refresh the catalog for `provider` in the background. Returns // immediately; the actual openclaw spawn / openrouter fetch runs out // of band. Single-flight via `refreshing` so concurrent requests -// collapse to one fork. -function refreshInBackground(provider: string): void { +// collapse to one fork. Exported so configure/route.ts can trigger a +// refresh right after the user adds an API key — otherwise the +// catalog stays on the pre-auth snapshot from boot warmup until the +// next service restart. +export function refreshInBackground(provider: string): void { if (refreshing.has(provider)) return; refreshing.add(provider); diff --git a/src/app/setup-api/ai-models/configure/route.ts b/src/app/setup-api/ai-models/configure/route.ts index 25246f1b1..df2594e4b 100644 --- a/src/app/setup-api/ai-models/configure/route.ts +++ b/src/app/setup-api/ai-models/configure/route.ts @@ -35,7 +35,8 @@ import { type ClawboxAiTier, } from "@/lib/clawbox-ai-models"; import { OPENROUTER_CURATED_MODELS, OPENROUTER_DEFAULT_MODEL_ID } from "@/lib/openrouter-models"; -import { isValidModelId } from "@/lib/provider-models"; +import { isValidModelId, isCatalogProvider } from "@/lib/provider-models"; +import { refreshInBackground as refreshCatalogInBackground } from "@/app/setup-api/ai-models/catalog/route"; const OPENCLAW_BIN = findOpenclawBin(); const OPENCLAW_HOME_DIR = @@ -839,6 +840,25 @@ export async function POST(request: Request) { await setProviderPlugins(primaryProvider); } + // 8c. Kick off a catalog refresh for the just-configured provider so + // the picker shows the full live model list instead of whatever + // the boot-time warmup found before the user added their API key. + // Without this, a device that adds Anthropic / OpenAI / etc. credentials + // after first boot stays stuck on the pre-auth snapshot — which is + // often a single fallback entry or empty — until the next service + // restart. The refresh runs out-of-band; we don't await it. Single- + // flight guarded inside refreshInBackground, so concurrent configure + // calls collapse to one openclaw fork. + // + // `ocProvider` is the openclaw-side provider id (e.g. "anthropic", + // "openai", "openai-codex", "google", "deepseek"). The catalog uses + // "clawai" for ClawBox AI rather than "deepseek", so map that case. + // Skip providers that aren't part of the catalog (local-only, llamacpp). + const catalogProvider = ocProvider === "deepseek" ? "clawai" : ocProvider; + if (isCatalogProvider(catalogProvider)) { + refreshCatalogInBackground(catalogProvider); + } + // 9. Restart OpenClaw gateway so it picks up the new auth profile and model try { await restartGateway(); diff --git a/src/app/setup-api/vnc/clipboard/route.ts b/src/app/setup-api/vnc/clipboard/route.ts new file mode 100644 index 000000000..efd64d50f --- /dev/null +++ b/src/app/setup-api/vnc/clipboard/route.ts @@ -0,0 +1,171 @@ +export const dynamic = "force-dynamic"; + +import { NextRequest, NextResponse } from "next/server"; +import { spawn } from "child_process"; +import { promises as fs } from "fs"; +import os from "os"; +import path from "path"; + +// Bridges the host browser's clipboard with the guest X CLIPBOARD via +// `xclip`. Avoids the RFB ClientCutText Latin-1 limitation that mangles +// Cyrillic / CJK / emoji on the basic noVNC paste path, and works over +// plain HTTP (no `navigator.clipboard.readText()` permission required — +// the textarea in the paste modal captures the text natively). +const MAX_CLIPBOARD_BYTES = 1_048_576; +const XCLIP_TIMEOUT_MS = 5_000; + +async function getVncDisplay(): Promise { + if (process.env.CLAWBOX_VNC_DISPLAY) return process.env.CLAWBOX_VNC_DISPLAY; + try { + const envFile = path.join(os.homedir(), ".cache", "clawbox", "vnc-display.env"); + const raw = await fs.readFile(envFile, "utf8"); + const match = raw.match(/CLAWBOX_VNC_DISPLAY=(:\d+)/); + if (match) return match[1]; + } catch { + // No marker yet — fall through to the default x11vnc display. + } + return ":99"; +} + +interface XclipResult { + stdout: string; + stderr: string; + code: number; +} + +function runXclip(args: string[], display: string, input?: string): Promise { + return new Promise((resolve, reject) => { + const isWrite = input !== undefined; + // For writes, xclip forks a daemon that keeps owning the X selection + // until another client claims it — if Node inherits its stdout/stderr, + // the `close` event never fires (the daemon holds those pipes open). + // Detach those streams so we observe only the parent's exit. + const proc = spawn("xclip", args, { + env: { ...process.env, DISPLAY: display }, + stdio: isWrite ? ["pipe", "ignore", "ignore"] : ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let stdoutBytes = 0; + let outputTooLarge = false; + let settled = false; + const settle = (result: XclipResult) => { + if (settled) return; + settled = true; + resolve(result); + }; + const timer = setTimeout(() => { + if (settled) return; + proc.kill("SIGTERM"); + settle({ stdout, stderr: `${stderr}\n[xclip] timed out`, code: -1 }); + }, XCLIP_TIMEOUT_MS); + if (proc.stdout) { + // Cap the buffer so a giant guest selection can't spike Jetson RAM + // and force a 1 MiB+ JSON serialisation hop into the browser. + proc.stdout.on("data", (d: Buffer) => { + stdoutBytes += d.byteLength; + if (stdoutBytes > MAX_CLIPBOARD_BYTES) { + outputTooLarge = true; + proc.kill("SIGTERM"); + return; + } + stdout += d.toString("utf8"); + }); + } + if (proc.stderr) { + proc.stderr.on("data", (d: Buffer) => { stderr += d.toString("utf8"); }); + } + proc.on("error", (err) => { + clearTimeout(timer); + if (settled) return; + settled = true; + reject(err); + }); + proc.on("close", (code) => { + clearTimeout(timer); + settle({ + stdout, + stderr: outputTooLarge ? `${stderr}\n[xclip] clipboard exceeds 1 MiB cap` : stderr, + code: outputTooLarge ? 413 : (code ?? -1), + }); + }); + if (isWrite && proc.stdin) { + proc.stdin.end(input, "utf8"); + } + }); +} + +export async function GET() { + try { + const display = await getVncDisplay(); + const { stdout, code, stderr } = await runXclip( + ["-selection", "clipboard", "-out"], + display, + ); + if (code !== 0) { + // xclip returns non-zero when the selection is empty; treat that as + // an empty string rather than an error so the UI can render "(empty)". + const isEmpty = stderr.includes("There is no owner"); + if (isEmpty) { + return NextResponse.json({ text: "" }, { headers: { "Cache-Control": "no-store" } }); + } + return NextResponse.json( + { error: `xclip exit ${code}: ${stderr.slice(0, 200)}` }, + { status: 500, headers: { "Cache-Control": "no-store" } }, + ); + } + return NextResponse.json({ text: stdout }, { headers: { "Cache-Control": "no-store" } }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "xclip read failed" }, + { status: 500 }, + ); + } +} + +export async function POST(request: NextRequest) { + // Reject oversized requests before buffering — on the Jetson, an attacker + // (or just a paste-happy user) could otherwise force the device to read + // a multi-MB body into RAM only to 413 it after the fact. +32 bytes is + // headroom for the `{"text":"..."}` JSON wrapper. + const contentLength = Number(request.headers.get("content-length") ?? "0"); + if (Number.isFinite(contentLength) && contentLength > MAX_CLIPBOARD_BYTES + 32) { + return NextResponse.json({ error: "text exceeds 1 MiB cap" }, { status: 413 }); + } + + let parsed: unknown; + try { + parsed = await request.json(); + } catch { + return NextResponse.json({ error: "request body must be JSON" }, { status: 400 }); + } + const body = (parsed ?? {}) as { text?: unknown }; + const text = typeof body.text === "string" ? body.text : ""; + if (text.length === 0) { + return NextResponse.json({ error: "text required" }, { status: 400 }); + } + if (Buffer.byteLength(text, "utf8") > MAX_CLIPBOARD_BYTES) { + return NextResponse.json({ error: "text exceeds 1 MiB cap" }, { status: 413 }); + } + + try { + const display = await getVncDisplay(); + const { code, stderr } = await runXclip( + ["-selection", "clipboard", "-in"], + display, + text, + ); + if (code !== 0) { + return NextResponse.json( + { error: `xclip exit ${code}: ${stderr.slice(0, 200)}` }, + { status: 500 }, + ); + } + return NextResponse.json({ ok: true }, { headers: { "Cache-Control": "no-store" } }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "xclip write failed" }, + { status: 500 }, + ); + } +} diff --git a/src/components/ChatPopup.tsx b/src/components/ChatPopup.tsx index 712c49342..cec16046a 100644 --- a/src/components/ChatPopup.tsx +++ b/src/components/ChatPopup.tsx @@ -1356,7 +1356,13 @@ function ChatPopup({ isOpen, onClose, onOpenFull, onOpenSettingsSection, onThink ) if (!activeOption?.provider) return null const catalog = chatProviderCatalog - if (!catalog || catalog.models.length < 2) return null + if (!catalog) return null + // Show the dropdown when there are multiple models to pick OR + // the catalog permits a custom model id (then the picker + // surfaces a "type your own" affordance, which is the only way + // to switch to a different Claude variant when Anthropic's + // OAuth scope returned a single canonical model). + if (catalog.models.length < 2 && !catalog.allowCustom) return null // ClawBox AI's wire-format provider is `deepseek` (Mike's // gateway forwards to DeepSeek's API), while the UI normalizes // to `clawai`. Try the canonical provider first, then fall diff --git a/src/components/ClawKeepApp.tsx b/src/components/ClawKeepApp.tsx index 755af2448..03de6420a 100644 --- a/src/components/ClawKeepApp.tsx +++ b/src/components/ClawKeepApp.tsx @@ -276,7 +276,9 @@ export default function ClawKeepApp() { ); setPairChallenge(start); setPairPhase("pending"); - window.open(start.verification_url, "_blank", "noopener,noreferrer"); + // No auto-open — the modal shows the code + an "Open authorization + // page" button so the user reads the code before focus shifts to the + // portal tab. Mirrors the ClawAI subscription-tab UX. } catch (e) { setError((e as Error).message); } finally { @@ -492,6 +494,8 @@ export default function ClawKeepApp() { challenge={pairChallenge} phase={pairPhase} onCancel={onCancelPair} + onGetNewCode={onPair} + busy={busy === "pair"} /> ) : status.paired ? ( <> @@ -918,10 +922,14 @@ function PairChallengeCard({ challenge, phase, onCancel, + onGetNewCode, + busy, }: { challenge: PairStartResponse; phase: "" | "pending" | "configuring"; onCancel: () => void; + onGetNewCode: () => void; + busy: boolean; }) { const { t } = useT(); const code = challenge.user_code; @@ -956,54 +964,80 @@ function PairChallengeCard({ }, [code, flashCopied]); return ( -

-

- {phase === "configuring" - ? t("clawkeep.pair.configuring") - : t("clawkeep.pair.enterCode")} -

-
- - {code} - - -
-

- {t("clawkeep.pair.typeCodeOnPortal")} +

+

+ {t("clawkeep.pair.intro")}

-
+
- {t("clawkeep.pair.reopenPortal")} + {t("ai.openAuthPage")} + +

+ {t("clawkeep.pair.thenEnterCode")} +

+
+ + {code} + + +
+

+ {t("clawkeep.pair.codeExpires")} +

+
+ + {phase && ( +
+
+ )} + +
+ ·
-

- {phase === "configuring" - ? t("clawkeep.pair.savingToken") - : t("clawkeep.pair.waitingApproval")} -

); } diff --git a/src/components/RemoteControlPanel.tsx b/src/components/RemoteControlPanel.tsx index 34baf02bd..47e88acbc 100644 --- a/src/components/RemoteControlPanel.tsx +++ b/src/components/RemoteControlPanel.tsx @@ -64,17 +64,22 @@ export default function RemoteControlPanel() { let alive = true; let timer: ReturnType | null = null; + // Continuous loop: always reschedule, just adjust cadence by current + // state. 2 s while the tunnel is mid-startup (active/activating with + // no URL yet), 15 s otherwise (URL is showing, OR the tunnel is in a + // terminal non-running state where nothing will change without user + // action). The previous version exited polling whenever the service + // wasn't already active — so if you opened Remote Access *before* + // clicking Start, the loop stopped, then clicking Start transitioned + // the service to active+no-URL without re-arming polling, and the + // URL never appeared in the UI until manual page reload. const loop = async () => { const s = await fetchStatus(); if (!alive) return; setLoading(false); const svc = s?.tunnel.service; - const needsPoll = !s?.tunnel.url && (svc === "active" || svc === "activating"); - if (needsPoll) { - timer = setTimeout(loop, POLL_INTERVAL_MS); - } else if (svc === "active") { - timer = setTimeout(loop, 15_000); - } + const stillNegotiating = !s?.tunnel.url && (svc === "active" || svc === "activating"); + timer = setTimeout(loop, stillNegotiating ? POLL_INTERVAL_MS : 15_000); }; loop(); return () => { alive = false; if (timer) clearTimeout(timer); }; diff --git a/src/components/VNCApp.tsx b/src/components/VNCApp.tsx index 9dc2a3d9e..0085bfc4e 100644 --- a/src/components/VNCApp.tsx +++ b/src/components/VNCApp.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { useT } from "@/lib/i18n"; import { getTrackedVncKey, type TrackedKey } from "@/lib/vnc-keys"; +import { copyToClipboard } from "@/lib/clipboard"; type ConnectionStatus = "connecting" | "connected" | "disconnected" | "error"; @@ -40,12 +41,36 @@ export default function VNCApp() { const [repairError, setRepairError] = useState(null); const [pasteOpen, setPasteOpen] = useState(false); const [pasteText, setPasteText] = useState(""); + const [pasteBusy, setPasteBusy] = useState(false); + const [pasteError, setPasteError] = useState(null); const pasteTextareaRef = useRef(null); // Mirrors pasteOpen so the focus/keyboard handlers (which live in effect // closures and can't see React state updates directly) can short-circuit // when the paste modal is open. const pasteOpenRef = useRef(false); + // Toast surfaced when the guest copies but the host clipboard API was + // blocked (HTTP origin, no permission). Lets the user click "Copy" with + // a real user gesture so `document.execCommand('copy')` succeeds. + const [copyToast, setCopyToast] = useState<{ text: string; copied: boolean } | null>(null); + const copyToastTimerRef = useRef(null); + // Guards rapid-fire reads of the guest CLIPBOARD — noVNC emits the + // `clipboard` event on every RFB ServerCutText message, which can stack + // when an X clipboard manager re-asserts ownership. The inflight flag + // collapses duplicate fetches; the `pending` flag remembers that another + // change arrived while we were mid-fetch so we re-fetch exactly once. + const remoteClipboardInflightRef = useRef(false); + const remoteClipboardPendingRef = useRef(false); + // Late-bound refs so the Ctrl+V handler (which lives inside a useEffect + // closure declared before openPasteModal / writeAndPaste exist) can call + // them. Filled in via a `useEffect` further down. + const openPasteModalRef = useRef<(() => void) | null>(null); + const writeAndPasteRef = useRef<((text: string) => Promise) | null>(null); + // Same trick for the noVNC `clipboard` event handler — it fires when the + // guest copies, but the payload is Latin-1-mangled; we use the event as a + // change signal and read the real UTF-8 via xclip on the server. + const fetchRemoteClipboardRef = useRef<(() => Promise) | null>(null); + const checkVnc = useCallback(async () => { try { const res = await fetch("/setup-api/vnc"); @@ -154,16 +179,15 @@ export default function VNCApp() { if (e.detail?.clean === false) setError("Connection lost unexpectedly"); }); - // Remote → local: when the guest copies, push the text into the - // host browser's clipboard so the user can paste it elsewhere. - rfb.addEventListener("clipboard", (e: CustomEvent) => { - const text = e.detail?.text; - if (typeof text !== "string" || !text) return; - if (navigator.clipboard?.writeText) { - navigator.clipboard.writeText(text).catch(() => { - // Host browser blocked the write (permissions / focus). Non-fatal. - }); - } + // Remote → local: when the guest copies, the noVNC `clipboard` + // event delivers a *Latin-1-mangled* payload (basic RFB ClientCutText + // only supports ISO-8859-1, so Cyrillic / CJK / emoji arrive as + // double-byte garbage like "оиÑ"). We ignore that payload and use + // the event purely as a change signal — then GET our own endpoint, + // which reads the *real* UTF-8 selection from the guest X CLIPBOARD + // via xclip. Same trick as the paste direction, in reverse. + rfb.addEventListener("clipboard", () => { + fetchRemoteClipboardRef.current?.(); }); rfbRef.current = rfb; @@ -219,16 +243,16 @@ export default function VNCApp() { // Local → remote: the `paste` event only fires on editable elements, // and the VNC canvas is not editable — so we listen for Ctrl/Cmd+V - // directly. On each shortcut we read the host clipboard via the async - // Clipboard API and push it into the guest's X CLIPBOARD selection via - // RFB (x11vnc turns that message into an X selection). noVNC continues - // to forward the V keystroke itself, so Chromium / terminals on the - // guest see "Ctrl+V pressed" and paste from the freshly-updated CLIPBOARD. + // directly. We push the host clipboard into the guest's X CLIPBOARD + // via the /setup-api/vnc/clipboard endpoint (xclip), then forward + // Ctrl+V to the guest so Chromium pastes from the freshly-updated + // CLIPBOARD. Going through xclip avoids the basic-RFB Latin-1 + // limitation that mangles Cyrillic / CJK / emoji. // - // navigator.clipboard.readText() requires a secure context (HTTPS or - // localhost). On LAN-served HTTP (http://clawbox.local, http://192.168.x.x) - // the browser rejects the read and we silently no-op — the user can still - // paste manually via the guest's context menu. + // navigator.clipboard.readText() requires a secure context. On LAN- + // served HTTP the browser rejects the read — we fall back to opening + // the paste modal so the user can paste into a textarea (which works + // on any origin) and submit from there. const onKeyDown = (event: KeyboardEvent) => { if (pasteOpenRef.current) return; if (!vncFocusedRef.current) return; @@ -238,14 +262,21 @@ export default function VNCApp() { !event.altKey && event.key.toLowerCase() === "v"; if (!isPasteShortcut) return; - if (!navigator.clipboard?.readText) return; + event.preventDefault(); + event.stopPropagation(); + if (!navigator.clipboard?.readText) { + openPasteModalRef.current?.(); + return; + } navigator.clipboard .readText() .then((text) => { - if (text) rfbRef.current?.clipboardPasteFrom(text); + if (!text) return; + void writeAndPasteRef.current?.(text); }) .catch(() => { - // Non-secure origin or clipboard permission denied — silently skip. + // Non-secure origin / permission denied — fall back to the modal. + openPasteModalRef.current?.(); }); }; @@ -438,6 +469,7 @@ export default function VNCApp() { const openPasteModal = useCallback(() => { setPasteText(""); + setPasteError(null); pasteOpenRef.current = true; setPasteOpen(true); // Stop the VNC-input handlers from grabbing focus back onto the canvas @@ -451,19 +483,137 @@ export default function VNCApp() { pasteOpenRef.current = false; setPasteOpen(false); setPasteText(""); + setPasteError(null); focusVncSurface(); }, [focusVncSurface]); - const sendPaste = useCallback(() => { + // Push `text` into the guest's X CLIPBOARD via xclip on the device, then + // send a Ctrl+V keystroke so the focused window inside Chromium pastes + // from the freshly-updated selection. xclip preserves UTF-8 (Cyrillic, + // CJK, emoji); the basic-RFB clientCutText path mangles those. + const writeAndPaste = useCallback(async (text: string): Promise => { + if (!text) return false; + try { + const res = await fetch("/setup-api/vnc/clipboard", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + setPasteError(body.error || `Clipboard write failed (HTTP ${res.status})`); + return false; + } + } catch (err) { + setPasteError(err instanceof Error ? err.message : "Clipboard write failed"); + return false; + } + const rfb = rfbRef.current; + if (!rfb) return false; + // XK_Control_L = 0xffe3, "v" lowercase = 0x76. press → press → release → + // release pattern is what noVNC's own send-ctrl-alt-del helper uses. + rfb.sendKey(0xffe3, "ControlLeft", true); + rfb.sendKey(0x76, "KeyV", true); + rfb.sendKey(0x76, "KeyV", false); + rfb.sendKey(0xffe3, "ControlLeft", false); + return true; + }, []); + + const sendPaste = useCallback(async () => { + // Rapid Ctrl+Enter / double-click can re-enter this callback before the + // `disabled` flag from setPasteBusy lands. Hard-guard so we never double- + // post the clipboard or double-inject Ctrl+V into the focused field. + if (pasteBusy) return; const text = pasteText; - if (text.length > 0) { - rfbRef.current?.clipboardPasteFrom(text); + if (!text) return; + setPasteBusy(true); + setPasteError(null); + let ok = false; + try { + ok = await writeAndPaste(text); + } finally { + setPasteBusy(false); } + if (!ok) return; pasteOpenRef.current = false; setPasteOpen(false); setPasteText(""); focusVncSurface(); - }, [focusVncSurface, pasteText]); + }, [focusVncSurface, pasteBusy, pasteText, writeAndPaste]); + + // Reads the *real* UTF-8 selection from the guest X CLIPBOARD via xclip, + // ignoring the Latin-1-mangled payload that noVNC's `clipboard` event + // delivers. Tries the modern Clipboard API first (HTTPS / localhost), and + // falls back to the manual-copy toast on insecure origins. + const fetchRemoteClipboard = useCallback(async () => { + if (remoteClipboardInflightRef.current) { + // Another fetch is already in flight — record that we need a second + // pass once it resolves so we don't miss the latest selection. + remoteClipboardPendingRef.current = true; + return; + } + remoteClipboardInflightRef.current = true; + try { + let text = ""; + try { + const res = await fetch("/setup-api/vnc/clipboard", { cache: "no-store" }); + if (!res.ok) return; + const body = (await res.json().catch(() => ({}))) as { text?: string }; + text = typeof body.text === "string" ? body.text : ""; + } catch { + return; + } + if (!text) return; + const ok = await copyToClipboard(text); + if (copyToastTimerRef.current) window.clearTimeout(copyToastTimerRef.current); + setCopyToast({ text, copied: ok }); + copyToastTimerRef.current = window.setTimeout(() => setCopyToast(null), ok ? 5_000 : 30_000); + } finally { + remoteClipboardInflightRef.current = false; + if (remoteClipboardPendingRef.current) { + remoteClipboardPendingRef.current = false; + // Re-run on next tick so React's scheduler gets a turn between calls. + setTimeout(() => { void fetchRemoteClipboard(); }, 0); + } + } + }, []); + + // Keep the late-bound refs pointing at the current callbacks so the + // Ctrl+V handler (in a useEffect closure declared earlier) can invoke + // them without forcing the whole effect to re-subscribe. + useEffect(() => { + openPasteModalRef.current = openPasteModal; + writeAndPasteRef.current = writeAndPaste; + fetchRemoteClipboardRef.current = fetchRemoteClipboard; + }, [openPasteModal, writeAndPaste, fetchRemoteClipboard]); + + // Manual-copy fallback for the toast — delegates to the shared helper in + // `src/lib/clipboard.ts` which tries the async Clipboard API first and + // falls back to a hidden-textarea `execCommand('copy')` (works on HTTP + // because we're inside a real user-gesture event handler). + const copyToastTextToHost = useCallback(async () => { + if (!copyToast) return; + const text = copyToast.text; + const success = await copyToClipboard(text); + if (copyToastTimerRef.current) window.clearTimeout(copyToastTimerRef.current); + setCopyToast({ text, copied: success }); + copyToastTimerRef.current = window.setTimeout(() => setCopyToast(null), success ? 3_000 : 8_000); + }, [copyToast]); + + const dismissCopyToast = useCallback(() => { + if (copyToastTimerRef.current) window.clearTimeout(copyToastTimerRef.current); + setCopyToast(null); + }, []); + + // Cancel the toast auto-dismiss timer on unmount so it can't fire on an + // unmounted component (React 18 makes this a no-op, but it's a dangling + // handle either way). + useEffect(() => () => { + if (copyToastTimerRef.current) { + window.clearTimeout(copyToastTimerRef.current); + copyToastTimerRef.current = null; + } + }, []); if (status === "error" && !vncInfo) { const repairing = repairState === "repairing"; @@ -564,26 +714,84 @@ export default function VNCApp() { aria-describedby="vnc-paste-dialog-desc" className="w-full px-3 py-2 bg-white/[0.04] border border-white/10 rounded-lg text-sm text-white outline-none focus:border-orange-400/60 focus:bg-white/[0.06] placeholder-white/25 resize-y" /> + {pasteError && ( +

{pasteError}

+ )}
)} + {copyToast && status === "connected" && ( +
+ content_copy +
+

+ {copyToast.copied ? t("copied") : t("vnc.copyToast.fromRemote")} +

+

+ {copyToast.text.slice(0, 120)} + {copyToast.text.length > 120 ? "…" : ""} +

+ {!copyToast.copied && ( +
+ + +
+ )} +
+ {copyToast.copied && ( + + )} +
+ )} {(status === "connecting" || status === "disconnected") && (
{status === "connecting" ? ( diff --git a/src/lib/clawkeep-translations.ts b/src/lib/clawkeep-translations.ts index c53861f4d..fa12dc8a0 100644 --- a/src/lib/clawkeep-translations.ts +++ b/src/lib/clawkeep-translations.ts @@ -42,18 +42,19 @@ export const clawkeepTranslations: Record> = { "clawkeep.pair.description": "Link this OpenClaw to your portal account and we'll mint short-lived R2 credentials so every backup lands in your private prefix.", "clawkeep.pair.button": "Pair with portal", "clawkeep.pair.connecting": "🔗 Connecting…", - "clawkeep.pair.enterCode": "👉 Enter this code", "clawkeep.pair.configuring": "🔄 Configuring…", "clawkeep.pair.codeAriaLabel": "Pairing code", "clawkeep.pair.codeCopied": "Code copied", "clawkeep.pair.copyCode": "Copy code", "clawkeep.pair.copy": "Copy", "clawkeep.pair.copied": "Copied", - "clawkeep.pair.typeCodeOnPortal": "On the portal page that opened, type the code and approve.", - "clawkeep.pair.reopenPortal": "Re-open portal page", "clawkeep.pair.savingToken": "⏳ Saving token…", - "clawkeep.pair.waitingApproval": "🕒 Waiting for approval…", "clawkeep.pair.failed": "Pair failed", + "clawkeep.pair.intro": "Open the ClawBox portal and enter the device code shown below. Your device finishes the handoff once you confirm on the portal.", + "clawkeep.pair.thenEnterCode": "Then enter this code:", + "clawkeep.pair.codeExpires": "Code expires in 15 minutes", + "clawkeep.pair.waitingAuthorization": "Waiting for authorization…", + "clawkeep.pair.getNewCode": "Get a new code", // === Status / dashboard === "clawkeep.status.protected": "You're Protected", @@ -196,18 +197,19 @@ export const clawkeepTranslations: Record> = { "clawkeep.pair.description": "Свържи този OpenClaw с твоя портал акаунт и ще издадем краткотрайни R2 удостоверения, така че всяко резервно копие да попада в твоя личен префикс.", "clawkeep.pair.button": "Сдвояване с портала", "clawkeep.pair.connecting": "🔗 Свързване…", - "clawkeep.pair.enterCode": "👉 Въведи този код", "clawkeep.pair.configuring": "🔄 Конфигуриране…", "clawkeep.pair.codeAriaLabel": "Код за сдвояване", "clawkeep.pair.codeCopied": "Кодът е копиран", "clawkeep.pair.copyCode": "Копирай кода", "clawkeep.pair.copy": "Копирай", "clawkeep.pair.copied": "Копирано", - "clawkeep.pair.typeCodeOnPortal": "На страницата на портала, която се отвори, въведи кода и одобри.", - "clawkeep.pair.reopenPortal": "Отвори отново страницата на портала", "clawkeep.pair.savingToken": "⏳ Запис на токена…", - "clawkeep.pair.waitingApproval": "🕒 Изчакване на одобрение…", "clawkeep.pair.failed": "Сдвояването се провали", + "clawkeep.pair.intro": "Отвори портала на ClawBox и въведи кода на устройството, показан по-долу. Устройството завършва връзката, след като потвърдиш в портала.", + "clawkeep.pair.thenEnterCode": "След това въведи този код:", + "clawkeep.pair.codeExpires": "Кодът изтича след 15 минути", + "clawkeep.pair.waitingAuthorization": "Изчакване на оторизация…", + "clawkeep.pair.getNewCode": "Вземи нов код", "clawkeep.status.protected": "Ти си защитен", "clawkeep.status.protectedSub": "Твоят OpenClaw е в безопасност в облака на ClawBox — конфигурация, агенти, удостоверения, всичко.", "clawkeep.status.lapsed": "Защитата изтече", @@ -334,18 +336,19 @@ export const clawkeepTranslations: Record> = { "clawkeep.pair.description": "Verbinde diesen OpenClaw mit deinem Portal-Konto. Wir stellen kurzlebige R2-Zugangsdaten aus, damit jedes Backup in deinem privaten Prefix landet.", "clawkeep.pair.button": "Mit Portal koppeln", "clawkeep.pair.connecting": "🔗 Verbinde…", - "clawkeep.pair.enterCode": "👉 Diesen Code eingeben", "clawkeep.pair.configuring": "🔄 Konfiguriere…", "clawkeep.pair.codeAriaLabel": "Kopplungscode", "clawkeep.pair.codeCopied": "Code kopiert", "clawkeep.pair.copyCode": "Code kopieren", "clawkeep.pair.copy": "Kopieren", "clawkeep.pair.copied": "Kopiert", - "clawkeep.pair.typeCodeOnPortal": "Gib den Code auf der geöffneten Portalseite ein und bestätige.", - "clawkeep.pair.reopenPortal": "Portalseite erneut öffnen", "clawkeep.pair.savingToken": "⏳ Token wird gespeichert…", - "clawkeep.pair.waitingApproval": "🕒 Warten auf Bestätigung…", "clawkeep.pair.failed": "Kopplung fehlgeschlagen", + "clawkeep.pair.intro": "Öffne das ClawBox-Portal und gib den unten gezeigten Gerätecode ein. Dein Gerät schließt die Übergabe ab, sobald du im Portal bestätigst.", + "clawkeep.pair.thenEnterCode": "Gib dann diesen Code ein:", + "clawkeep.pair.codeExpires": "Code läuft in 15 Minuten ab", + "clawkeep.pair.waitingAuthorization": "Warte auf Autorisierung…", + "clawkeep.pair.getNewCode": "Neuen Code anfordern", "clawkeep.status.protected": "Du bist geschützt", "clawkeep.status.protectedSub": "Dein OpenClaw ist sicher in der ClawBox-Cloud — Konfiguration, Agenten, Anmeldedaten, alles dabei.", "clawkeep.status.lapsed": "Schutz abgelaufen", @@ -472,18 +475,19 @@ export const clawkeepTranslations: Record> = { "clawkeep.pair.description": "Vincula este OpenClaw a tu cuenta del portal y emitiremos credenciales R2 de corta duración para que cada copia llegue a tu prefijo privado.", "clawkeep.pair.button": "Emparejar con el portal", "clawkeep.pair.connecting": "🔗 Conectando…", - "clawkeep.pair.enterCode": "👉 Introduce este código", "clawkeep.pair.configuring": "🔄 Configurando…", "clawkeep.pair.codeAriaLabel": "Código de emparejamiento", "clawkeep.pair.codeCopied": "Código copiado", "clawkeep.pair.copyCode": "Copiar código", "clawkeep.pair.copy": "Copiar", "clawkeep.pair.copied": "Copiado", - "clawkeep.pair.typeCodeOnPortal": "En la página del portal que se abrió, escribe el código y aprueba.", - "clawkeep.pair.reopenPortal": "Volver a abrir la página del portal", "clawkeep.pair.savingToken": "⏳ Guardando token…", - "clawkeep.pair.waitingApproval": "🕒 Esperando aprobación…", "clawkeep.pair.failed": "Emparejamiento fallido", + "clawkeep.pair.intro": "Abre el portal de ClawBox e introduce el código del dispositivo mostrado abajo. Tu dispositivo completa la conexión cuando confirmes en el portal.", + "clawkeep.pair.thenEnterCode": "Luego introduce este código:", + "clawkeep.pair.codeExpires": "El código expira en 15 minutos", + "clawkeep.pair.waitingAuthorization": "Esperando autorización…", + "clawkeep.pair.getNewCode": "Obtener un código nuevo", "clawkeep.status.protected": "Estás protegido", "clawkeep.status.protectedSub": "Tu OpenClaw está a salvo en la nube de ClawBox — configuración, agentes, credenciales, todo.", "clawkeep.status.lapsed": "Protección caducada", @@ -610,18 +614,19 @@ export const clawkeepTranslations: Record> = { "clawkeep.pair.description": "Liez cet OpenClaw à votre compte du portail et nous générerons des identifiants R2 à courte durée de vie pour que chaque sauvegarde atterrisse dans votre préfixe privé.", "clawkeep.pair.button": "Associer au portail", "clawkeep.pair.connecting": "🔗 Connexion…", - "clawkeep.pair.enterCode": "👉 Saisissez ce code", "clawkeep.pair.configuring": "🔄 Configuration…", "clawkeep.pair.codeAriaLabel": "Code d'association", "clawkeep.pair.codeCopied": "Code copié", "clawkeep.pair.copyCode": "Copier le code", "clawkeep.pair.copy": "Copier", "clawkeep.pair.copied": "Copié", - "clawkeep.pair.typeCodeOnPortal": "Sur la page du portail qui s'est ouverte, saisissez le code et approuvez.", - "clawkeep.pair.reopenPortal": "Rouvrir la page du portail", "clawkeep.pair.savingToken": "⏳ Enregistrement du jeton…", - "clawkeep.pair.waitingApproval": "🕒 En attente d'approbation…", "clawkeep.pair.failed": "Échec de l'association", + "clawkeep.pair.intro": "Ouvre le portail ClawBox et saisis le code de l'appareil affiché ci-dessous. Ton appareil termine la liaison une fois que tu confirmes sur le portail.", + "clawkeep.pair.thenEnterCode": "Puis saisis ce code :", + "clawkeep.pair.codeExpires": "Le code expire dans 15 minutes", + "clawkeep.pair.waitingAuthorization": "En attente d'autorisation…", + "clawkeep.pair.getNewCode": "Obtenir un nouveau code", "clawkeep.status.protected": "Vous êtes protégé", "clawkeep.status.protectedSub": "Votre OpenClaw est en sécurité dans le cloud ClawBox — config, agents, identifiants, tout y est.", "clawkeep.status.lapsed": "Protection expirée", @@ -748,18 +753,19 @@ export const clawkeepTranslations: Record> = { "clawkeep.pair.description": "Collega questo OpenClaw al tuo account portale e genereremo credenziali R2 a breve scadenza così ogni backup finisce nel tuo prefisso privato.", "clawkeep.pair.button": "Accoppia al portale", "clawkeep.pair.connecting": "🔗 Connessione…", - "clawkeep.pair.enterCode": "👉 Inserisci questo codice", "clawkeep.pair.configuring": "🔄 Configurazione…", "clawkeep.pair.codeAriaLabel": "Codice di accoppiamento", "clawkeep.pair.codeCopied": "Codice copiato", "clawkeep.pair.copyCode": "Copia codice", "clawkeep.pair.copy": "Copia", "clawkeep.pair.copied": "Copiato", - "clawkeep.pair.typeCodeOnPortal": "Sulla pagina del portale che si è aperta, scrivi il codice e approva.", - "clawkeep.pair.reopenPortal": "Riapri la pagina del portale", "clawkeep.pair.savingToken": "⏳ Salvataggio token…", - "clawkeep.pair.waitingApproval": "🕒 In attesa di approvazione…", "clawkeep.pair.failed": "Accoppiamento fallito", + "clawkeep.pair.intro": "Apri il portale ClawBox e inserisci il codice del dispositivo mostrato qui sotto. Il tuo dispositivo completa il collegamento una volta confermato sul portale.", + "clawkeep.pair.thenEnterCode": "Poi inserisci questo codice:", + "clawkeep.pair.codeExpires": "Il codice scade tra 15 minuti", + "clawkeep.pair.waitingAuthorization": "In attesa di autorizzazione…", + "clawkeep.pair.getNewCode": "Ottieni un nuovo codice", "clawkeep.status.protected": "Sei protetto", "clawkeep.status.protectedSub": "Il tuo OpenClaw è al sicuro nel cloud ClawBox — configurazione, agenti, credenziali, tutto.", "clawkeep.status.lapsed": "Protezione scaduta", @@ -886,18 +892,19 @@ export const clawkeepTranslations: Record> = { "clawkeep.pair.description": "この OpenClaw をポータルアカウントに紐付けると、各バックアップが専用プレフィックスに届くよう短期 R2 認証情報を発行します。", "clawkeep.pair.button": "ポータルとペアリング", "clawkeep.pair.connecting": "🔗 接続中…", - "clawkeep.pair.enterCode": "👉 このコードを入力", "clawkeep.pair.configuring": "🔄 構成中…", "clawkeep.pair.codeAriaLabel": "ペアリングコード", "clawkeep.pair.codeCopied": "コードをコピーしました", "clawkeep.pair.copyCode": "コードをコピー", "clawkeep.pair.copy": "コピー", "clawkeep.pair.copied": "コピー済み", - "clawkeep.pair.typeCodeOnPortal": "開いたポータルページでコードを入力し承認してください。", - "clawkeep.pair.reopenPortal": "ポータルページを再度開く", "clawkeep.pair.savingToken": "⏳ トークン保存中…", - "clawkeep.pair.waitingApproval": "🕒 承認待ち…", "clawkeep.pair.failed": "ペアリングに失敗しました", + "clawkeep.pair.intro": "ClawBox ポータルを開き、下に表示されているデバイスコードを入力してください。ポータルで確認するとデバイスのハンドオフが完了します。", + "clawkeep.pair.thenEnterCode": "次にこのコードを入力:", + "clawkeep.pair.codeExpires": "コードは 15 分で期限切れになります", + "clawkeep.pair.waitingAuthorization": "認証を待っています…", + "clawkeep.pair.getNewCode": "新しいコードを取得", "clawkeep.status.protected": "保護されています", "clawkeep.status.protectedSub": "あなたの OpenClaw は ClawBox クラウドで安全 — 設定・エージェント・認証情報すべて。", "clawkeep.status.lapsed": "保護が切れました", @@ -1024,18 +1031,19 @@ export const clawkeepTranslations: Record> = { "clawkeep.pair.description": "Koppel deze OpenClaw aan je portaal-account, dan stellen we kortlevende R2-credentials uit zodat elke back-up in je privé-prefix terechtkomt.", "clawkeep.pair.button": "Koppelen met portaal", "clawkeep.pair.connecting": "🔗 Verbinden…", - "clawkeep.pair.enterCode": "👉 Voer deze code in", "clawkeep.pair.configuring": "🔄 Configureren…", "clawkeep.pair.codeAriaLabel": "Koppelcode", "clawkeep.pair.codeCopied": "Code gekopieerd", "clawkeep.pair.copyCode": "Code kopiëren", "clawkeep.pair.copy": "Kopiëren", "clawkeep.pair.copied": "Gekopieerd", - "clawkeep.pair.typeCodeOnPortal": "Op de portaalpagina die opende, typ de code en keur goed.", - "clawkeep.pair.reopenPortal": "Portaalpagina opnieuw openen", "clawkeep.pair.savingToken": "⏳ Token opslaan…", - "clawkeep.pair.waitingApproval": "🕒 Wachten op goedkeuring…", "clawkeep.pair.failed": "Koppeling mislukt", + "clawkeep.pair.intro": "Open het ClawBox-portaal en voer de onderstaande apparaatcode in. Je apparaat voltooit de overdracht zodra je in het portaal bevestigt.", + "clawkeep.pair.thenEnterCode": "Voer dan deze code in:", + "clawkeep.pair.codeExpires": "Code verloopt over 15 minuten", + "clawkeep.pair.waitingAuthorization": "Wachten op autorisatie…", + "clawkeep.pair.getNewCode": "Nieuwe code ophalen", "clawkeep.status.protected": "Je bent beveiligd", "clawkeep.status.protectedSub": "Je OpenClaw staat veilig in de ClawBox-cloud — config, agents, credentials, alles erop.", "clawkeep.status.lapsed": "Bescherming verlopen", @@ -1162,18 +1170,19 @@ export const clawkeepTranslations: Record> = { "clawkeep.pair.description": "Länka denna OpenClaw till ditt portalkonto så utfärdar vi kortvariga R2-uppgifter så att varje säkerhetskopia hamnar i ditt privata prefix.", "clawkeep.pair.button": "Parkoppla med portalen", "clawkeep.pair.connecting": "🔗 Ansluter…", - "clawkeep.pair.enterCode": "👉 Ange den här koden", "clawkeep.pair.configuring": "🔄 Konfigurerar…", "clawkeep.pair.codeAriaLabel": "Parkopplingskod", "clawkeep.pair.codeCopied": "Koden kopierad", "clawkeep.pair.copyCode": "Kopiera kod", "clawkeep.pair.copy": "Kopiera", "clawkeep.pair.copied": "Kopierat", - "clawkeep.pair.typeCodeOnPortal": "På portalsidan som öppnades, skriv koden och godkänn.", - "clawkeep.pair.reopenPortal": "Öppna portalsidan igen", "clawkeep.pair.savingToken": "⏳ Sparar token…", - "clawkeep.pair.waitingApproval": "🕒 Väntar på godkännande…", "clawkeep.pair.failed": "Parkoppling misslyckades", + "clawkeep.pair.intro": "Öppna ClawBox-portalen och ange enhetskoden som visas nedan. Enheten slutför överlämningen när du bekräftar i portalen.", + "clawkeep.pair.thenEnterCode": "Ange sedan denna kod:", + "clawkeep.pair.codeExpires": "Koden går ut om 15 minuter", + "clawkeep.pair.waitingAuthorization": "Väntar på auktorisering…", + "clawkeep.pair.getNewCode": "Hämta en ny kod", "clawkeep.status.protected": "Du är skyddad", "clawkeep.status.protectedSub": "Din OpenClaw är trygg i ClawBox-molnet — konfiguration, agenter, autentiseringar, allt.", "clawkeep.status.lapsed": "Skyddet har upphört", @@ -1300,18 +1309,19 @@ export const clawkeepTranslations: Record> = { "clawkeep.pair.description": "将此 OpenClaw 链接到您的门户账户,我们将颁发短期 R2 凭证,使每次备份都进入您的私有前缀。", "clawkeep.pair.button": "与门户配对", "clawkeep.pair.connecting": "🔗 连接中…", - "clawkeep.pair.enterCode": "👉 输入此代码", "clawkeep.pair.configuring": "🔄 配置中…", "clawkeep.pair.codeAriaLabel": "配对代码", "clawkeep.pair.codeCopied": "代码已复制", "clawkeep.pair.copyCode": "复制代码", "clawkeep.pair.copy": "复制", "clawkeep.pair.copied": "已复制", - "clawkeep.pair.typeCodeOnPortal": "在打开的门户页面上,输入代码并批准。", - "clawkeep.pair.reopenPortal": "重新打开门户页面", "clawkeep.pair.savingToken": "⏳ 保存令牌中…", - "clawkeep.pair.waitingApproval": "🕒 等待批准…", "clawkeep.pair.failed": "配对失败", + "clawkeep.pair.intro": "打开 ClawBox 门户并输入下面显示的设备代码。在门户上确认后,您的设备将完成连接。", + "clawkeep.pair.thenEnterCode": "然后输入此代码:", + "clawkeep.pair.codeExpires": "代码将在 15 分钟后过期", + "clawkeep.pair.waitingAuthorization": "等待授权…", + "clawkeep.pair.getNewCode": "获取新代码", "clawkeep.status.protected": "您已受保护", "clawkeep.status.protectedSub": "您的 OpenClaw 已安全保存在 ClawBox 云中 — 配置、代理、凭证,一应俱全。", "clawkeep.status.lapsed": "保护已失效", diff --git a/src/lib/desktop-translations-part1.ts b/src/lib/desktop-translations-part1.ts index 5c161eebf..1425de417 100644 --- a/src/lib/desktop-translations-part1.ts +++ b/src/lib/desktop-translations-part1.ts @@ -159,6 +159,7 @@ export const bg: Record = { "vnc.pasteHelp": "Постави текста по-долу (Ctrl+V работи тук) и натисни Изпрати. Текстът ще се появи в клипборда на VNC, за да го поставиш в Chromium или друго приложение вътре.", "vnc.pastePlaceholder": "Постави тук (Ctrl+V)…", "vnc.sendPaste": "Изпрати във VNC", + "vnc.copyToast.fromRemote": "Копирано от VNC", "store.all": "Всички", "store.installed": "Инсталирани", "store.uninstall": "Деинсталирай", @@ -517,6 +518,7 @@ export const de: Record = { "vnc.pasteHelp": "Füge deinen Text unten ein (Ctrl+V funktioniert hier) und klicke auf Senden. Der Text landet in der Zwischenablage der VNC und kann in Chromium oder andere Apps innerhalb des VNC eingefügt werden.", "vnc.pastePlaceholder": "Hier einfügen (Ctrl+V)…", "vnc.sendPaste": "An VNC senden", + "vnc.copyToast.fromRemote": "Aus VNC kopiert", "store.all": "Alle", "store.installed": "Installiert", "store.uninstall": "Deinstallieren", @@ -875,6 +877,7 @@ export const es: Record = { "vnc.pasteHelp": "Pega tu texto abajo (Ctrl+V funciona aquí) y haz clic en Enviar. El texto irá al portapapeles de VNC para pegarlo en Chromium o cualquier app dentro del VNC.", "vnc.pastePlaceholder": "Pega aquí (Ctrl+V)…", "vnc.sendPaste": "Enviar a VNC", + "vnc.copyToast.fromRemote": "Copiado desde VNC", "store.all": "Todas", "store.installed": "Instaladas", "store.uninstall": "Desinstalar", diff --git a/src/lib/desktop-translations-part2.ts b/src/lib/desktop-translations-part2.ts index b898bc0b8..7393ec196 100644 --- a/src/lib/desktop-translations-part2.ts +++ b/src/lib/desktop-translations-part2.ts @@ -159,6 +159,7 @@ export const fr: Record = { "vnc.pasteHelp": "Collez votre texte ci-dessous (Ctrl+V fonctionne ici) et cliquez sur Envoyer. Le texte atterrit dans le presse-papiers VNC pour être collé dans Chromium ou toute app du VNC.", "vnc.pastePlaceholder": "Collez ici (Ctrl+V)…", "vnc.sendPaste": "Envoyer vers VNC", + "vnc.copyToast.fromRemote": "Copié depuis VNC", "store.all": "Tout", "store.installed": "Installées", "store.uninstall": "Désinstaller", @@ -517,6 +518,7 @@ export const it: Record = { "vnc.pasteHelp": "Incolla il testo qui sotto (Ctrl+V funziona qui) e clicca Invia. Il testo arriva negli appunti del VNC per poterlo incollare in Chromium o in qualsiasi app dentro il VNC.", "vnc.pastePlaceholder": "Incolla qui (Ctrl+V)…", "vnc.sendPaste": "Invia a VNC", + "vnc.copyToast.fromRemote": "Copiato da VNC", "store.all": "Tutto", "store.installed": "Installate", "store.uninstall": "Disinstalla", @@ -875,6 +877,7 @@ export const ja: Record = { "vnc.pasteHelp": "下にテキストを貼り付け(Ctrl+V が使えます)、送信をクリックしてください。テキストは VNC のクリップボードに入るので、Chromium や VNC 内のアプリに貼り付けられます。", "vnc.pastePlaceholder": "ここに貼り付け (Ctrl+V)…", "vnc.sendPaste": "VNCに送信", + "vnc.copyToast.fromRemote": "VNCからコピー", "store.all": "すべて", "store.installed": "インストール済み", "store.uninstall": "アンインストール", diff --git a/src/lib/desktop-translations-part3.ts b/src/lib/desktop-translations-part3.ts index c3e3cc821..75a636e9d 100644 --- a/src/lib/desktop-translations-part3.ts +++ b/src/lib/desktop-translations-part3.ts @@ -159,6 +159,7 @@ export const nl: Record = { "vnc.pasteHelp": "Plak je tekst hieronder (Ctrl+V werkt hier) en klik op Verzenden. De tekst komt op het klembord van VNC zodat je hem in Chromium of een andere app binnen VNC kunt plakken.", "vnc.pastePlaceholder": "Hier plakken (Ctrl+V)…", "vnc.sendPaste": "Naar VNC sturen", + "vnc.copyToast.fromRemote": "Gekopieerd uit VNC", "store.all": "Alles", "store.installed": "Geïnstalleerd", "store.uninstall": "Verwijderen", @@ -517,6 +518,7 @@ export const sv: Record = { "vnc.pasteHelp": "Klistra in din text nedan (Ctrl+V fungerar här) och klicka på Skicka. Texten hamnar i VNC:s urklipp så du kan klistra in den i Chromium eller någon app inuti VNC.", "vnc.pastePlaceholder": "Klistra in här (Ctrl+V)…", "vnc.sendPaste": "Skicka till VNC", + "vnc.copyToast.fromRemote": "Kopierat från VNC", "store.all": "Alla", "store.installed": "Installerade", "store.uninstall": "Avinstallera", @@ -875,6 +877,7 @@ export const zh: Record = { "vnc.pasteHelp": "在下方粘贴文本(此处支持 Ctrl+V),然后点击发送。文本会进入 VNC 剪贴板,可在 Chromium 或 VNC 内任何应用中粘贴。", "vnc.pastePlaceholder": "粘贴到这里 (Ctrl+V)…", "vnc.sendPaste": "发送到 VNC", + "vnc.copyToast.fromRemote": "已从 VNC 复制", "store.all": "全部", "store.installed": "已安装", "store.uninstall": "卸载", diff --git a/src/lib/desktop-translations.ts b/src/lib/desktop-translations.ts index 8fd1b9fa5..33a4511de 100644 --- a/src/lib/desktop-translations.ts +++ b/src/lib/desktop-translations.ts @@ -187,6 +187,7 @@ export const desktopTranslations: Record> = { "vnc.pasteHelp": "Paste your text below (Ctrl+V works here) and click Send. The text lands on the remote clipboard so you can paste it into Chromium or any app inside the VNC.", "vnc.pastePlaceholder": "Paste here (Ctrl+V)…", "vnc.sendPaste": "Send to VNC", + "vnc.copyToast.fromRemote": "Copied from VNC", // === AppStore === diff --git a/src/lib/use-clawbox-login.ts b/src/lib/use-clawbox-login.ts index 9b2c5bd36..0a309c824 100644 --- a/src/lib/use-clawbox-login.ts +++ b/src/lib/use-clawbox-login.ts @@ -61,14 +61,29 @@ export function useClawboxLogin(intervalMs: number = DEFAULT_INTERVAL_MS): Clawb let cancelled = false; let timer: ReturnType | null = null; + // Transient-failure handler: preserve the last-known loggedIn/tier so + // a momentary fetch failure (gateway WS drop, portal timeout, etc.) + // doesn't flip the state to "free" and re-fire the downgrade modal + // on every disconnect–reconnect cycle. The server-side /status route + // already caches portal responses with proper TTLs, so a 2xx body is + // the authoritative signal — anything else is "I don't know right + // now", not "you've been downgraded". + const preserveOnTransient = () => { + if (cancelled) return; + // Return the same ref when nothing logical has changed so React bails + // out and downstream consumers don't re-render on every failed poll. + setState((prev) => ( + prev.loading + ? { loggedIn: prev.loggedIn, tier: prev.tier, loading: false } + : prev + )); + }; + const tick = async () => { try { const res = await fetch("/setup-api/ai-models/status", { cache: "no-store" }); if (!res.ok) { - // A non-2xx response means the device can't currently confirm the - // session — clear stale loggedIn/tier so callers don't keep gating - // open after a previously-good poll. - if (!cancelled) setState({ loggedIn: false, tier: null, loading: false }); + preserveOnTransient(); return; } const data = (await res.json()) as AiStatusResponse; @@ -89,8 +104,7 @@ export function useClawboxLogin(intervalMs: number = DEFAULT_INTERVAL_MS): Clawb loading: false, }); } catch { - // Network failure → fall closed, same reasoning as the !res.ok branch. - if (!cancelled) setState({ loggedIn: false, tier: null, loading: false }); + preserveOnTransient(); } finally { if (!cancelled) { timer = setTimeout(tick, intervalMs); From 1de4dbb37d73a852b273d6c3e7a63e864511f485 Mon Sep 17 00:00:00 2001 From: Krasimir Kralev <263465593+KrasimirKralev@users.noreply.github.com> Date: Tue, 19 May 2026 16:52:19 +0300 Subject: [PATCH 3/3] fix: codex peer-dep self-heal + don't flip badge to Free on portal auth loss (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: codex peer-dep self-heal + don't flip badge to Free on portal auth loss Two related post-v3.0.3 hardenings observed live on a customer device and a dev box. ## 1. gateway-pre-start.sh: re-install codex if the peer-dep symlink is missing Symptom on a customer device: chat fails with `Error: Cannot find package 'openclaw' imported from .../@openclaw/codex/dist/shared-client-…js`. Root cause: `openclaw plugins install codex` writes both the package directory AND a `/node_modules/openclaw -> ` symlink. If something later removes the nested node_modules (partial install, openclaw upgrade churn, manual cleanup), the package.json remains but the peer dep is gone. Our v3.0.3 auto-installer only checked `$CODEX_PLUGIN_DIR/package.json`, declared the plugin "installed", and skipped repair. The plugin then loaded but every import via the openclaw package failed at runtime. Add a check on the nested peer dep too. `-e` follows symlinks, so both "missing symlink" and "dangling symlink" trigger a `--force` reinstall, which rebuilds the link without redownloading the package contents. ## 2. status/route.ts: portal 401/403 no longer flips badge to Free Symptom: when ClawKeep auth disconnects (or the bearer is otherwise rejected by the portal), the device flips its tier badge to Free and fires the downgrade-celebration popup with a Re-subscribe CTA — even though the user might still be on a paid plan. Root cause: fetchPortalTier treated 401/403 as a definitive "user is Free" verdict and cached it for the full 120 s TTL. But the portal returns 401/403 for several distinct cases: - User is genuinely Free. - Token was revoked (admin action, account event, plan transition). - Token migrated to a new format the device hasn't refreshed yet. - Backend cleanup pruned the device record. The response code alone can't distinguish them. Fall through to the existing `unreachable` branch on 401/403 instead. clawaiAccountTier then preserves `localTier` (last known good), tierSource is "picker", the downgrade-celebration popup doesn't fire spuriously, and — because `unreachable` is uncached — the very next poll re-tries the portal, so a recovered token is picked up immediately instead of waiting out a stale 120 s null. A user who legitimately downgrades to Free still flips correctly: the portal returns 200 + `tier: "free"` for that case, which is the path that goes through mapPortalTier and returns null. ## Tests - Replaced "returns clawaiTier=null on portal 403" with two new tests: - "preserves localTier on portal 401/403 (auth lost, might still be paid)" - "does not cache the auth-failure verdict — recovers on next poll" ## Verified - Codex resilience: reproduced the broken state on a dev Jetson (`@openclaw/codex` present, nested node_modules removed), confirmed the new check triggers `--force` reinstall and the chat recovers. - Auth-loss handling: covered by the two new unit tests above. * refactor(simplify): negative-cache unreachable + trim duplicated rationale Applies the three actionable findings from /simplify: 1. Efficiency: previously, removing the 401/403 positive-null cache meant every 30 s status poll hit the portal during sustained auth failure or outage (4× sustained per-device load vs. the prior 120 s cache). Add a separate short negative cache (portalUnreachableCache, 30 s TTL — matches the poll cadence so we hit the portal at most every other poll). Successful 200 clears it so recovery is responsive. The "401/403 isn't Free" semantic from the previous commit is preserved. 2. Quality: comment block in fetchPortalTier was 16 lines, the "also: deliberately not cached" tail was redundant with the test name. Trimmed to 6 lines (the irreducible "401/403 is ambiguous" reasoning). 3. Quality: dropped the duplicated prose comments inside the two new test cases — the test titles already document the behaviour and the rationale belongs in route.ts, not both places. Tests updated: - Renamed "does not cache the auth-failure verdict" → "negative- caches an unreachable verdict so back-to-back polls don't hammer the portal". Same shape, now asserts fetchSpy was called once (not twice). Verifies the new cache hit. - "preserves localTier on portal 401/403" unchanged in behaviour; comment stripped. --- scripts/gateway-pre-start.sh | 19 +++++-- src/app/setup-api/ai-models/status/route.ts | 57 +++++++++++++++------ src/tests/routes/ai-models/status.test.ts | 24 +++++++-- 3 files changed, 77 insertions(+), 23 deletions(-) diff --git a/scripts/gateway-pre-start.sh b/scripts/gateway-pre-start.sh index 5a217a479..e27895c4e 100755 --- a/scripts/gateway-pre-start.sh +++ b/scripts/gateway-pre-start.sh @@ -331,9 +331,22 @@ uses_codex = ( print("1" if uses_codex else "0") PY )" -if [ "$NEEDS_CODEX_PLUGIN" = "1" ] && [ ! -f "$CODEX_PLUGIN_DIR/package.json" ]; then - echo " Installing @openclaw/codex runtime plugin (codex model selected)…" - "$OPENCLAW_BIN" plugins install codex >/dev/null 2>&1 \ +# Also check the nested peer-dep symlink. `openclaw plugins install +# codex` writes `/node_modules/openclaw -> ` +# alongside the package.json; if that symlink is missing or dangling +# (partial install, openclaw upgrade that cleared the nested +# node_modules, manual cleanup) the codex plugin loads but its +# top-level imports fail at runtime with: +# Error: Cannot find package 'openclaw' imported from +# .../@openclaw/codex/dist/shared-client-…js +# Checking only the package.json misses that broken state. `-e` +# follows symlinks, so it catches both "missing" and "dangling". +# `--force` on install rebuilds the symlink without reinstalling +# unnecessary content when the package directory is already there. +CODEX_PEER_DEP="$CODEX_PLUGIN_DIR/node_modules/openclaw/package.json" +if [ "$NEEDS_CODEX_PLUGIN" = "1" ] && { [ ! -f "$CODEX_PLUGIN_DIR/package.json" ] || [ ! -e "$CODEX_PEER_DEP" ]; }; then + echo " Installing/repairing @openclaw/codex runtime plugin (codex model selected)…" + "$OPENCLAW_BIN" plugins install codex --force >/dev/null 2>&1 \ || echo " WARN: openclaw plugins install codex failed; Codex chats will fail until resolved" fi diff --git a/src/app/setup-api/ai-models/status/route.ts b/src/app/setup-api/ai-models/status/route.ts index fe2650989..b7e615f44 100644 --- a/src/app/setup-api/ai-models/status/route.ts +++ b/src/app/setup-api/ai-models/status/route.ts @@ -41,6 +41,16 @@ const PORTAL_FETCH_TIMEOUT_MS = 4_000; // reset / multi-account dev churn — but a long-running process would // otherwise leak entries forever. const PORTAL_TIER_CACHE_MAX_ENTRIES = 64; +// Short negative-cache window for tokens whose last portal lookup +// resolved to `unreachable` (4xx auth failure, 5xx, or network +// error). With useClawboxLogin polling every 30s, this caps the +// per-device portal load during a sustained auth-failure or +// outage at ~1 request per 30s (down from 1-per-poll). Smaller +// than PORTAL_TIER_CACHE_TTL_MS because the positive cache is +// safe to hold longer; an unreachable verdict needs to clear +// quickly enough that recovery (token re-pair, portal recovers) +// shows up on the next poll, not minutes later. +const PORTAL_UNREACHABLE_TTL_MS = 30_000; interface DeviceInfoResponse { tier?: string; @@ -57,6 +67,10 @@ interface PortalCacheEntry { } const portalTierCache = new Map(); +// token → epoch-ms timestamp when its unreachable verdict expires. +// Separate from portalTierCache because the value is "we tried and +// it failed, don't try again yet" rather than "the answer is null". +const portalUnreachableCache = new Map(); const inFlightPortalLookups = new Map>(); /** @@ -111,11 +125,15 @@ function mapPortalTier(body: DeviceInfoResponse): ClawboxAiTier | null { * * Cache semantics: * - 200 OK: parsed tier is cached for `PORTAL_TIER_CACHE_TTL_MS`. - * - 401 / 403: a definitive "no entitlement" verdict is also cached - * so we don't re-hammer the portal for invalid tokens. - * - 5xx / network error: cache untouched; caller falls back to the - * locally-stored picker selection so the badge doesn't flicker - * during transient portal outages. + * - Non-200 / network error: token is marked unreachable for + * `PORTAL_UNREACHABLE_TTL_MS` so we don't hit the portal every + * 30 s status poll during a sustained auth failure or outage. + * A successful 200 clears the unreachable mark so recovery is + * responsive. + * + * 401/403 are deliberately treated the same as 5xx/network errors + * (unreachable) rather than as a definitive "Free" verdict — see + * the non-200 branch in the body for the rationale. * * @param token The bearer token to look up. * @returns Either a definitive `{ source: "portal", tier }` answer or @@ -126,10 +144,19 @@ async function fetchPortalTier(token: string): Promise { const cached = portalTierCache.get(token); if (cached && cached.expiresAt > now) return { source: "portal", tier: cached.tier }; + const unreachableUntil = portalUnreachableCache.get(token); + if (unreachableUntil !== undefined && unreachableUntil > now) { + return { source: "unreachable" }; + } + const existing = inFlightPortalLookups.get(token); if (existing) return existing; const promise = (async (): Promise => { + const markUnreachable = (): PortalLookup => { + portalUnreachableCache.set(token, now + PORTAL_UNREACHABLE_TTL_MS); + return { source: "unreachable" }; + }; try { const res = await fetch(PORTAL_DEVICE_INFO_URL, { headers: { Authorization: `Bearer ${token}` }, @@ -139,19 +166,18 @@ async function fetchPortalTier(token: string): Promise { const body = await res.json() as DeviceInfoResponse; const tier = mapPortalTier(body); rememberTier(token, tier, now); + portalUnreachableCache.delete(token); return { source: "portal", tier }; } - // 401/403 are definitive — the portal *did* answer, the token just - // doesn't entitle anything. Cache so we don't re-hammer for the - // TTL. 5xx and network errors leave the cache untouched and let - // callers fall back to the locally-stored picker selection. - if (res.status === 401 || res.status === 403) { - rememberTier(token, null, now); - return { source: "portal", tier: null }; - } - return { source: "unreachable" }; + // 401/403 is ambiguous: it can mean genuinely Free OR token + // revoked / migrated / corrupted on a still-paid account. We + // can't tell from the response alone, and treating it as + // "Free" silently downgrades paid users with broken auth (and + // fires the downgrade-celebration popup). Mark unreachable + // instead so callers preserve localTier. + return markUnreachable(); } catch { - return { source: "unreachable" }; + return markUnreachable(); } })(); @@ -169,6 +195,7 @@ async function fetchPortalTier(token: string): Promise { * a clean module-state. Not for production use. */ export function _resetPortalTierCache() { + portalUnreachableCache.clear(); portalTierCache.clear(); inFlightPortalLookups.clear(); } diff --git a/src/tests/routes/ai-models/status.test.ts b/src/tests/routes/ai-models/status.test.ts index b8e446ceb..5129c7c2c 100644 --- a/src/tests/routes/ai-models/status.test.ts +++ b/src/tests/routes/ai-models/status.test.ts @@ -238,19 +238,33 @@ describe("/setup-api/ai-models/status", () => { expect(body.tierSource).toBe("portal"); }); - it("returns clawaiTier=null on portal 403 (invalid token) and caches the verdict", async () => { + it("preserves localTier on portal 401/403 (auth lost, might still be paid)", async () => { mockReadConfig.mockResolvedValue(clawaiConfigBase as never); mockGetConfigValue.mockResolvedValue("pro"); fetchSpy.mockResolvedValue(new Response("invalid_token", { status: 403 })); + const res = await GET(); + const body = await res.json(); + + expect(body.clawaiTier).toBe("pro"); + expect(body.tierSource).toBe("picker"); + }); + + it("negative-caches an unreachable verdict so back-to-back polls don't hammer the portal", async () => { + mockReadConfig.mockResolvedValue(clawaiConfigBase as never); + mockGetConfigValue.mockResolvedValue("pro"); + fetchSpy.mockResolvedValue(new Response("invalid_token", { status: 401 })); + const first = await (await GET()).json(); const second = await (await GET()).json(); - expect(first.clawaiTier).toBeNull(); - expect(first.tierSource).toBe("portal"); - // 403 caches; the second request must not hit the network again. + expect(first.clawaiTier).toBe("pro"); + expect(first.tierSource).toBe("picker"); + expect(second.clawaiTier).toBe("pro"); + expect(second.tierSource).toBe("picker"); + // Second call inside the unreachable TTL must hit the negative + // cache instead of re-fetching from the portal. expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(second.clawaiTier).toBeNull(); }); it("falls back to the locally-stored tier when the portal is unreachable", async () => {