From 220866af21b04a67fce85014b79e0fefe2cbbc5f Mon Sep 17 00:00:00 2001 From: KrasimirKralev <263465593+KrasimirKralev@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:48:17 +0300 Subject: [PATCH 1/2] fix(local-ai,clawkeep): idempotent pull stream + namespaced pulls, delete error surfacing, idle re-arm, clawkeep status/timeout/gating, poll json guard --- src/app/setup-api/clawkeep/pair/poll/route.ts | 8 +- src/app/setup-api/ollama/pull/route.ts | 10 ++- src/components/AIModelsStep.tsx | 1 + src/components/DoneStep.tsx | 1 + src/hooks/useOllamaModels.ts | 20 +++-- src/lib/clawkeep.ts | 73 ++++++++++++++++++- src/lib/local-ai-runtime.ts | 9 +++ 7 files changed, 110 insertions(+), 12 deletions(-) diff --git a/src/app/setup-api/clawkeep/pair/poll/route.ts b/src/app/setup-api/clawkeep/pair/poll/route.ts index f5be295c1..b6c7146b2 100644 --- a/src/app/setup-api/clawkeep/pair/poll/route.ts +++ b/src/app/setup-api/clawkeep/pair/poll/route.ts @@ -136,7 +136,13 @@ export async function POST() { return NextResponse.json({ status: "pending" }, { headers: { "Cache-Control": "no-store" } }); } - const data = (await upstreamRes.json()) as UpstreamPollResponse; + // A 200 with a non-JSON body (captive portal / proxy HTML) must not throw + // an unhandled error — that would 500 the route while the client keeps + // polling. Treat an unparseable body as "keep waiting". + const data = (await upstreamRes.json().catch(() => null)) as UpstreamPollResponse | null; + if (!data) { + return NextResponse.json({ status: "pending" }, { headers: { "Cache-Control": "no-store" } }); + } const upstreamStatus = (data.status || "").toLowerCase(); if (upstreamStatus === "pending" || upstreamStatus === "authorization_pending") { diff --git a/src/app/setup-api/ollama/pull/route.ts b/src/app/setup-api/ollama/pull/route.ts index 8659bdddd..d947175a3 100644 --- a/src/app/setup-api/ollama/pull/route.ts +++ b/src/app/setup-api/ollama/pull/route.ts @@ -15,8 +15,10 @@ export async function POST(request: Request) { const model = body.model || "llama3.2:3b"; - // Validate model name format (e.g. "llama3.2:3b", "mistral", "qwen2.5-coder:1.5b") - if (!/^[a-z0-9._-]+(?::[a-zA-Z0-9._-]+)?$/i.test(model)) { + // Validate model name format. Mirrors the delete route's MODEL_RE so + // namespaced refs ("user/model:tag", "hf.co/...") that Ollama accepts + // aren't rejected here. + if (!/^[a-zA-Z0-9._:/-]+$/.test(model)) { return NextResponse.json( { error: "Invalid model name format" }, { status: 400 }, @@ -63,8 +65,10 @@ export async function POST(request: Request) { const parsed = JSON.parse(line); if (parsed.error) { controller.enqueue(new TextEncoder().encode(JSON.stringify({ error: parsed.error }) + "\n")); - controller.close(); hasError = true; + // Let the single `finally` close the controller — closing + // here too would double-close and reject the stream with + // "Controller is already closed". return; } } catch { diff --git a/src/components/AIModelsStep.tsx b/src/components/AIModelsStep.tsx index 1d71965fa..678044fd4 100644 --- a/src/components/AIModelsStep.tsx +++ b/src/components/AIModelsStep.tsx @@ -777,6 +777,7 @@ export default function AIModelsStep({ onSaveSuccess: () => showSuccessAndContinue(), onSaveError: (message: string) => showError(message), onPullError: (message: string) => showError(message), + onDeleteError: (message: string) => showError(message), onClearStatus: () => setStatus(null), }), [showError, showSuccessAndContinue]); diff --git a/src/components/DoneStep.tsx b/src/components/DoneStep.tsx index 2694b1dd1..a1559127d 100644 --- a/src/components/DoneStep.tsx +++ b/src/components/DoneStep.tsx @@ -668,6 +668,7 @@ export default function DoneStep({ setupComplete = false, onComplete }: DoneStep }, onSaveError: (message: string) => setAiStatus({ type: "error", message }), onPullError: (message: string) => setAiStatus({ type: "error", message }), + onDeleteError: (message: string) => setAiStatus({ type: "error", message }), onClearStatus: () => setAiStatus(null), }), []); diff --git a/src/hooks/useOllamaModels.ts b/src/hooks/useOllamaModels.ts index 79dbbd3d9..3520063c6 100644 --- a/src/hooks/useOllamaModels.ts +++ b/src/hooks/useOllamaModels.ts @@ -18,6 +18,7 @@ export interface OllamaCallbacks { onSaveSuccess: (model: string) => void; onSaveError: (message: string) => void; onPullError: (message: string) => void; + onDeleteError: (message: string) => void; /** Called before save/pull actions to clear previous status messages */ onClearStatus?: () => void; } @@ -193,14 +194,23 @@ export function useOllamaModels(callbacks: OllamaCallbacks, configureScope: Conf headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model }), }); - if (res.ok) { - await checkOllamaStatus(); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + callbacks.onDeleteError( + typeof data.error === "string" ? data.error : "Failed to delete model" + ); } - } catch { - /* silently fail — model list will refresh next status check */ + } catch (err) { + callbacks.onDeleteError( + `Failed to delete model: ${err instanceof Error ? err.message : err}` + ); + } finally { + // Always reconcile against the daemon: a partial/failed delete still + // needs the list refreshed so the UI reflects reality. + await checkOllamaStatus(); } }, - [checkOllamaStatus] + [callbacks, checkOllamaStatus] ); const selectExistingOllamaModel = useCallback( diff --git a/src/lib/clawkeep.ts b/src/lib/clawkeep.ts index 1509fb007..c0bc865c1 100644 --- a/src/lib/clawkeep.ts +++ b/src/lib/clawkeep.ts @@ -685,11 +685,48 @@ export interface CloudSnapshot { interface SnapshotsResponse { ok: boolean; error?: string; + /** Coarse failure classification from the daemon so we can map to a + * sensible HTTP status instead of collapsing everything into 502. */ + kind?: string; snapshots?: CloudSnapshot[]; quotaBytes?: number; cloudBytes?: number; } +/** + * Map a failed `snapshots` daemon response to an HTTP status + a friendly + * message. We deliberately never surface `resp.error` verbatim — it can be a + * raw Python traceback or leak internal paths — so the caller gets a clean, + * classified error the UI can act on. + */ +function mapSnapshotsError(resp: SnapshotsResponse): ClawKeepError { + switch (resp.kind) { + case "unpaired": + case "no_token": + case "not_paired": + return new ClawKeepError("ClawKeep is not paired with an account", 409); + case "auth": + case "unauthorized": + case "revoked": + case "forbidden": + return new ClawKeepError( + "ClawKeep authorisation was rejected — re-pair the device", + 401, + ); + case "network": + case "offline": + case "timeout": + return new ClawKeepError("Could not reach the ClawKeep portal", 504); + case "portal": + case "server": + return new ClawKeepError("The ClawKeep portal returned an error", 502); + default: + // Covers missing-dependency (e.g. boto3) and any unclassified daemon + // failure without leaking the raw internal string. + return new ClawKeepError("Could not list cloud backups", 502); + } +} + interface RestoreOk { ok: true; archive: string; @@ -717,6 +754,9 @@ export class RestoreNeedsPassphraseError extends ClawKeepError { } const RESTORE_TIMEOUT_MS = 30 * 60 * 1000; // hard cap matches openclaw verify + multipart download +// Generous cap for a full backup (openclaw backup create + multipart upload). +// A hung clawkeepd must not hold a Next.js worker open forever. +const BACKUP_TIMEOUT_MS = 60 * 60 * 1000; function spawnCliJson( bin: string, @@ -754,6 +794,13 @@ function spawnCliJson( }); child.on("error", (err) => { clearTimeout(killTimer); + // An unresolved binary (clawkeep/clawkeepd not on PATH or the derived + // sibling missing) surfaces as ENOENT. Turn it into a friendly 503 + // instead of a raw "spawn ... ENOENT" 500. + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + reject(new ClawKeepError("ClawKeep daemon not installed", 503)); + return; + } reject(new Error(`spawn ${clawkeepBin} failed: ${err.message}`)); }); child.on("close", (code) => { @@ -777,10 +824,16 @@ function spawnCliJson( } async function fetchCloudSnapshots(): Promise { + // Fail fast + friendly on the unpaired case rather than shelling out and + // surfacing the daemon's raw "no token" error as a 502. + const token = await readToken(); + if (!token) { + throw new ClawKeepError("ClawKeep is not paired with an account", 409); + } const bin = (await getDaemonBin()) ?? DEFAULT_BIN_NAME; const resp = await spawnCliJson(bin, "snapshots", [], { timeoutMs: 60_000 }); if (!resp.ok) { - throw new ClawKeepError(resp.error ?? "snapshots failed", 502); + throw mapSnapshotsError(resp); } return resp; } @@ -988,6 +1041,20 @@ export async function runBackup( const child = spawn(bin, args, { env, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; + let settled = false; + const settle = (result: BackupResult) => { + if (settled) return; + settled = true; + clearTimeout(killTimer); + resolve(result); + }; + // Mirror spawnCliJson's kill-timer: SIGKILL a hung daemon and resolve + // with a non-zero exit so the caller surfaces "backup timed out" rather + // than leaking a worker forever. + const killTimer = setTimeout(() => { + try { child.kill("SIGKILL"); } catch { /* already gone */ } + settle({ exitCode: 124, stdout, stderr: appendCapped(stderr, "\nbackup timed out") }); + }, BACKUP_TIMEOUT_MS); child.stdout.on("data", (b: Buffer) => { stdout = appendCapped(stdout, b.toString("utf8")); }); @@ -996,10 +1063,10 @@ export async function runBackup( }); child.on("error", (e) => { // ENOENT etc. — synthesize a non-zero exit so callers can surface it. - resolve({ exitCode: 127, stdout, stderr: appendCapped(stderr, e.message) }); + settle({ exitCode: 127, stdout, stderr: appendCapped(stderr, e.message) }); }); child.on("close", (code) => { - resolve({ exitCode: code ?? -1, stdout, stderr }); + settle({ exitCode: code ?? -1, stdout, stderr }); }); }); } diff --git a/src/lib/local-ai-runtime.ts b/src/lib/local-ai-runtime.ts index a987da5c1..c3e29e19c 100644 --- a/src/lib/local-ai-runtime.ts +++ b/src/lib/local-ai-runtime.ts @@ -206,6 +206,15 @@ export async function ensureLocalAiReady(provider: LocalAiProvider): Promise 0 an + // in-flight proxy request owns the lifecycle, so we leave it alone — + // beginLocalAiUse will have cleared this timer anyway. + if (state.activeRequests === 0) { + scheduleIdleStop(provider); + } } } From 42526593182c6efec55065092927af51d3138b2d Mon Sep 17 00:00:00 2001 From: KrasimirKralev <263465593+KrasimirKralev@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:03:14 +0300 Subject: [PATCH 2/2] fix(ollama): reject '..' segments in model refs (keep namespaced refs valid) --- src/app/setup-api/ollama/delete/route.ts | 2 +- src/app/setup-api/ollama/pull/route.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/setup-api/ollama/delete/route.ts b/src/app/setup-api/ollama/delete/route.ts index 03e97946c..ff53b20c7 100644 --- a/src/app/setup-api/ollama/delete/route.ts +++ b/src/app/setup-api/ollama/delete/route.ts @@ -9,7 +9,7 @@ const MODEL_RE = /^[a-zA-Z0-9._:/-]+$/; export async function POST(request: Request) { try { const { model } = await request.json(); - if (!model || typeof model !== "string" || !MODEL_RE.test(model)) { + if (!model || typeof model !== "string" || !MODEL_RE.test(model) || model.includes("..")) { return NextResponse.json({ error: "Invalid model name" }, { status: 400 }); } diff --git a/src/app/setup-api/ollama/pull/route.ts b/src/app/setup-api/ollama/pull/route.ts index d947175a3..180b406f3 100644 --- a/src/app/setup-api/ollama/pull/route.ts +++ b/src/app/setup-api/ollama/pull/route.ts @@ -17,8 +17,9 @@ export async function POST(request: Request) { // Validate model name format. Mirrors the delete route's MODEL_RE so // namespaced refs ("user/model:tag", "hf.co/...") that Ollama accepts - // aren't rejected here. - if (!/^[a-zA-Z0-9._:/-]+$/.test(model)) { + // aren't rejected here. Reject any ".." segment: the broadened charset + // permits it, but a traversal-looking ref is never a legitimate model. + if (!/^[a-zA-Z0-9._:/-]+$/.test(model) || model.includes("..")) { return NextResponse.json( { error: "Invalid model name format" }, { status: 400 },