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/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/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/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/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); 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 () => {