Skip to content
Open
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
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
"@fontsource/noto-sans-sc": "5.3.0",
"@monaco-editor/react": "4.7.0",
"@openclaw/carapace": "git+https://github.com/openclaw/carapace.git#v0.2.0",
"@openclaw/krillswitch-react": "0.0.1",
"@openclaw/plugin-inspector": "0.3.17",
"@radix-ui/react-avatar": "1.2.3",
"@radix-ui/react-dialog": "1.1.20",
Expand Down
1 change: 1 addition & 0 deletions specs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ into `docs/` and leave only the design record here.
- `manual-testing.md`: maintainer CLI smoke checklist.
- `dev-worktrees.md`: disposable Worktrunk/Codex worktree lifecycle contract.
- `dev-seeding.md`: local development fixture seeding ownership rules.
- `feature-flags.md`: Krill Switch SSR, hydration, identity, and fallback contract.
- `mintlify.md`: docs publishing setup notes.
- `openclaw-docs-extraction.md`: CLAW-89 extraction classification.
- `deploy.md`: maintainer deploy checklist for the ClawHub project.
Expand Down
4 changes: 4 additions & 0 deletions specs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ Set env vars:
- `CONVEX_SITE_URL` (same value; used by auth provider config)
- `SITE_URL` (web app URL)
- `VITE_APP_BUILD_SHA` (set to the same commit SHA stamped into Convex)
- `VITE_KRILLSWITCH_EVAL_KEY` (optional public Krill Switch environment key;
code defaults are used when absent)
- `VITE_KRILLSWITCH_BASE_URL` (optional; defaults to
`https://flags.openclaw.ai`)

Deploy order:

Expand Down
35 changes: 35 additions & 0 deletions specs/feature-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Feature flags

ClawHub evaluates release flags through Krill Switch during server rendering,
then keeps them fresh in the browser. Flags are not an authorization or security
boundary: protected operations must continue to enforce their rules in Convex
and HTTP handlers.

## Runtime contract

- The root server loader calls `POST https://flags.openclaw.ai/v1/eval` using the
public environment evaluation key from `VITE_KRILLSWITCH_EVAL_KEY`, then
serializes those values for hydration. Visible flagged content must not render
a different code default before hydration.
- `VITE_KRILLSWITCH_BASE_URL` can override the evaluation origin for local
testing. It defaults to the production evaluation host.
- Missing configuration, network errors, invalid payloads, and incompatible
remote value types preserve code-owned defaults. Server evaluation has a
200 ms budget and must not block rendering beyond it.
- Evaluations use an anonymous context key persisted in a first-party HTTP-only
cookie and passed to the hydrated provider. The server and browser must use
the same key so targeting and percentage rollouts remain stable. Do not add
personal or sensitive attributes without documenting why targeting needs them.
- Values refresh when the page becomes visible and every 60 seconds. ETags
avoid retransmitting unchanged evaluations.

The official `@openclaw/krillswitch-react` SDK owns response validation, typed
manifest merging, SSR evaluation, hydration bootstrap, caching, and polling.
Keep ClawHub's adapter limited to runtime configuration and app-specific flags.

## Initial proof flag

The `souls` boolean flag defaults to `false`. When enabled, the home hero
subtitle changes from “Discover skills and plugins from top creators” to
“Discover skills and plugins built with soul.” This is intentionally a safe,
copy-only proof that can be toggled without changing application behavior.
33 changes: 22 additions & 11 deletions src/components/AppProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
routeToBannedAccountPage as navigateToBannedAccountPage,
} from "../lib/authErrorMessage";
import { isCliDeviceUserCode } from "../lib/cliDeviceCode";
import { FeatureFlagProvider, type FeatureFlagValues } from "../lib/featureFlags";
import { clearAuthError, setAuthError, useAuthError } from "../lib/useAuthError";
import { AuthErrorMessage } from "./AuthErrorMessage";
import { ClientOnly } from "./ClientOnly";
Expand Down Expand Up @@ -180,19 +181,29 @@ export function AuthErrorToast() {
return null;
}

