Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/app/setup-api/clawkeep/pair/poll/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
2 changes: 1 addition & 1 deletion src/app/setup-api/ollama/delete/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}

Expand Down
11 changes: 8 additions & 3 deletions src/app/setup-api/ollama/pull/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ 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. 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 },
Expand Down Expand Up @@ -63,8 +66,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 {
Expand Down
1 change: 1 addition & 0 deletions src/components/AIModelsStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
1 change: 1 addition & 0 deletions src/components/DoneStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}), []);

Expand Down
20 changes: 15 additions & 5 deletions src/hooks/useOllamaModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(
Expand Down
73 changes: 70 additions & 3 deletions src/lib/clawkeep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<T>(
bin: string,
Expand Down Expand Up @@ -754,6 +794,13 @@ function spawnCliJson<T>(
});
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) => {
Expand All @@ -777,10 +824,16 @@ function spawnCliJson<T>(
}

async function fetchCloudSnapshots(): Promise<SnapshotsResponse> {
// 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<SnapshotsResponse>(bin, "snapshots", [], { timeoutMs: 60_000 });
if (!resp.ok) {
throw new ClawKeepError(resp.error ?? "snapshots failed", 502);
throw mapSnapshotsError(resp);
}
return resp;
}
Expand Down Expand Up @@ -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"));
});
Expand All @@ -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 });
});
});
}
9 changes: 9 additions & 0 deletions src/lib/local-ai-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,15 @@ export async function ensureLocalAiReady(provider: LocalAiProvider): Promise<voi
if (state.startPromise === startPromise) {
state.startPromise = null;
}
// Re-arm the idle-stop timer for callers that don't go through
// begin/endLocalAiUse (the ollama pull/delete routes only call
// ensureLocalAiReady). Without this, a pull/delete leaves a big model
// resident forever on the 8GB Jetson. When activeRequests > 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);
}
}
}

Expand Down
Loading