Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
38 changes: 38 additions & 0 deletions scripts/gateway-pre-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,44 @@ else:
PY
fi

# Ensure @openclaw/codex runtime plugin is installed if any agent uses
# the openai-codex provider. OpenClaw 2026.5.12 split the codex harness
# out of the core gateway into a separate npm package and only auto-
# installs it during `openclaw onboard --auth-choice openai-codex…`.
# Our configure route writes openclaw.json directly (see the schema-
# drift note in src/app/setup-api/ai-models/configure/route.ts), so
# devices that pick a Codex model never trigger the install and the
# gateway logs `Requested agent harness "codex" is not registered` on
# every chat attempt. Detect the codex provider in config and install
# the plugin idempotently here — mirrors OpenClaw's own
# `modelSelectionShouldEnsureCodexPlugin` detection logic.
CODEX_PLUGIN_DIR="/home/clawbox/.openclaw/npm/node_modules/@openclaw/codex"
NEEDS_CODEX_PLUGIN="$(python3 - "$OPENCLAW_CONFIG" <<'PY'
import json, sys
try:
with open(sys.argv[1]) as f:
cfg = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
print("0"); sys.exit(0)
primary = (cfg.get("agents", {}).get("defaults", {}).get("model", {}) or {}).get("primary") or ""
profiles = cfg.get("auth", {}).get("profiles", {}) or {}
uses_codex = (
isinstance(primary, str) and primary.lower().startswith("openai-codex/")
) or any(
(isinstance(k, str) and k.lower().startswith("openai-codex:")) or
(isinstance(v, dict) and isinstance(v.get("provider"), str)
and v["provider"].lower() == "openai-codex")
for k, v in profiles.items()
)
Comment on lines +309 to +318

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add defensive isinstance check for auth to match existing pattern.

Line 310 will raise AttributeError if cfg["auth"] exists but is not a dict (e.g., None or a corrupted value), since the exception handler only catches FileNotFoundError and JSONDecodeError. This would silently skip the codex install, causing confusing "harness not registered" errors.

The existing code at line 131 uses the defensive pattern—this should match:

Proposed fix
 primary = (cfg.get("agents", {}).get("defaults", {}).get("model", {}) or {}).get("primary") or ""