export function AppProviders({ children }: { children: React.ReactNode }) {
export function AppProviders({
children,
featureFlagContextKey,
initialFeatureFlags,
}: {
children: React.ReactNode;
featureFlagContextKey: string;
initialFeatureFlags: FeatureFlagValues | null;
}) {
return (
<ConvexAuthProvider client={convex} shouldHandleCode={false}>
<TooltipProvider delayDuration={400}>
<AuthCodeHandler />
<AuthErrorHandler />
<AuthErrorToast />
<UserBootstrap />
{children}
<ClientOnly>
<DevPersonaFab />
</ClientOnly>
</TooltipProvider>
<FeatureFlagProvider contextKey={featureFlagContextKey} initialValues={initialFeatureFlags}>
<TooltipProvider delayDuration={400}>
<AuthCodeHandler />
<AuthErrorHandler />
<AuthErrorToast />
<UserBootstrap />
{children}
<ClientOnly>
<DevPersonaFab />
</ClientOnly>
</TooltipProvider>
</FeatureFlagProvider>
</ConvexAuthProvider>
);
}
7 changes: 7 additions & 0 deletions src/lib/featureFlagManifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const FEATURE_FLAG_DEFAULTS: FeatureFlagValues = {
souls: false,
};

export type FeatureFlagValues = {
souls: boolean;
};
67 changes: 67 additions & 0 deletions src/lib/featureFlags.functions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { createKrillswitchEvaluator } from "@openclaw/krillswitch-react/server";
import { createServerFn } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import { FEATURE_FLAG_DEFAULTS, type FeatureFlagValues } from "./featureFlagManifest";
import { getRuntimeEnv, isDevRuntime } from "./runtimeEnv";

const DEFAULT_KRILLSWITCH_BASE_URL = "https://flags.openclaw.ai";
const FEATURE_FLAG_CONTEXT_COOKIE = "clawhub-feature-flag-context";
const FEATURE_FLAG_CONTEXT_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
const SSR_EVALUATION_TIMEOUT_MS = 200;

const evaluateFlags = createKrillswitchEvaluator(FEATURE_FLAG_DEFAULTS);

type InitialFeatureFlags = {
contextKey: string;
values: FeatureFlagValues | null;
};

function getOrCreateContextKey(): string {
const existing = getCookie(FEATURE_FLAG_CONTEXT_COOKIE)?.trim();
if (existing) return existing;

const contextKey = `anon-${crypto.randomUUID()}`;
setCookie(FEATURE_FLAG_CONTEXT_COOKIE, contextKey, {
httpOnly: true,
maxAge: FEATURE_FLAG_CONTEXT_MAX_AGE_SECONDS,
path: "/",
sameSite: "lax",
secure: !isDevRuntime(),
});
return contextKey;
}

export async function evaluateInitialFeatureFlags(args: {
baseUrl: string;
contextKey: string;
evalKey: string;
signal: AbortSignal;
}): Promise<FeatureFlagValues> {
return await evaluateFlags({
baseUrl: args.baseUrl,
context: { key: args.contextKey },
evalKey: args.evalKey,
signal: args.signal,
});
}

export const loadInitialFeatureFlags = createServerFn({ method: "GET" }).handler(
async (): Promise<InitialFeatureFlags> => {
const contextKey = getOrCreateContextKey();
const evalKey = getRuntimeEnv("VITE_KRILLSWITCH_EVAL_KEY");
if (!evalKey) return { contextKey, values: null };

try {
const values = await evaluateInitialFeatureFlags({
baseUrl: getRuntimeEnv("VITE_KRILLSWITCH_BASE_URL") ?? DEFAULT_KRILLSWITCH_BASE_URL,
contextKey,
evalKey,
signal: AbortSignal.timeout(SSR_EVALUATION_TIMEOUT_MS),
});
return { contextKey, values };
} catch (error) {
console.warn("Krill Switch SSR evaluation failed; using code defaults.", error);
return { contextKey, values: null };
}
},
);
40 changes: 40 additions & 0 deletions src/lib/featureFlags.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { evaluateInitialFeatureFlags } from "./featureFlags.functions";

const fetchMock = vi.fn<typeof fetch>();

describe("server feature flag evaluation", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("evaluates the manifest with the stable SSR context", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ flags: { souls: { value: true } } }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);

await expect(
evaluateInitialFeatureFlags({
baseUrl: "https://flags.openclaw.ai",
contextKey: "anon-stable-context",
evalKey: "ks_clawhub_production_public",
signal: new AbortController().signal,
}),
).resolves.toEqual({ souls: true });

expect(fetchMock).toHaveBeenCalledWith(
"https://flags.openclaw.ai/v1/eval",
expect.objectContaining({
body: JSON.stringify({ context: { key: "anon-stable-context" } }),
headers: expect.objectContaining({
authorization: "Bearer ks_clawhub_production_public",
}),
method: "POST",
}),
);
});
});
86 changes: 86 additions & 0 deletions src/lib/featureFlags.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/* @vitest-environment jsdom */

