Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion config/clawbox-performance.service
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ After=multi-user.target nvpmodel.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/bash -c 'nvpmodel -m 0 && jetson_clocks'
ExecStart=/bin/bash -c 'MAXN_ID=$(grep -oP "POWER_MODEL ID=\\K\\d+(?=\\s+NAME=MAXN)" /etc/nvpmodel.conf | tail -1); nvpmodel -m ${MAXN_ID:-0} && jetson_clocks'
ExecStop=/bin/bash -c 'jetson_clocks --restore 2>/dev/null || true'

[Install]
Expand Down
13 changes: 10 additions & 3 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ step_apt_update() {
apt-get update -qq
apt-get install -y -qq git curl network-manager avahi-daemon iptables iw python3-pip
# Node.js 22 (required for production server — bun doesn't fire upgrade events)
if node --version 2>/dev/null | grep -q '^v2[2-9]\|^v[3-9]'; then
if node --version 2>/dev/null | grep -qE '^v(2[2-9]|[3-9][0-9])\.'; then
echo " Node.js $(node --version) already installed"
else
echo " Installing Node.js 22..."
Expand Down Expand Up @@ -375,7 +375,14 @@ step_nvidia_jetpack() {
}

step_performance_mode() {
nvpmodel -m 0
# Find the highest MAXN mode (MAXN_SUPER > MAXN); fall back to mode 0
local MAXN_LINE MAXN_ID MAXN_NAME
MAXN_LINE=$(grep -oP 'POWER_MODEL ID=\K\d+\s+NAME=\S+' /etc/nvpmodel.conf | grep 'NAME=MAXN' | tail -1)
MAXN_ID="${MAXN_LINE%% *}"
MAXN_ID="${MAXN_ID:-0}"
MAXN_NAME="${MAXN_LINE#*NAME=}"
echo " Setting power mode to $MAXN_ID (${MAXN_NAME:-unknown})"
nvpmodel -m "$MAXN_ID"
jetson_clocks
# Ensure persistent service is installed and enabled for next boot
if [ -f "$PROJECT_DIR/config/clawbox-performance.service" ]; then
Expand Down Expand Up @@ -537,7 +544,7 @@ step_install_bun
log "Building ClawBox..."
step_build

log "Installing OpenClaw ($OPENCLAW_VERSION)..."
log "Installing OpenClaw..."
step_openclaw_install

log "Patching and configuring OpenClaw..."
Expand Down
10 changes: 8 additions & 2 deletions scripts/start-ap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,21 @@ if [ -f "$CONFIG_FILE" ] && command -v node &>/dev/null; then
fi
fi

# If hotspot is explicitly disabled (post-setup), skip AP mode entirely
if [ "$setup_complete" = true ] && [ "${HOTSPOT_DISABLED:-}" = "1" ]; then
echo "[AP] Hotspot disabled in settings — skipping AP mode"
exit 0
fi

# After setup is complete, try to connect to saved WiFi instead of starting AP.
# If WiFi connect fails (e.g. out of range), fall through to AP mode as fallback.
if [ "$setup_complete" = true ]; then
# Try all saved WiFi profiles (exclude the AP itself) until one connects
while IFS= read -r profile; do
[ -z "$profile" ] && continue
echo "[AP] Setup complete — trying saved WiFi: $profile"
if nmcli connection up "$profile" ifname "wlP1p1s0" 2>/dev/null; then
WIFI_STATE=$(nmcli -t -f GENERAL.STATE device show "wlP1p1s0" 2>/dev/null | cut -d: -f2)
if nmcli connection up "$profile" ifname "$IFACE" 2>/dev/null; then
WIFI_STATE=$(nmcli -t -f GENERAL.STATE device show "$IFACE" 2>/dev/null | cut -d: -f2)
if echo "$WIFI_STATE" | grep -q '(connected)'; then
echo "[AP] WiFi connected to '$profile' — skipping AP mode"
exit 0
Expand Down
10 changes: 2 additions & 8 deletions src/app/setup-api/ai-models/oauth/exchange/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,6 @@ export async function POST(request: Request) {

// Anthropic accepts JSON; OpenAI and Google require form-urlencoded
const useFormEncoding = provider !== "anthropic";
const tokenController = new AbortController();
const tokenTimeout = setTimeout(() => tokenController.abort(), 30_000);

let tokenRes: Response;
try {
tokenRes = await fetch(config.tokenEndpoint, {
Expand All @@ -125,19 +122,16 @@ export async function POST(request: Request) {
body: useFormEncoding
? new URLSearchParams(exchangeBody).toString()
: JSON.stringify(exchangeBody),
signal: tokenController.signal,
signal: AbortSignal.timeout(30_000),
});
} catch (fetchErr) {
clearTimeout(tokenTimeout);
if (fetchErr instanceof DOMException && fetchErr.name === "AbortError") {
if (fetchErr instanceof DOMException && fetchErr.name === "TimeoutError") {
return NextResponse.json(
{ error: "Token exchange timed out" },
{ status: 504 }
);
}
throw fetchErr;
} finally {
clearTimeout(tokenTimeout);
}

if (!tokenRes.ok) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/setup-api/ai-models/oauth/start/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export async function POST(request: Request) {
if (provider === "google" && !isGoogleConfigured) {
return NextResponse.json(
{ error: "Google OAuth credentials not configured. Run install.sh to set them up." },
{ status: 500 }
{ status: 400 }
);
}
const config = OAUTH_PROVIDERS[provider];
Expand Down
51 changes: 37 additions & 14 deletions src/app/setup-api/system/hotspot/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from "fs/promises";
import path from "path";
import { execFile } from "child_process";
import { promisify } from "util";
import { get, set } from "@/lib/config-store";
import { get, setMany, getAll } from "@/lib/config-store";

const execFileAsync = promisify(execFile);

Expand All @@ -16,21 +16,23 @@ const HOTSPOT_ENV_PATH = path.join(
);

export async function GET() {
const ssid = ((await get("hotspot_ssid")) as string) || "ClawBox-Setup";
const hasPassword = !!(await get("hotspot_password"));
return NextResponse.json({ ssid, hasPassword });
const config = await getAll();
const ssid = (config.hotspot_ssid as string) || "ClawBox-Setup";
const hasPassword = !!config.hotspot_password;
const enabled = config.hotspot_enabled !== false;
return NextResponse.json({ ssid, hasPassword, enabled });
}

export async function POST(request: Request) {
try {
let body: { ssid?: string; password?: string };
let body: { ssid?: string; password?: string; enabled?: boolean };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}

const { ssid, password } = body;
const { ssid, password, enabled } = body;

if (!ssid || !ssid.trim()) {
return NextResponse.json(
Expand Down Expand Up @@ -60,27 +62,48 @@ export async function POST(request: Request) {
);
}

await set("hotspot_ssid", ssid.trim());
await set("hotspot_password", password || undefined);
const updates: Record<string, unknown> = {
hotspot_ssid: ssid.trim(),
hotspot_password: password || undefined,
};
if (typeof enabled === "boolean") {
updates.hotspot_enabled = enabled;
}
await setMany(updates);

const isEnabled = typeof enabled === "boolean" ? enabled : (await get("hotspot_enabled")) !== false;

// Write shell-sourceable env file for start-ap.sh
const envLines = [`HOTSPOT_SSID=${shellQuote(ssid.trim())}`];
if (password) {
envLines.push(`HOTSPOT_PASSWORD=${shellQuote(password)}`);
}
if (!isEnabled) {
envLines.push(`HOTSPOT_DISABLED=1`);
}
await fs.mkdir(path.dirname(HOTSPOT_ENV_PATH), { recursive: true });
await fs.writeFile(HOTSPOT_ENV_PATH, envLines.join("\n") + "\n", {
mode: 0o600,
});

// Restart the AP service via root-update mechanism so changes take effect
// Start or stop the AP service based on enabled state
try {
await execFileAsync("systemctl", [
"start",
"clawbox-root-update@restart_ap.service",
]);
if (isEnabled) {
await execFileAsync("systemctl", [
"start",
"clawbox-root-update@restart_ap.service",
]);
} else {
// Stop the AP — run stop-ap.sh directly since clawbox user can execute it
const stopScript = path.join(
process.env.CLAWBOX_ROOT || "/home/clawbox/clawbox",
"scripts",
"stop-ap.sh"
);
await execFileAsync("bash", [stopScript], { timeout: 15_000 });
}
} catch (apErr) {
console.warn("[hotspot] Failed to restart AP:", apErr);
console.warn("[hotspot] Failed to toggle AP:", apErr);
// Non-fatal: settings are saved for next AP start
}

Expand Down
2 changes: 2 additions & 0 deletions src/app/setup-api/update/run/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const dynamic = "force-dynamic";

import { NextResponse } from "next/server";
import { startUpdate, isUpdateCompleted } from "@/lib/updater";

Expand Down
4 changes: 2 additions & 2 deletions src/app/setup-api/wifi/connect/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { switchToClient } from "@/lib/network";
import { set } from "@/lib/config-store";
import { set, setMany } from "@/lib/config-store";

export const dynamic = "force-dynamic";

Expand All @@ -26,7 +26,7 @@ export async function POST(request: Request) {
// Actually attempt connection before responding
await switchToClient(ssid, password as string | undefined);

await set("wifi_configured", true);
await setMany({ wifi_configured: true, hotspot_enabled: false });

return NextResponse.json({
success: true,
Expand Down
37 changes: 33 additions & 4 deletions src/components/DoneStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ export default function DoneStep({ setupComplete = false }: DoneStepProps) {
const [hotspotName, setHotspotName] = useState("ClawBox-Setup");
const [hotspotPassword, setHotspotPassword] = useState("");
const [showHotspotPassword, setShowHotspotPassword] = useState(false);
const [hotspotEnabled, setHotspotEnabled] = useState(true);
const [secSaving, setSecSaving] = useState(false);
const [secStatus, setSecStatus] = useState<SectionStatusMessage | null>(null);

Expand Down Expand Up @@ -509,8 +510,9 @@ export default function DoneStep({ setupComplete = false }: DoneStepProps) {
fetch("/setup-api/system/hotspot", { signal: controller.signal })
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data && !controller.signal.aborted && data.ssid) {
setHotspotName(data.ssid);
if (data && !controller.signal.aborted) {
if (data.ssid) setHotspotName(data.ssid);
if (typeof data.enabled === "boolean") setHotspotEnabled(data.enabled);
}
})
.catch(() => {});
Expand Down Expand Up @@ -645,7 +647,7 @@ export default function DoneStep({ setupComplete = false }: DoneStepProps) {
return;
}
}
if (!hotspotName.trim()) {
if (hotspotEnabled && !hotspotName.trim()) {
setSecStatus({ type: "error", message: "Hotspot name is required" });
return;
}
Expand Down Expand Up @@ -675,6 +677,7 @@ export default function DoneStep({ setupComplete = false }: DoneStepProps) {
body: JSON.stringify({
ssid: hotspotName.trim(),
password: hotspotPassword || undefined,
enabled: hotspotEnabled,
}),
});
if (!hotspotRes.ok) {
Expand Down Expand Up @@ -1322,6 +1325,10 @@ export default function DoneStep({ setupComplete = false }: DoneStepProps) {
autoComplete="off"
/>
</div>
<p className="text-[11px] text-amber-400/80 leading-relaxed">
<span className="font-semibold">Note:</span> Connecting to WiFi will stop the hotspot.
{"You'll"} need to reach the device via your WiFi network at <span className="font-semibold">http://clawbox.local</span>.
</p>
Comment thread
yalexx marked this conversation as resolved.
Resolved
{wifiStatus && <StatusMessage type={wifiStatus.type} message={wifiStatus.message} />}
<button
type="button"
Expand Down Expand Up @@ -1545,8 +1552,30 @@ export default function DoneStep({ setupComplete = false }: DoneStepProps) {
/>
</div>
<div className="border-t border-[var(--border-subtle)] pt-3">
<div className="flex items-center justify-between mb-3">
<label htmlFor="hs-toggle" className="text-xs font-semibold text-[var(--text-secondary)]">Hotspot</label>
<button
id="hs-toggle"
type="button"
role="switch"
aria-checked={hotspotEnabled}
onClick={() => setHotspotEnabled((v) => !v)}
className={`relative w-10 h-[22px] rounded-full transition-colors cursor-pointer border-none ${hotspotEnabled ? "bg-[var(--coral-bright)]" : "bg-gray-600"}`}
>
<span className={`absolute top-[2px] left-[2px] w-[18px] h-[18px] rounded-full bg-white transition-transform shadow-sm ${hotspotEnabled ? "translate-x-[18px]" : ""}`} />
</button>
</div>
{!hotspotEnabled ? (
<p className="text-[11px] text-amber-400/80 leading-relaxed mb-3">
Hotspot will be disabled on next boot. The device will only be reachable via WiFi or Ethernet.
</p>
) : (
<p className="text-[11px] text-amber-400/80 leading-relaxed mb-3">
Enabling the hotspot will disconnect WiFi. The device will be reachable via the hotspot or Ethernet at <span className="font-semibold">http://clawbox.local</span>.
</p>
)}
<label htmlFor="hs-name" className={LABEL_CLASS}>Hotspot Name</label>
<input id="hs-name" type="text" value={hotspotName} onChange={(e) => setHotspotName(e.target.value)} maxLength={32} className={INPUT_CLASS} />
<input id="hs-name" type="text" value={hotspotName} onChange={(e) => setHotspotName(e.target.value)} maxLength={32} className={INPUT_CLASS} disabled={!hotspotEnabled} />
</div>
Comment thread
yalexx marked this conversation as resolved.
Resolved
<div>
<label htmlFor="hs-pw" className={LABEL_CLASS}>Hotspot Password <span className="text-[var(--text-muted)] font-normal">(optional)</span></label>
Expand Down
8 changes: 7 additions & 1 deletion src/components/WifiStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,13 @@ export default function WifiStep({ onNext }: WifiStepProps) {
</div>
)}

<div className="flex items-center gap-3 mt-5">
<p className="text-xs text-amber-400/80 mt-4 leading-relaxed">
<span className="font-semibold">Note:</span> Connecting to WiFi will stop the ClawBox-Setup hotspot.
You will lose this connection and need to join your home WiFi to continue setup at{" "}
<span className="font-semibold">http://clawbox.local</span>.
</p>

<div className="flex items-center gap-3 mt-3">
<button
type="button"
onClick={connectWifi}
Expand Down
11 changes: 5 additions & 6 deletions src/lib/gateway-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,11 @@ async function getGatewayToken(): Promise<string> {
export async function serveGatewayHTML(
request: NextRequest
): Promise<NextResponse> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
try {
const [res, gatewayToken] = await Promise.all([
fetch(`http://127.0.0.1:${GATEWAY_PORT}/`, {
cache: "no-store",
signal: controller.signal,
signal: AbortSignal.timeout(3000),
}),
getGatewayToken(),
]);
Expand All @@ -71,7 +69,10 @@ export async function serveGatewayHTML(
let html = await res.text();

const safeToken = gatewayToken
? JSON.stringify(gatewayToken).replace(/</g, "\\u003c")
? JSON.stringify(gatewayToken)
.replace(/&/g, "\\u0026")
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
: "";
const tokenScript = safeToken
? `<script id="clawbox-token" type="application/json">${safeToken}</script>
Expand All @@ -97,7 +98,5 @@ export async function serveGatewayHTML(
});
} catch {
return redirectToSetup(request);
} finally {
clearTimeout(timeout);
}
}
Loading
Loading