-profiles = cfg.get("auth", {}).get("profiles", {}) or {}
+auth = cfg.get("auth")
+profiles = auth.get("profiles", {}) if isinstance(auth, dict) else {}
+profiles = profiles if isinstance(profiles, dict) else {}
 uses_codex = (

As per coding guidelines: "Review for proper error handling" — the current pattern is inconsistent with line 131's defensive approach.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/gateway-pre-start.sh` around lines 309 - 318, The code should
defensively ensure cfg["auth"] is a dict before calling .get to avoid
AttributeError; change how profiles is derived so it uses the existing pattern
(e.g., auth = cfg.get("auth"); use auth if isinstance(auth, dict) else {}), then
set profiles = auth.get("profiles", {}) or {}; keep the rest of the uses_codex
logic but rely on profiles being a dict so profiles.items() is safe and
reference the variables primary, profiles, and uses_codex when making the
change.

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 \
|| echo " WARN: openclaw plugins install codex failed; Codex chats will fail until resolved"
fi

# Register ClawBox MCP server (only if not already set). Kept as a CLI
# call because MCP config has richer schema validation in the CLI path
# and we only hit this branch on fresh installs / factory resets.
Expand Down
2 changes: 2 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import dynamic from "next/dynamic";
import * as kv from "@/lib/client-kv";
import TierUpgradeCelebration from "@/components/TierUpgradeCelebration";
import { OPEN_APP_EVENT, FIX_ERROR_EVENT } from "@/lib/ui-events";
import { purgeLegacyChatCaches } from "@/lib/chat-history-cache";
import ChromeShelf from "@/components/ChromeShelf";
Expand Down Expand Up @@ -1533,6 +1534,7 @@ function ChromeDesktopInner() {
onDragLeave={handleDesktopDragLeave}
onDrop={handleDesktopDrop}
>
<TierUpgradeCelebration />
{/* Drop overlay */}
{desktopDragOver && (
<div className="fixed inset-0 z-[99998] flex items-center justify-center bg-black/60 backdrop-blur-sm pointer-events-none">
Expand Down
24 changes: 13 additions & 11 deletions src/app/setup-api/ai-models/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,15 @@ function rememberTier(token: string, tier: ClawboxAiTier | null, now: number) {
* @returns The badge-facing tier, or `null` for Free.
*/
function mapPortalTier(body: DeviceInfoResponse): ClawboxAiTier | null {
const plan = (body.tier ?? "").trim().toLowerCase();
// Subscription plan is the source of truth — a stale or bogus
// deviceTier stamp on a Free account must never grant a paid badge.
if (plan !== "pro" && plan !== "max") return null;
// Paid: prefer the explicit device-pair stamp (lets Max subs run
// flash); otherwise map plan → device tier.
const stamped = normalizeClawboxAiTier(body.deviceTier);
if (stamped) return stamped;
const plan = (body.tier ?? "").trim().toLowerCase();
if (plan === "max") return "pro";
if (plan === "pro") return "flash";
return null;
return plan === "max" ? "pro" : "flash";
}

/**
Expand Down Expand Up @@ -247,13 +250,12 @@ export async function GET() {
let accountTierSource: "portal" | "picker" = "picker";
if (hasClawaiProfile) {
clawaiAccountTier = localTier;
// Defence-in-depth: if the user never authorised a paid tier
// locally (localTier === null), keep the account tier null
// regardless of what the portal stamps. Skips the portal call
// entirely on the Free path — saves a 4 s cold-cache timeout
// on the render-path GET and avoids the portal-upgrade bug
// where a Free user gets deviceTier="flash" issued.
if (localTier !== null && clawaiToken) {
// Ask the portal whenever a clawai token is paired, regardless
// of whether we have a local tier yet. This is what makes
// Free → Paid upgrades visible without forcing a re-login.
// mapPortalTier now gates non-null returns on a paid plan, so
// a bogus deviceTier stamp can no longer promote a Free user.
if (clawaiToken) {
const lookup = await fetchPortalTier(clawaiToken);
if (lookup.source === "portal") {
clawaiAccountTier = lookup.tier;
Expand Down
4 changes: 2 additions & 2 deletions src/components/ChatApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,8 @@ function ChatApp({ onThinkingChange, hideHeader = false }: ChatAppProps) {
ws.send(JSON.stringify({
type: 'req', id, method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
minProtocol: 4,
maxProtocol: 4,
client: {
id: 'openclaw-control-ui',
version: 'clawbox-chat',
Expand Down
4 changes: 2 additions & 2 deletions src/components/ChatPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -674,8 +674,8 @@ function ChatPopup({ isOpen, onClose, onOpenFull, onOpenSettingsSection, onThink
ws.send(JSON.stringify({
type: 'req', id, method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
minProtocol: 4,
maxProtocol: 4,
client: {
id: 'openclaw-control-ui',
version: 'clawbox-chat',
Expand Down
213 changes: 213 additions & 0 deletions src/components/TierUpgradeCelebration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
"use client";

import { useEffect, useState, type MouseEvent, type ReactNode } from "react";
import { useClawboxLogin } from "@/lib/use-clawbox-login";
import { useT } from "@/lib/i18n";
import { PORTAL_DASHBOARD_URL } from "@/lib/max-subscription";
import * as kv from "@/lib/client-kv";

// Announces account-tier transitions:
// - upgrade (Free → Pro → Max): celebratory modal, shown once per tier
// - downgrade to Free (paid → Free): informational "plan is now Free" modal
// - intermediate downgrade (Max → Pro): silent
//
// Backed by client-kv so a poll re-reporting the same tier doesn't reopen
// the dialog, and so a later climb back to the same paid tier doesn't
// re-celebrate something the user already saw. A real cancel + resubscribe
// flow *does* re-celebrate, because the downgrade resets the seen marker.

const SEEN_KEY = "clawai_tier_seen";

// Tier ordering. Keep aligned with normalizeClawboxAiTier on the server
// (flash = Pro plan, pro = Max plan).
const TIER_RANK: Record<string, number> = { flash: 1, pro: 2 };

function rankOf(tier: string | null | undefined): number {
if (!tier) return 0;
return TIER_RANK[tier] ?? 0;
}

type DialogState =
| { kind: "upgrade"; tier: "flash" | "pro" }
| { kind: "downgrade-free" };

// Per-dialog content + presentation. Keyed by the upgrade tier
// ("flash" = Pro plan, "pro" = Max plan) or "free" for the downgrade.
// Translation keys are referenced literally here so the i18n
// invariant test can still discover them.
type ContentKey = "flash" | "pro" | "free";
type Tone = "paid" | "muted";

interface DialogContent {
titleId: string;
tone: Tone;
badgeKey: string;
headlineKey: string;
bodyKey: string;
}

const CONTENT: Record<ContentKey, DialogContent> = {
flash: {
titleId: "tier-upgrade-title",
tone: "paid",
badgeKey: "tierCelebration.proBadge",
headlineKey: "tierCelebration.proHeadline",
bodyKey: "tierCelebration.proBody",
},
pro: {
titleId: "tier-upgrade-title",
tone: "paid",
badgeKey: "tierCelebration.maxBadge",
headlineKey: "tierCelebration.maxHeadline",
bodyKey: "tierCelebration.maxBody",
},
free: {
titleId: "tier-downgrade-title",
tone: "muted",
badgeKey: "tierCelebration.freeBadge",
headlineKey: "tierCelebration.freeHeadline",
bodyKey: "tierCelebration.freeBody",
},
};

export default function TierUpgradeCelebration() {
const { tier, loading } = useClawboxLogin();
const { t } = useT();
const [dialog, setDialog] = useState<DialogState | null>(null);

useEffect(() => {
if (loading) return;
const seen = kv.get(SEEN_KEY) || "";
const currentRank = rankOf(tier);
const seenRank = rankOf(seen);

// Upgrade to a paid tier we haven't celebrated yet.
if (currentRank > seenRank && (tier === "flash" || tier === "pro")) {
setDialog({ kind: "upgrade", tier });
return;
}
// Downgrade from any paid tier to Free.
if (currentRank === 0 && seenRank > 0) {
setDialog({ kind: "downgrade-free" });
return;
}
// Intermediate downgrade (Max → Pro) or no-change tick: silently
// sync `seen` so a later climb back to the same tier doesn't re-fire
// the celebration the user already saw.
if (seen !== (tier ?? "")) kv.set(SEEN_KEY, tier ?? "");
}, [tier, loading]);

if (!dialog) return null;

const onClose = () => {
if (dialog.kind === "upgrade") {
kv.set(SEEN_KEY, dialog.tier);
} else {
kv.set(SEEN_KEY, "");
}
setDialog(null);
};
Comment on lines +74 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Use the shared useWindows.ts reducer pattern for this modal’s lifecycle state.

dialog open/close is managed locally with useState, which diverges from the required window-state reducer pattern for component windows/modals.

As per coding guidelines, src/components/**/*.tsx: Use the useWindows.ts reducer pattern for managing window state (open, minimize, maximize, close, focus).

🧰 Tools
🪛 ESLint

[error] 97-97: Error: Calling setState synchronously within an effect can trigger cascading renders

Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:

  • Update external systems with the latest state from React.
  • Subscribe for updates from some external system, calling setState in a callback function when external state changes.

Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).

/home/jailuser/git/src/components/TierUpgradeCelebration.tsx:97:7
95 | // Upgrade to a paid tier we haven't celebrated yet.
96 | if (currentRank > seenRank && (tier === "flash" || tier === "pro")) {

97 | setDialog({ kind: "upgrade", tier });
| ^^^^^^^^^ Avoid calling setState() directly within an effect
98 | return;
99 | }
100 | // Downgrade from any paid tier to Free.

(react-hooks/set-state-in-effect)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/TierUpgradeCelebration.tsx` around lines 74 - 120, The
component TierUpgradeCelebration currently uses local useState for dialog
lifecycle; replace that with the shared window reducer pattern from
useWindows.ts: remove useState<DialogState|null> dialog and setDialog usage and
instead register this modal with the useWindows reducer (import useWindows and
the window action creators), dispatch the "open" action when you need to show
the upgrade/downgrade dialog (use the same payload shape { kind: "upgrade", tier
} or { kind: "downgrade-free" }) and dispatch the "close" action in onClose (and
update SEEN_KEY via the reducer close handler or via the close action
side-effect). Ensure rankOf/tier/loading logic still computes when to dispatch
open (replace setDialog calls with dispatch open) and keep kv.set(SEEN_KEY, ...)
behavior triggered on close via the window close handler or by dispatching an
action that includes the seen value; reference TierUpgradeCelebration,
useClawboxLogin, SEEN_KEY, FREE_SEEN_VALUE, rankOf and integrate with the
existing useWindows reducer API for open/minimize/maximize/close semantics.


const contentKey: ContentKey = dialog.kind === "upgrade" ? dialog.tier : "free";
const content = CONTENT[contentKey];
const primary = dialog.kind === "upgrade" ? (
<button
type="button"
onClick={onClose}
autoFocus
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl btn-gradient text-sm font-medium text-white cursor-pointer w-full"
>
{t("tierCelebration.upgradeCta")}
</button>
) : (
<div className="flex flex-col gap-2">
<a
href={PORTAL_DASHBOARD_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => setTimeout(onClose, 50)}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl btn-gradient text-sm font-medium text-white cursor-pointer no-underline"
>
<span className="material-symbols-rounded" style={{ fontSize: 18 }}>open_in_new</span>
{t("tierCelebration.resubscribe")}
</a>
<button
type="button"
onClick={onClose}
autoFocus
className="px-4 py-2 rounded-xl text-sm text-white/60 hover:text-white/85 hover:bg-white/[0.04] cursor-pointer"
>
{t("tierCelebration.freeCta")}
</button>
</div>
);

return (
<CelebrationShell
titleId={content.titleId}
tone={content.tone}
badge={t(content.badgeKey)}
headline={t(content.headlineKey)}
body={t(content.bodyKey)}
onClose={onClose}
primary={primary}
/>
);
}

interface ShellProps {
titleId: string;
tone: Tone;
badge: string;
headline: string;
body: string;
primary: ReactNode;
onClose: () => void;
}

function CelebrationShell({ titleId, tone, badge, headline, body, primary, onClose }: ShellProps) {
const glowClass = tone === "paid"
? "drop-shadow-[0_0_18px_rgba(217,70,239,0.6)]"
: "drop-shadow-[0_0_12px_rgba(255,255,255,0.18)]";
const badgeClass = tone === "paid"
? "bg-gradient-to-r from-fuchsia-500 to-pink-500 text-white shadow-[0_4px_12px_rgba(217,70,239,0.3)]"
: "bg-white/10 text-white/70 border border-white/15";

return (
<div
className="fixed inset-0 z-[100001] flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={onClose}
Comment on lines +188 to +193

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add Escape dismissal and dialog description linkage for keyboard/screen-reader completeness.

♿ Suggested accessibility patch
   return (
     <div
       className="fixed inset-0 z-[100001] flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
       role="dialog"
       aria-modal="true"
       aria-labelledby={titleId}
+      aria-describedby={`${titleId}-description`}
+      onKeyDown={(e) => {
+        if (e.key === "Escape") onClose();
+      }}
       onClick={onClose}
     >
@@
-          <p className="text-sm text-white/65 leading-relaxed">{body}</p>
+          <p id={`${titleId}-description`} className="text-sm text-white/65 leading-relaxed">{body}</p>

As per coding guidelines, src/components/**: React 19 components with Tailwind CSS v4. Review for accessibility, proper state management, and XSS prevention.

Also applies to: 215-219

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/TierUpgradeCelebration.tsx` around lines 188 - 193, The dialog
overlay in TierUpgradeCelebration lacks keyboard dismissal and a description
link for assistive tech; update the component to (1) generate stable IDs (useId
or similar) for titleId and a new descriptionId and add
aria-describedby={descriptionId} on the root dialog, (2) add a useEffect that
registers a keydown listener to call onClose when Escape is pressed and cleans
up on unmount, and (3) change the overlay onClick to only call onClose when the
click target equals the overlay (e.target === e.currentTarget) so inner content
clicks don't close it; ensure the description element uses the descriptionId and
avoid injecting unescaped HTML (use safe text) to prevent XSS.

>
<div
onClick={(e: MouseEvent) => e.stopPropagation()}
className="w-full max-w-md rounded-2xl border border-white/10 bg-[#0f1219] p-6 shadow-2xl text-center"
>
<div className="flex flex-col items-center gap-4 mb-5">
<img
src="/clawbox-crab.png"
alt=""
width={72}
height={72}
className={"select-none pointer-events-none " + glowClass}
/>
<span
className={
"inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider " +
badgeClass
}
>
{badge}
</span>
<h2 id={titleId} className="text-lg font-semibold text-white">
{headline}
</h2>
<p className="text-sm text-white/65 leading-relaxed">{body}</p>
</div>
{primary}
</div>
</div>
);
}
36 changes: 36 additions & 0 deletions src/lib/desktop-translations-part1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,18 @@ export const bg: Record<string, string> = {
"updateNotification.viewUpdate": "Виж актуализацията",
"updateNotification.later": "По-късно",
"updateNotification.dismiss": "Затвори",
"tierCelebration.proBadge": "План Pro",
"tierCelebration.proHeadline": "Добре дошъл в ClawBox Pro 🎉",
"tierCelebration.proBody": "Акаунтът ти вече е в план Pro. Облачните копия с ClawKeep, Remote Desktop и DeepSeek V4 Flash са отключени.",
"tierCelebration.maxBadge": "План Max",
"tierCelebration.maxHeadline": "Добре дошъл в ClawBox Max 🎉",
"tierCelebration.maxBody": "Акаунтът ти вече е в план Max — пълният фронт. DeepSeek V4 Pro, неограничен ClawKeep, Remote Desktop, всичко е отворено.",
"tierCelebration.upgradeCta": "Да започваме",
"tierCelebration.freeBadge": "Безплатен план",
"tierCelebration.freeHeadline": "Вече си в безплатния план",
"tierCelebration.freeBody": "Облачните копия с ClawKeep и Remote Desktop са заключени, но ClawBox продължава да работи с локалния модел. Можеш да се абонираш отново по всяко време.",
"tierCelebration.freeCta": "Разбрах",
"tierCelebration.resubscribe": "Подновяване",
};

export const de: Record<string, string> = {
Expand Down Expand Up @@ -688,6 +700,18 @@ export const de: Record<string, string> = {
"updateNotification.viewUpdate": "Update anzeigen",
"updateNotification.later": "Später",
"updateNotification.dismiss": "Schließen",
"tierCelebration.proBadge": "Pro-Plan",
"tierCelebration.proHeadline": "Willkommen bei ClawBox Pro 🎉",
"tierCelebration.proBody": "Dein Konto ist jetzt im Pro-Plan. ClawKeep-Cloud-Backups, Remote Desktop und DeepSeek V4 Flash sind freigeschaltet.",
"tierCelebration.maxBadge": "Max-Plan",
"tierCelebration.maxHeadline": "Willkommen bei ClawBox Max 🎉",
"tierCelebration.maxBody": "Dein Konto ist jetzt im Max-Plan — die volle Bandbreite. DeepSeek V4 Pro, unbegrenztes ClawKeep, Remote Desktop, alles ist offen.",
"tierCelebration.upgradeCta": "Los geht’s",
"tierCelebration.freeBadge": "Kostenloser Plan",
"tierCelebration.freeHeadline": "Du bist jetzt im kostenlosen Plan",
"tierCelebration.freeBody": "ClawKeep-Cloud-Backups und Remote Desktop sind gesperrt, aber deine ClawBox läuft weiter mit dem lokalen Modell. Du kannst jederzeit erneut abonnieren.",
"tierCelebration.freeCta": "Verstanden",
"tierCelebration.resubscribe": "Erneut abonnieren",
};

export const es: Record<string, string> = {
Expand Down Expand Up @@ -1034,4 +1058,16 @@ export const es: Record<string, string> = {
"updateNotification.viewUpdate": "Ver actualización",
"updateNotification.later": "Más tarde",
"updateNotification.dismiss": "Descartar",
"tierCelebration.proBadge": "Plan Pro",
"tierCelebration.proHeadline": "Bienvenido a ClawBox Pro 🎉",
"tierCelebration.proBody": "Tu cuenta ahora está en el plan Pro. Copias en la nube de ClawKeep, Remote Desktop y DeepSeek V4 Flash están desbloqueados.",
"tierCelebration.maxBadge": "Plan Max",
"tierCelebration.maxHeadline": "Bienvenido a ClawBox Max 🎉",
"tierCelebration.maxBody": "Tu cuenta ahora está en el plan Max — toda la frontera. DeepSeek V4 Pro, ClawKeep ilimitado, Remote Desktop, todas las puertas abiertas.",
"tierCelebration.upgradeCta": "Vamos",
"tierCelebration.freeBadge": "Plan gratuito",
"tierCelebration.freeHeadline": "Ahora estás en el plan gratuito",
"tierCelebration.freeBody": "Las copias en la nube de ClawKeep y Remote Desktop están bloqueadas, pero tu ClawBox sigue funcionando con el modelo local. Puedes volver a suscribirte cuando quieras.",
"tierCelebration.freeCta": "Entendido",
"tierCelebration.resubscribe": "Volver a suscribirme",
};
Loading
Loading