import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FeatureFlagProvider, useFeatureFlag } from "./featureFlags";

const fetchMock = vi.fn<typeof fetch>();

function evalResponse(value: unknown): Response {
return new Response(JSON.stringify({ flags: { souls: { value } } }), {
status: 200,
headers: { "content-type": "application/json" },
});
}

function FlagProbe() {
return <span>{useFeatureFlag("souls") ? "has soul" : "safe default"}</span>;
}

describe("feature flags", () => {
beforeEach(() => {
localStorage.clear();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("hydrates from server values without rendering the code default first", () => {
fetchMock.mockReturnValueOnce(new Promise<Response>(() => {}));
const observedValues: boolean[] = [];

function FirstRenderProbe() {
observedValues.push(useFeatureFlag("souls"));
return <FlagProbe />;
}

render(
<FeatureFlagProvider
baseUrl="https://flags.openclaw.ai"
contextKey="user-123"
evalKey="ks_clawhub_production_public"
initialValues={{ souls: true }}
>
<FirstRenderProbe />
</FeatureFlagProvider>,
);

expect(observedValues[0]).toBe(true);
expect(screen.getByText("has soul")).toBeTruthy();
});

it("applies a successful browser refresh after hydration", async () => {
fetchMock.mockResolvedValueOnce(evalResponse(false));

render(
<FeatureFlagProvider
baseUrl="https://flags.openclaw.ai"
contextKey="user-123"
evalKey="ks_clawhub_production_public"
initialValues={{ souls: true }}
>
<FlagProbe />
</FeatureFlagProvider>,
);

expect(screen.getByText("has soul")).toBeTruthy();
await act(async () => {
await Promise.resolve();
});
expect(screen.getByText("safe default")).toBeTruthy();
});

it("renders code defaults without contacting Krill when no evaluation key is configured", () => {
render(
<FeatureFlagProvider evalKey="">
<FlagProbe />
</FeatureFlagProvider>,
);

expect(screen.getByText("safe default")).toBeTruthy();
expect(fetchMock).not.toHaveBeenCalled();
});
});
43 changes: 43 additions & 0 deletions src/lib/featureFlags.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createKrillswitch } from "@openclaw/krillswitch-react";
import type { ReactNode } from "react";
import { FEATURE_FLAG_DEFAULTS, type FeatureFlagValues } from "./featureFlagManifest";
import { getRuntimeEnv } from "./runtimeEnv";

const DEFAULT_KRILLSWITCH_BASE_URL = "https://flags.openclaw.ai";
const krill = createKrillswitch(FEATURE_FLAG_DEFAULTS);

export type { FeatureFlagValues } from "./featureFlagManifest";
export const useFeatureFlag = krill.useFeatureFlag;

export function FeatureFlagProvider({
baseUrl,
children,
contextKey,
evalKey,
initialValues,
pollIntervalMs,
}: {
baseUrl?: string;
children: ReactNode;
contextKey?: string;
evalKey?: string;
initialValues?: Partial<FeatureFlagValues> | null;
pollIntervalMs?: number;
}) {
const resolvedEvalKey = evalKey ?? getRuntimeEnv("VITE_KRILLSWITCH_EVAL_KEY");
if (!resolvedEvalKey) return children;

return (
<krill.FeatureFlagProvider
baseUrl={
baseUrl ?? getRuntimeEnv("VITE_KRILLSWITCH_BASE_URL") ?? DEFAULT_KRILLSWITCH_BASE_URL
}
contextKey={contextKey}
evalKey={resolvedEvalKey}
initialValues={initialValues}
pollIntervalMs={pollIntervalMs}
>
{children}
</krill.FeatureFlagProvider>
);
}
Loading
Loading