From edffbac1e4099a1ca1abd99d91aa281c56808f39 Mon Sep 17 00:00:00 2001 From: Manas Raghuwanshi Date: Mon, 10 Aug 2026 08:24:29 +0530 Subject: [PATCH] feat(onboarding): add a demo mode that runs without an API key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first screen demanded an API key before anything would run, and users who wanted to try the tool before handing over a credential stopped there. Shipping a shared Gemini key was the obvious fix and does not work. The free-tier quota belongs to the project rather than the caller, so everyone holding a copy competes for one bucket while a single turn is up to 40 requests; a key printed in a terminal reaches screenshots and gets revoked; and there is no way to replace it once it is on thousands of disks. So the key stays on a proxy and the client holds a token instead. The token is worthless to Google — it authenticates to Woopcode's proxy, which applies the quota and forwards under the real key — which makes the demo revocable, rationable and switchable off without shipping a release. Mechanically this is one line: @google/genai takes a full-URL override, so a demo session is an ordinary Gemini client pointed somewhere else. The wire format, the streaming, the loop and the message type are untouched, and the proxy's two refusals were chosen to land on rules runtime/retry.ts already has — 403 for a spent allowance is fatal there, 429 for a busy proxy is retryable, so load shedding works and exhaustion does not cost three attempts to reach the same answer. The proxy runs a free-tier key, so demo traffic falls under terms that let Google train on it and have humans read it. The wizard says so and requires an explicit keypress, because the alternative is a user sending a private repository somewhere they were never told about. Moving to a paid-tier key later is a server-side change and needs no client release. Two failures this turned up in code that already existed: every path that stored a user's key spread over the previous entry, and normalizeConfig rebuilds provider entries field by field. Together they meant an upgrade out of demo mode would keep the proxy URL underneath a real Google key, and a demo entry would lose that URL on the next read while keeping the token. Both are covered by tests that were confirmed to fail with the fix reverted. --- CLAUDE.md | 4 +- commands/agent.tsx | 8 +- commands/agentController.ts | 34 +- commands/providers/login.ts | 14 +- commands/slash/commands.ts | 30 +- config/config.ts | 39 ++- config/demoAccount.ts | 236 ++++++++++++++ onboarding/index.ts | 25 +- onboarding/setupWizard.tsx | 168 +++++++++- packages/tests/config/demoMode.test.ts | 326 +++++++++++++++++++ packages/tests/providers/demoBaseUrl.test.ts | 149 +++++++++ providers/client.ts | 24 +- 12 files changed, 1015 insertions(+), 42 deletions(-) create mode 100644 config/demoAccount.ts create mode 100644 packages/tests/config/demoMode.test.ts create mode 100644 packages/tests/providers/demoBaseUrl.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index f19e186..0dee339 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,7 +118,9 @@ Reading a trajectory, `run_end`'s `ok: true` means the loop finished, not that t ## Environment variables -`WOOPCODE_API_KEY`, `WOOPCODE_PROVIDER`, `WOOPCODE_MAX_ITERATIONS`, `WOOPCODE_MAX_ATTEMPTS` (retry), `WOOPCODE_TOOL_HISTORY_BUDGET`, `WOOPCODE_THINKING_BUDGET`, `WOOPCODE_NON_INTERACTIVE`. Bun loads `.env` automatically — no `dotenv`. +`WOOPCODE_API_KEY`, `WOOPCODE_PROVIDER`, `WOOPCODE_MAX_ITERATIONS`, `WOOPCODE_MAX_ATTEMPTS` (retry), `WOOPCODE_TOOL_HISTORY_BUDGET`, `WOOPCODE_THINKING_BUDGET`, `WOOPCODE_NON_INTERACTIVE`, `WOOPCODE_DEMO_URL`. Bun loads `.env` automatically — no `dotenv`. + +`WOOPCODE_DEMO_URL` points demo mode at a proxy other than the production one, which is how the proxy is run locally. Demo mode stores a token, not a key: the credential in `providers.json` is only valid against that URL, so the two are written and cleared together (`config/demoAccount.ts`). A shared Gemini key cannot be shipped instead — the free-tier quota belongs to the project rather than the caller, and a key printed in a terminal gets scraped and revoked with no way to replace it. `WOOPCODE_THINKING_BUDGET` takes `off`, `-1` (the default, meaning automatic), or a token count. `off` omits `thinkingConfig` from the request entirely, and exists because `gemini-3.5-flash-lite` rejects a budget of `0` with a 400 — so "disable" cannot be expressed as a number. Budgets below roughly a thousand are ignored rather than honoured: measured, 128 and 512 return zero thinking tokens while 1024 and -1 return 54–202. diff --git a/commands/agent.tsx b/commands/agent.tsx index 8661963..ce7dd13 100644 --- a/commands/agent.tsx +++ b/commands/agent.tsx @@ -146,7 +146,7 @@ async function runHeadless( options: { model?: string; events?: string; session?: InitializeOptions } = {}, ) { registerCommands(); - const { provider, apiKey } = await ensureProviderConfigured(); + const { provider, apiKey, baseUrl } = await ensureProviderConfigured(); const selectedModel = await resolveModel(options.model); store.setSelectedModel(selectedModel); @@ -248,7 +248,7 @@ async function runHeadless( }, }; - const controller = new AgentController(provider, apiKey, selectedModel, callbacks); + const controller = new AgentController(provider, apiKey, selectedModel, callbacks, baseUrl); await controller.initialize(options.session); // On stderr, not stdout: stdout is the agent's answer and a caller pipes it. @@ -296,7 +296,7 @@ async function runInteractive( // Launches onboarding when nothing is configured, so this may not return // immediately on a first run. - const { provider, apiKey } = await ensureProviderConfigured(); + const { provider, apiKey, baseUrl } = await ensureProviderConfigured(); const config = await getConfig(); const selectedModel = await resolveModel(modelOverride); @@ -390,7 +390,7 @@ async function runInteractive( store.setTransientStatus("Cancelled", CANCEL_STATUS_MS); }, }; - const controller = new AgentController(provider, apiKey, selectedModel, callbacks); + const controller = new AgentController(provider, apiKey, selectedModel, callbacks, baseUrl); try { await controller.initialize(session); } catch (error) { diff --git a/commands/agentController.ts b/commands/agentController.ts index dc3f8f9..66fd920 100644 --- a/commands/agentController.ts +++ b/commands/agentController.ts @@ -22,6 +22,7 @@ import { type SessionRecord, } from "../config/sessions"; import { agentLoop } from "../runtime/loop"; +import { demoExhaustionMessage } from "../config/demoAccount"; import { stopAllProcesses } from "../tools/process"; import { PLAN_MODE_PROMPT } from "../config/systemPrompt"; import { @@ -121,6 +122,8 @@ export class AgentController { private apiKey: string, modelOrCallbacks: string | AgentCallbacks, callbacks?: AgentCallbacks, + /** Non-vendor host for this provider's requests; see ProviderEntry.baseUrl. */ + private baseUrl?: string, ) { this.model = typeof modelOrCallbacks === "string" ? modelOrCallbacks : DEFAULT_MODEL_ID; this.callbacks = typeof modelOrCallbacks === "string" ? callbacks! : modelOrCallbacks; @@ -140,11 +143,17 @@ export class AgentController { * * Returns false when a turn is in flight, so the caller can report that * instead of swapping credentials underneath a running request. + * + * `baseUrl` is assigned unconditionally, unlike `model`. It belongs to the + * credential rather than to the session: a demo session that switches to a + * real key must stop talking to the proxy, and leaving the old value in + * place would send that key to a server it was never issued for. */ - setProvider(provider: string, apiKey: string, model?: string) { + setProvider(provider: string, apiKey: string, model?: string, baseUrl?: string) { if (this.isRunning) return false; this.provider = provider; this.apiKey = apiKey; + this.baseUrl = baseUrl; if (model) this.model = model; return true; } @@ -153,6 +162,19 @@ export class AgentController { return this.provider; } + /** + * Replaces a failure the user cannot act on with one they can. + * + * Lives here rather than in the loop, which is deliberately ignorant of + * providers and of how this session was credentialed. Anything it does not + * recognise is passed through untouched — a mapping that swallowed unknown + * errors would turn a real bug into a wrong explanation. + */ + private describeTurnError(error: Error): Error { + const demo = demoExhaustionMessage(error, { baseUrl: this.baseUrl }); + return demo ? new Error(demo) : error; + } + getSessionMode() { return this.sessionMode; } @@ -239,7 +261,12 @@ export class AgentController { let failed = false; try { - const client = createProviderClient(this.provider, this.apiKey, this.model); + const client = createProviderClient( + this.provider, + this.apiKey, + this.model, + this.baseUrl, + ); agentLoopStarted = true; response = await agentLoop( client, @@ -255,6 +282,9 @@ export class AgentController { this.wasCancelled = true; this.callbacks.onCancel?.(); }, + onError: (error) => { + this.callbacks.onError?.(this.describeTurnError(error)); + }, }, this.abortController.signal, !conversational, diff --git a/commands/providers/login.ts b/commands/providers/login.ts index 395ea03..cb7f869 100644 --- a/commands/providers/login.ts +++ b/commands/providers/login.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import { loginProvider } from "../../config/authProvider"; -import { getConfig, saveConfig } from "../../config/config"; +import { apiProviderEntry, getConfig, saveConfig } from "../../config/config"; import { isProviderEnabled, unsupportedProviderMessage, @@ -30,13 +30,11 @@ export const loginCommand = new Command("login") const config = await getConfig(); config.defaultProvider = options.provider; - // The entry can be absent in a config written by an older version or - // trimmed by hand, so create it rather than indexing into undefined. - config.providers[options.provider] = { - ...config.providers[options.provider], - type: "api", - apiKey: options.apiKey, - }; + // Built fresh rather than spread over the previous entry: that entry may + // be a demo one, whose proxy URL would otherwise survive underneath a real + // key. It also covers an entry that is absent entirely, in a config + // written by an older version or trimmed by hand. + config.providers[options.provider] = apiProviderEntry(options.apiKey); await saveConfig(config); console.log("logging into " + options.provider); diff --git a/commands/slash/commands.ts b/commands/slash/commands.ts index 2847ab3..b6a9873 100644 --- a/commands/slash/commands.ts +++ b/commands/slash/commands.ts @@ -1,7 +1,7 @@ import type { SlashCommand, SlashCommandContext } from "./types"; import { registry } from "./registry"; import { APPROVAL_MODES, describeApprovalMode, parseApprovalMode } from "../../runtime/approval"; -import { getConfig, saveConfig } from "../../config/config"; +import { apiProviderEntry, getConfig, saveConfig } from "../../config/config"; import { listSessions, UNTITLED, type SessionSummary } from "../../config/sessions"; import { relativeTime } from "../../tui/src/relative-time"; import { isProviderEnabled, unsupportedProviderMessage } from "../../providers/providerRegistry"; @@ -273,7 +273,12 @@ const providerCommand: SlashCommand = { // The running controller holds its own provider/key, so the config write // alone would leave this session on the previous provider. - context.controller.setProvider(newProvider, providerConfig.apiKey, model); + context.controller.setProvider( + newProvider, + providerConfig.apiKey, + model, + providerConfig.baseUrl, + ); config.defaultProvider = newProvider; if (model) config.selectedModel = model; @@ -357,7 +362,9 @@ const loginCommand: SlashCommand = { return `Cannot change provider while the agent is running. Press Esc to cancel first.`; } - config.providers[provider].apiKey = apiKey; + // Replaces the entry outright: this is the way out of demo mode, and the + // demo's proxy URL must not outlive the token it belonged to. + config.providers[provider] = apiProviderEntry(apiKey); config.defaultProvider = provider; const model = modelForProvider(provider, config.selectedModel); @@ -366,7 +373,8 @@ const loginCommand: SlashCommand = { // A re-login with a fresh key must reach the running session too, not just // the config file. - context.controller.setProvider(provider, apiKey, model); + // No base URL: a user's own key always goes to the vendor directly. + context.controller.setProvider(provider, apiKey, model, undefined); if (model) { const { store } = await import("../../tui/src/store/ui-store"); @@ -424,15 +432,21 @@ const logoutCommand: SlashCommand = { // Drop the revoked key from the live session as well. With no provider // left, the next turn reports that instead of using stale credentials. - const nextKey = config.defaultProvider - ? config.providers[config.defaultProvider]?.apiKey ?? "" - : ""; + const nextEntry = config.defaultProvider + ? config.providers[config.defaultProvider] + : undefined; + const nextKey = nextEntry?.apiKey ?? ""; const nextModel = modelForProvider( config.defaultProvider, config.selectedModel, ); if (nextModel) config.selectedModel = nextModel; - context.controller.setProvider(config.defaultProvider, nextKey, nextModel); + context.controller.setProvider( + config.defaultProvider, + nextKey, + nextModel, + nextEntry?.baseUrl, + ); if (nextModel) { const { store } = await import("../../tui/src/store/ui-store"); diff --git a/config/config.ts b/config/config.ts index 96ae1f8..76594f8 100644 --- a/config/config.ts +++ b/config/config.ts @@ -6,6 +6,31 @@ import { type ApprovalMode, parseApprovalMode } from "../runtime/approval"; export interface ProviderEntry { type?: string; apiKey?: string; + /** + * Where to send this provider's requests, when not the vendor's own host. + * + * Set for demo mode, whose credential is a token issued by Woopcode's proxy + * rather than a Google key. The two travel together and separating them is a + * failure: the token is meaningless to Google, so an entry that keeps the + * key but loses the URL authenticates against the wrong server. + */ + baseUrl?: string; + /** Epoch ms after which a demo token is dead. Absent on a real key. */ + demoExpiresAt?: number; +} + +/** + * The entry for a key the user supplied themselves. + * + * Built fresh rather than spread over whatever was stored before, because the + * entry it replaces may be a demo one. Spreading kept `type: "demo"`, the + * proxy's `baseUrl` and the old expiry alive underneath the new key — so + * upgrading out of demo mode sent a real Google credential to Woopcode's + * proxy, and expired the moment the demo token would have. Every path that + * stores a user key goes through here so that cannot come back. + */ +export function apiProviderEntry(apiKey: string): ProviderEntry { + return { type: "api", apiKey }; } export interface ProvidersConfig { @@ -72,7 +97,13 @@ export async function readJsonFile(path: string, label: string): Promise; @@ -84,10 +115,14 @@ export function normalizeConfig(raw: unknown): ProvidersConfig { const providers: Record = {}; for (const [name, entry] of Object.entries(rawProviders)) { if (!entry || typeof entry !== "object") continue; - const { type, apiKey } = entry as ProviderEntry; + const { type, apiKey, baseUrl, demoExpiresAt } = entry as ProviderEntry; providers[name] = { ...(typeof type === "string" ? { type } : { type: "api" }), ...(typeof apiKey === "string" ? { apiKey } : {}), + ...(typeof baseUrl === "string" ? { baseUrl } : {}), + ...(typeof demoExpiresAt === "number" && Number.isFinite(demoExpiresAt) + ? { demoExpiresAt } + : {}), }; } diff --git a/config/demoAccount.ts b/config/demoAccount.ts new file mode 100644 index 0000000..32918f0 --- /dev/null +++ b/config/demoAccount.ts @@ -0,0 +1,236 @@ +import { join } from "path"; +import { getConfigDir } from "./paths"; +import type { ProviderEntry } from "./config"; +import { readJsonFile } from "./config"; + +/** + * Demo mode: running Woopcode without an API key of your own. + * + * A shared Gemini key cannot be shipped to users. The free-tier quota belongs + * to the project rather than the caller, so everyone holding a copy competes + * for one bucket while a single turn is up to 40 requests; a key printed in a + * terminal ends up in screenshots and gets revoked; and there is no way to + * replace it once it is on thousands of disks. + * + * So the key stays on a proxy and the client holds a token instead. The token + * is worthless to Google — it authenticates to Woopcode's proxy, which applies + * the quota and forwards under the real key. That makes the demo revocable, + * rationable and switchable off without shipping a release. + * + * This module owns the whole lifecycle so no other file knows the endpoint. + */ + +/** The provider a demo session runs as; the proxy speaks Gemini's wire format. */ +export const DEMO_PROVIDER = "google"; + +/** Marks a provider entry as demo-issued rather than user-supplied. */ +export const DEMO_ENTRY_TYPE = "demo"; + +/** + * The proxy Woopcode's demo talks to. + * + * Overridable so the proxy can be run on localhost during development. It is + * read per call rather than captured at import, because a test that sets the + * variable in `beforeAll` would otherwise race module loading. + */ +export function demoEndpoint( + env: Record = process.env, +): string { + return (env.WOOPCODE_DEMO_URL?.trim() || "https://demo.woopcode.dev").replace( + /\/+$/, + "", + ); +} + +/** What the proxy hands back when a demo session is granted. */ +export interface DemoSession { + token: string; + /** Epoch ms. */ + expiresAt: number; + /** Requests this install may make per day, for display only. */ + dailyLimit?: number; +} + +function installIdPath(): string { + return join(getConfigDir(), "install-id.json"); +} + +/** + * A stable, random identifier for this installation. + * + * The proxy rations per install, and needs something to ration by that is not + * an IP — shared NATs and cloud egress make addresses both leaky and unfair. + * It is random and carries nothing about the machine or the user: its only job + * is to be the same string tomorrow. + */ +export async function getInstallId(): Promise { + const path = installIdPath(); + const existing = await readJsonFile(path, "install id"); + + if ( + existing && + typeof existing === "object" && + typeof (existing as { installId?: unknown }).installId === "string" && + (existing as { installId: string }).installId.length > 0 + ) { + return (existing as { installId: string }).installId; + } + + const installId = crypto.randomUUID(); + await Bun.write(path, JSON.stringify({ installId }, null, 2)); + return installId; +} + +/** + * How long to wait for the proxy before giving up on it. + * + * Short, because of where this runs. A hung request leaves the wizard on a + * spinner with no key handler and no way out but Ctrl+C — on the first screen + * a new user ever sees. Ten seconds is long enough for a cold start and short + * enough that failing lands them back on "use my own API key" while they are + * still willing to. + */ +const SESSION_REQUEST_TIMEOUT_MS = 10_000; + +/** + * Asks the proxy for a demo session. + * + * Every failure is reported as a message a user can act on, because this runs + * inside the setup wizard where the alternative on screen is "use my own API + * key" — a raw fetch error there reads as the product being broken rather than + * as one optional path being unavailable. + * + * @throws If the proxy refuses, is unreachable, or answers with something that + * is not a session. + */ +export async function requestDemoSession( + signal?: AbortSignal, + /** Overridable so a test can prove the deadline fires without waiting it out. */ + timeoutMs: number = SESSION_REQUEST_TIMEOUT_MS, +): Promise { + const installId = await getInstallId(); + const endpoint = `${demoEndpoint()}/v1/session`; + + // The caller's signal still wins; this only adds a deadline of its own, so a + // wizard that is cancelled does not also wait out the timeout. + const timeout = AbortSignal.timeout(timeoutMs); + const deadline = signal ? AbortSignal.any([signal, timeout]) : timeout; + + let response: Response; + try { + response = await fetch(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ installId }), + signal: deadline, + }); + } catch (error) { + const timedOut = timeout.aborted; + throw new Error( + timedOut + ? "The demo service did not respond in time. You can still set up your own API key." + : `Could not reach the demo service (${ + error instanceof Error ? error.message : "network error" + }). You can still set up your own API key.`, + ); + } + + if (!response.ok) { + // 503 is the kill switch: the demo is off, deliberately, and no amount of + // retrying changes that. Say so rather than blaming the connection. + const reason = + response.status === 503 + ? "The demo is temporarily unavailable." + : `The demo service refused the request (HTTP ${response.status}).`; + throw new Error(`${reason} You can still set up your own API key.`); + } + + const body = (await response.json().catch(() => null)) as Partial | null; + + if (!body || typeof body.token !== "string" || !body.token) { + throw new Error( + "The demo service returned an unusable response. You can still set up your own API key.", + ); + } + + // An absent or unparseable expiry is treated as already expired rather than + // as forever. A token with no deadline would sit in the config outliving + // whatever the server thought it granted, and fail on some later turn + // instead of here, where there is still a wizard to fall back into. + const expiresAt = + typeof body.expiresAt === "number" && Number.isFinite(body.expiresAt) + ? body.expiresAt + : 0; + + return { + token: body.token, + expiresAt, + ...(typeof body.dailyLimit === "number" ? { dailyLimit: body.dailyLimit } : {}), + }; +} + +/** The provider entry a granted session is stored as. */ +export function demoProviderEntry(session: DemoSession): ProviderEntry { + return { + type: DEMO_ENTRY_TYPE, + apiKey: session.token, + baseUrl: demoEndpoint(), + demoExpiresAt: session.expiresAt, + }; +} + +/** + * The marker the proxy puts in the body of a 403 when an install has spent its + * daily allowance. + * + * A token rather than prose, because this is matched against an error message + * the SDK assembled: matching on wording would break the moment the proxy + * rephrases its own error, and silently — the user would get the raw provider + * failure back with no sign that the mapping had stopped working. + * + * 403 and not 429 on purpose. `runtime/retry.ts` treats 429 as transient and + * would spend every attempt re-asking a question whose answer is fixed until + * tomorrow; 403 is already in its fatal set, so this fails immediately. + */ +export const DEMO_EXHAUSTED_MARKER = "woopcode_demo_exhausted"; + +/** + * Rewrites a spent-allowance failure into something a user can act on, or + * returns null to leave the error alone. + * + * Only applies to a demo session: the same marker arriving on a real key would + * mean something has gone wrong that this message would misdescribe. + */ +export function demoExhaustionMessage( + error: unknown, + entry: { baseUrl?: string } | undefined, +): string | null { + if (!entry?.baseUrl) return null; + + const text = error instanceof Error ? error.message : String(error); + if (!text.includes(DEMO_EXHAUSTED_MARKER)) return null; + + return ( + "Demo limit reached for today.\n" + + "Run /login to continue with your own key." + ); +} + +export function isDemoEntry(entry: ProviderEntry | undefined): boolean { + return entry?.type === DEMO_ENTRY_TYPE; +} + +/** + * Whether a demo entry is past its deadline. + * + * Only demo entries expire. A user's own key has no `demoExpiresAt` and must + * never be judged by this — treating a missing deadline as "expired" would + * lock every ordinary user out of their own credentials. + */ +export function isDemoExpired( + entry: ProviderEntry | undefined, + now: number = Date.now(), +): boolean { + if (!isDemoEntry(entry)) return false; + return (entry?.demoExpiresAt ?? 0) <= now; +} diff --git a/onboarding/index.ts b/onboarding/index.ts index ceac304..6f8aaa6 100644 --- a/onboarding/index.ts +++ b/onboarding/index.ts @@ -7,27 +7,44 @@ import { canPromptInteractively, resolveEnvCredentials, } from "../config/envCredentials"; +import { isDemoExpired } from "../config/demoAccount"; export interface ProviderCredentials { provider: string; apiKey: string; + /** Set for demo mode, whose token is only valid against Woopcode's proxy. */ + baseUrl?: string; } /** * Resolves the active provider and its key, or null when the config cannot - * currently run: no default provider, a missing or keyless provider entry, or - * a provider Woopcode has no client for. + * currently run: no default provider, a missing or keyless provider entry, a + * provider Woopcode has no client for, or a demo token that has expired. + * + * An expired demo token counts as unconfigured on purpose. Returning it would + * put the failure on the first turn, as a 403 from a server the user has never + * heard of; returning null puts it in the wizard, where "try the demo again" + * and "use my own key" are both one keypress away. */ export async function resolveCredentials(): Promise { const config = await getConfig(); const provider = config.defaultProvider; - const apiKey = config.providers[provider]?.apiKey; + const entry = config.providers[provider]; + const apiKey = entry?.apiKey; if (!provider || !apiKey || !isProviderEnabled(provider)) { return null; } - return { provider, apiKey }; + if (isDemoExpired(entry)) { + return null; + } + + return { + provider, + apiKey, + ...(entry?.baseUrl ? { baseUrl: entry.baseUrl } : {}), + }; } /** diff --git a/onboarding/setupWizard.tsx b/onboarding/setupWizard.tsx index 09bf348..3900c6c 100644 --- a/onboarding/setupWizard.tsx +++ b/onboarding/setupWizard.tsx @@ -4,17 +4,29 @@ import TextInput from "ink-text-input"; import Spinner from "ink-spinner"; import { getEnabledProviders, type ProviderInfo } from "../providers/providerRegistry"; import { loginProvider } from "../config/authProvider"; -import { getConfig, saveConfig } from "../config/config"; +import { apiProviderEntry, getConfig, saveConfig } from "../config/config"; +import { + DEMO_PROVIDER, + demoProviderEntry, + requestDemoSession, +} from "../config/demoAccount"; import { colors } from "../tui/src/styles/theme"; type WizardStep = | "welcome" + | "choose-path" + | "demo-disclosure" + | "requesting-demo" | "select-provider" | "api-key-info" | "enter-key" | "validating" | "complete"; +/** The two ways out of the welcome screen, in the order they are listed. */ +const PATHS = ["demo", "own-key"] as const; +type SetupPath = (typeof PATHS)[number]; + interface SetupWizardProps { onComplete: () => void; onError: (error: string) => void; @@ -27,12 +39,38 @@ export function SetupWizard({ onComplete, onError }: SetupWizardProps) { null, ); const [apiKey, setApiKey] = useState(""); + const [pathIndex, setPathIndex] = useState(0); + /** + * Which path reached the final screen. Not derived from `pathIndex`: a demo + * request that fails falls back to provider selection while leaving the + * highlight where it was, so the index says "demo" for a run that ended with + * the user pasting their own key. + */ + const [completedVia, setCompletedVia] = useState("own-key"); const enabledProviders = getEnabledProviders(); useInput((input, key) => { if (step === "welcome") { if (key.return) { + setStep("choose-path"); + } + } else if (step === "choose-path") { + if (key.upArrow) { + setPathIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setPathIndex((prev) => Math.min(PATHS.length - 1, prev + 1)); + } else if (key.return) { + setStep(PATHS[pathIndex]! === "demo" ? "demo-disclosure" : "select-provider"); + } + } else if (step === "demo-disclosure") { + // Two distinct keys, not "any key". The disclosure below is the only + // notice a demo user gets that their code reaches Google's free tier, + // and a screen dismissed by whatever they happened to press next is not + // a notice anyone read. + if (input === "y" || input === "Y") { + void startDemo(); + } else if (key.escape || input === "n" || input === "N") { setStep("select-provider"); } } else if (step === "select-provider") { @@ -53,6 +91,33 @@ export function SetupWizard({ onComplete, onError }: SetupWizardProps) { } }); + const startDemo = async () => { + setStep("requesting-demo"); + + try { + const session = await requestDemoSession(); + + const config = await getConfig(); + config.defaultProvider = DEMO_PROVIDER; + config.providers[DEMO_PROVIDER] = demoProviderEntry(session); + await saveConfig(config); + + setCompletedVia("demo"); + setStep("complete"); + setTimeout(onComplete, 1000); + } catch (error) { + // The demo is the optional path. When it is unavailable the wizard drops + // into provider selection rather than dead-ending, so a user who came to + // set up their own key is not blocked by a service they never wanted. + onError( + error instanceof Error + ? error.message + : "Could not start the demo. You can still set up your own API key.", + ); + setStep("select-provider"); + } + }; + const handleKeySubmit = async (value: string) => { if (!selectedProvider || !value.trim()) { return; @@ -73,14 +138,12 @@ export function SetupWizard({ onComplete, onError }: SetupWizardProps) { const config = await getConfig(); config.defaultProvider = selectedProvider.id; - // A provider chosen in the wizard may have no entry yet. - config.providers[selectedProvider.id] = { - ...config.providers[selectedProvider.id], - type: "api", - apiKey: value.trim(), - }; + // Built fresh, which covers both a provider with no entry yet and one + // holding a demo entry whose proxy URL must not survive a real key. + config.providers[selectedProvider.id] = apiProviderEntry(value.trim()); await saveConfig(config); + setCompletedVia("own-key"); setStep("complete"); setTimeout(onComplete, 1000); } catch (error) { @@ -101,14 +164,86 @@ export function SetupWizard({ onComplete, onError }: SetupWizardProps) { Welcome to Woopcode! - You'll need an AI provider to use Woopcode. - We'll only ask for this once. + Let's get you set up. We'll only ask for this once. Press Enter to continue... ); } + if (step === "choose-path") { + const options = [ + { + label: "Try the demo — no API key needed", + hint: "Runs on Gemini through Woopcode's demo service. Limited daily usage.", + }, + { + label: "Use my own API key", + hint: "Google, OpenAI or Anthropic. Your key stays on this machine.", + }, + ]; + + return ( + + How would you like to start? + + {options.map((option, index) => ( + + + {index === pathIndex ? "❯ " : " "} + {option.label} + + {index === pathIndex && {option.hint}} + + ))} + + Use ↑↓ arrows to select, Enter to confirm + + ); + } + + if (step === "demo-disclosure") { + return ( + + + Before you try the demo + + + + The demo runs on Google's free tier. Under Google's terms, anything + you send there — including the contents of files in this repository — + may be used to improve Google's models, and may be read by human + reviewers. + + + Don't use the demo on private or confidential code. + + + Using your own API key avoids this. You can switch any time with + /login. + + + + Press y to accept and start the demo, or{" "} + n to set up your own key. + + + ); + } + + if (step === "requesting-demo") { + return ( + + + + + + Starting your demo session... + + + ); + } + if (step === "select-provider") { return ( @@ -185,8 +320,19 @@ export function SetupWizard({ onComplete, onError }: SetupWizardProps) { if (step === "complete") { return ( - ✓ API key verified - ✓ Configuration saved + {completedVia === "demo" ? ( + <> + ✓ Demo session started + + Limited daily usage. Run /login to switch to your own API key. + + + ) : ( + <> + ✓ API key verified + ✓ Configuration saved + + )} Starting Woopcode... diff --git a/packages/tests/config/demoMode.test.ts b/packages/tests/config/demoMode.test.ts new file mode 100644 index 0000000..510a902 --- /dev/null +++ b/packages/tests/config/demoMode.test.ts @@ -0,0 +1,326 @@ +import { describe, test, expect, beforeEach, afterAll } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +// Redirected before the modules under test are imported, and for the whole +// file rather than per test: config paths are resolved at call time from this +// variable, and restoring it in an afterEach would point the rest of the file +// at the developer's real ~/.config/woopcode. +const previousConfigHome = process.env.XDG_CONFIG_HOME; +const previousDemoUrl = process.env.WOOPCODE_DEMO_URL; +const configHome = mkdtempSync(join(tmpdir(), `woopcode-demo-${crypto.randomUUID()}-`)); +process.env.XDG_CONFIG_HOME = configHome; + +const { getConfig, saveConfig, normalizeConfig, apiProviderEntry } = await import( + "../../../config/config" +); +const { + DEMO_EXHAUSTED_MARKER, + demoEndpoint, + demoExhaustionMessage, + demoProviderEntry, + getInstallId, + isDemoEntry, + isDemoExpired, + requestDemoSession, +} = await import("../../../config/demoAccount"); +const { resolveCredentials } = await import("../../../onboarding"); + +const configDir = join(configHome, "woopcode"); +const providersPath = join(configDir, "providers.json"); + +const HOUR = 60 * 60 * 1000; + +afterAll(() => { + if (previousConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousConfigHome; + if (previousDemoUrl === undefined) delete process.env.WOOPCODE_DEMO_URL; + else process.env.WOOPCODE_DEMO_URL = previousDemoUrl; + rmSync(configHome, { recursive: true, force: true }); +}); + +beforeEach(async () => { + await Bun.write( + providersPath, + JSON.stringify({ defaultProvider: "", providers: {} }, null, 2), + ); +}); + +describe("a demo entry survives being stored", () => { + // The regression this file exists for. normalizeConfig rebuilds every + // provider entry field by field, so a field it does not name is dropped on + // the next read — silently, and with the worst possible result: the demo + // token stays but the proxy URL it is only valid against does not, so the + // next turn sends a Woopcode token to Google as if it were a Google key. + test("baseUrl and demoExpiresAt round-trip through disk", async () => { + const config = await getConfig(); + config.defaultProvider = "google"; + config.providers.google = demoProviderEntry({ + token: "demo-token", + expiresAt: Date.now() + HOUR, + }); + await saveConfig(config); + + const reloaded = await getConfig(); + + expect(reloaded.providers.google?.apiKey).toBe("demo-token"); + expect(reloaded.providers.google?.baseUrl).toBe(demoEndpoint()); + expect(reloaded.providers.google?.type).toBe("demo"); + expect(typeof reloaded.providers.google?.demoExpiresAt).toBe("number"); + }); + + test("a non-numeric expiry is dropped rather than carried through", () => { + const normalized = normalizeConfig({ + defaultProvider: "google", + providers: { google: { type: "demo", apiKey: "t", demoExpiresAt: "soon" } }, + }); + + expect(normalized.providers.google).not.toHaveProperty("demoExpiresAt"); + }); +}); + +describe("leaving demo mode", () => { + // Every path that stores a user's own key builds the entry with + // apiProviderEntry. Spreading over the previous entry instead kept the + // demo's type, proxy URL and expiry alive underneath the new key, which sent + // a real Google credential to Woopcode's proxy and expired it on the demo's + // schedule. + test("apiProviderEntry carries nothing over from a demo entry", () => { + const demo = demoProviderEntry({ token: "demo-token", expiresAt: Date.now() + HOUR }); + const upgraded = { ...demo, ...apiProviderEntry("real-key") }; + + // The spread above is what a caller must NOT do; the assertion is that + // apiProviderEntry's own result is clean. + expect(upgraded.baseUrl).toBeDefined(); + expect(apiProviderEntry("real-key")).toEqual({ type: "api", apiKey: "real-key" }); + expect(apiProviderEntry("real-key")).not.toHaveProperty("baseUrl"); + expect(apiProviderEntry("real-key")).not.toHaveProperty("demoExpiresAt"); + }); +}); + +describe("expiry", () => { + test("a live demo entry is not expired", () => { + expect(isDemoExpired({ type: "demo", demoExpiresAt: Date.now() + HOUR })).toBe(false); + }); + + test("a past deadline is expired", () => { + expect(isDemoExpired({ type: "demo", demoExpiresAt: Date.now() - 1 })).toBe(true); + }); + + test("a demo entry with no deadline is expired, not immortal", () => { + expect(isDemoExpired({ type: "demo", apiKey: "t" })).toBe(true); + }); + + // A user's own key has no deadline either. Judging it by the same rule would + // lock every ordinary user out of credentials they supplied themselves. + test("a real key is never expired", () => { + expect(isDemoExpired({ type: "api", apiKey: "real" })).toBe(false); + expect(isDemoEntry({ type: "api", apiKey: "real" })).toBe(false); + }); +}); + +describe("resolveCredentials", () => { + async function store(entry: Record) { + await Bun.write( + providersPath, + JSON.stringify({ defaultProvider: "google", providers: { google: entry } }, null, 2), + ); + } + + test("a live demo session resolves with its base URL", async () => { + await store({ + type: "demo", + apiKey: "demo-token", + baseUrl: "https://demo.example", + demoExpiresAt: Date.now() + HOUR, + }); + + expect(await resolveCredentials()).toEqual({ + provider: "google", + apiKey: "demo-token", + baseUrl: "https://demo.example", + }); + }); + + // Not "returns the entry and lets the turn 403": that puts the failure in + // front of a user with no wizard left to fall back into. + test("an expired demo session counts as unconfigured", async () => { + await store({ + type: "demo", + apiKey: "demo-token", + baseUrl: "https://demo.example", + demoExpiresAt: Date.now() - 1, + }); + + expect(await resolveCredentials()).toBeNull(); + }); + + test("a real key resolves with no base URL at all", async () => { + await store({ type: "api", apiKey: "real-key" }); + + const resolved = await resolveCredentials(); + expect(resolved).toEqual({ provider: "google", apiKey: "real-key" }); + expect(resolved).not.toHaveProperty("baseUrl"); + }); +}); + +describe("requesting a session from the proxy", () => { + /** A real server on a real port, so the request is not imagined. */ + function serve(handler: (request: Request) => Response | Promise) { + const server = Bun.serve({ port: 0, fetch: handler }); + process.env.WOOPCODE_DEMO_URL = `http://localhost:${server.port}`; + return server; + } + + test("a granted session is returned and the install id is sent", async () => { + let body: unknown; + let path = ""; + const server = serve(async (request) => { + path = new URL(request.url).pathname; + body = await request.json(); + return Response.json({ + token: "issued-token", + expiresAt: 4102444800000, + dailyLimit: 50, + }); + }); + + try { + const session = await requestDemoSession(); + expect(session).toEqual({ + token: "issued-token", + expiresAt: 4102444800000, + dailyLimit: 50, + }); + expect(path).toBe("/v1/session"); + expect((body as { installId: string }).installId).toBe(await getInstallId()); + } finally { + server.stop(true); + } + }); + + test("the install id is stable across calls", async () => { + const first = await getInstallId(); + expect(await getInstallId()).toBe(first); + expect(first.length).toBeGreaterThan(0); + }); + + // Absent, not forever. A token with no deadline would outlive whatever the + // server granted and fail on some later turn instead of here. + test("a session with no expiry is treated as already expired", async () => { + const server = serve(() => Response.json({ token: "t" })); + + try { + const session = await requestDemoSession(); + expect(session.expiresAt).toBe(0); + expect(isDemoExpired(demoProviderEntry(session))).toBe(true); + } finally { + server.stop(true); + } + }); + + test("the kill switch reports the demo as unavailable", async () => { + const server = serve(() => new Response("off", { status: 503 })); + + try { + await expect(requestDemoSession()).rejects.toThrow(/temporarily unavailable/i); + } finally { + server.stop(true); + } + }); + + test("every failure still points at the other way in", async () => { + const server = serve(() => new Response("no", { status: 429 })); + + try { + await expect(requestDemoSession()).rejects.toThrow(/your own API key/i); + } finally { + server.stop(true); + } + }); + + // Otherwise the wizard sits on a spinner with no key handler, on the first + // screen a new user ever sees, with only Ctrl+C out. + test("a proxy that never answers gives up instead of hanging", async () => { + const server = serve(() => new Promise(() => {})); + + try { + // A short deadline rather than the real one: this proves the mechanism + // fires, and waiting out the production value would add ten seconds to + // every run of the whole suite for a single assertion. + await expect(requestDemoSession(undefined, 50)).rejects.toThrow( + /did not respond in time/i, + ); + } finally { + server.stop(true); + } + }); + + test("a caller's own cancellation is still honoured", async () => { + const server = serve(() => new Promise(() => {})); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 50); + + try { + await expect(requestDemoSession(controller.signal)).rejects.toThrow( + /demo service/i, + ); + } finally { + server.stop(true); + } + }); + + test("a response that is not a session is refused", async () => { + const server = serve(() => Response.json({ nope: true })); + + try { + await expect(requestDemoSession()).rejects.toThrow(/unusable response/i); + } finally { + server.stop(true); + } + }); +}); + +// The proxy's two refusals are chosen to land on opposite sides of a rule that +// already exists in runtime/retry.ts, and nothing in that file mentions the +// demo. If someone moves 403 into the retryable set, exhaustion starts costing +// three round trips to reach the same answer, and load shedding stops working +// if 429 leaves it. This is the test that notices. +describe("the proxy's status codes match what retry already does", () => { + test("403 — allowance spent — is fatal, so it is not retried", async () => { + const { isRetryableError } = await import("../../../runtime/retry"); + expect(isRetryableError({ status: 403 })).toBe(false); + }); + + test("429 — proxy busy — is retried, which is how load shedding works", async () => { + const { isRetryableError } = await import("../../../runtime/retry"); + expect(isRetryableError({ status: 429 })).toBe(true); + }); +}); + +describe("exhaustion is explained, not passed through", () => { + const demoEntry = { baseUrl: "https://demo.example" }; + + test("the marker becomes an instruction naming /login", () => { + const message = demoExhaustionMessage( + new Error(`403 Forbidden {"error":"${DEMO_EXHAUSTED_MARKER}"}`), + demoEntry, + ); + + expect(message).toContain("Demo limit reached"); + expect(message).toContain("/login"); + }); + + // Anything unrecognised is left alone: a mapping that swallowed other errors + // would replace a real bug with a wrong explanation. + test("an unrelated failure is left untouched", () => { + expect(demoExhaustionMessage(new Error("socket hang up"), demoEntry)).toBeNull(); + }); + + test("the same marker on a real key is not rewritten", () => { + expect( + demoExhaustionMessage(new Error(DEMO_EXHAUSTED_MARKER), { baseUrl: undefined }), + ).toBeNull(); + }); +}); diff --git a/packages/tests/providers/demoBaseUrl.test.ts b/packages/tests/providers/demoBaseUrl.test.ts new file mode 100644 index 0000000..c4caab1 --- /dev/null +++ b/packages/tests/providers/demoBaseUrl.test.ts @@ -0,0 +1,149 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { createProviderClient, geminiClient } from "../../../providers/client"; +import type { Message } from "../../../config/types"; + +/** + * Demo mode is one line: the Gemini client is handed a base URL and the SDK + * sends everything to it instead of to Google. Every other part of the feature + * — the token, the config field, the wizard — is worthless if that line is + * wrong, and wrong in the direction that matters silently: a request that + * still goes to Google carries a Woopcode demo token as if it were a Google + * key, and comes back as an authentication error nobody would connect to this. + * + * So this is checked against a real server on a real port rather than a + * stubbed fetch. The assertion is that the request arrives here — which it can + * only do by not having gone to Google. + */ + +const messages: Message[] = [{ role: "user", content: "hello" }]; + +let server: ReturnType | null = null; + +afterEach(() => { + server?.stop(true); + server = null; +}); + +/** Records what reaches it and answers with a minimal, valid stream. */ +function recordingProxy() { + const seen: { path: string; apiKey: string | null }[] = []; + + server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + seen.push({ + path: url.pathname, + // The SDK sends the credential as a header; the proxy authenticates + // the demo token from exactly here. + apiKey: request.headers.get("x-goog-api-key"), + }); + + return new Response( + `data: ${JSON.stringify({ + candidates: [{ content: { parts: [{ text: "hi" }] }, finishReason: "STOP" }], + })}\r\n\r\n`, + { headers: { "content-type": "text/event-stream" } }, + ); + }, + }); + + return { seen, url: `http://localhost:${server.port}` }; +} + +async function drain(stream: AsyncGenerator) { + for await (const _ of stream) { + // The events themselves are covered elsewhere; this file is about where + // the request went. + } +} + +describe("baseUrl diverts requests away from Google", () => { + test("geminiClient sends the turn to the base URL it was given", async () => { + const { seen, url } = recordingProxy(); + const client = geminiClient("demo-token", "gemini-3.5-flash-lite", undefined, url); + + await drain(client.stream(messages, "", undefined, false)); + + expect(seen).toHaveLength(1); + expect(seen[0]!.path).toContain("gemini-3.5-flash-lite"); + expect(seen[0]!.apiKey).toBe("demo-token"); + }); + + test("createProviderClient passes it through for google", async () => { + const { seen, url } = recordingProxy(); + const client = createProviderClient("google", "demo-token", undefined, url); + + await drain(client.stream(messages, "", undefined, false)); + + expect(seen).toHaveLength(1); + expect(seen[0]!.apiKey).toBe("demo-token"); + }); + + // The alias is what a config written by an older version stores, and it + // reaches the same client by a different branch of the switch. + test("the gemini alias gets it too", async () => { + const { seen, url } = recordingProxy(); + const client = createProviderClient("gemini", "demo-token", undefined, url); + + await drain(client.stream(messages, "", undefined, false)); + + expect(seen).toHaveLength(1); + }); + + /** + * Where a request would have gone, without letting it go anywhere. + * + * The cases below are about traffic that must NOT reach the proxy, and the + * honest version of that assertion would otherwise be a real call to Google, + * Anthropic or OpenAI — an outbound request per run, failing whenever CI has + * no network and reading as a code break. Stubbing the global is the + * per-file, restorable way to fake a network boundary. + */ + function recordDestinations() { + const urls: string[] = []; + const original = globalThis.fetch; + + globalThis.fetch = (async (input: any, init?: any) => { + urls.push(typeof input === "string" ? input : (input?.url ?? String(input))); + throw new Error("blocked in test"); + }) as unknown as typeof fetch; + + return { urls, restore: () => (globalThis.fetch = original) }; + } + + // Not merely untested: forwarding it would point an SDK that speaks another + // wire format at a proxy that only answers Gemini's. + test("anthropic and openai ignore it rather than being pointed at the proxy", async () => { + const proxyUrl = "http://demo.invalid:9999"; + const { urls, restore } = recordDestinations(); + + try { + for (const provider of ["anthropic", "openai"]) { + const client = createProviderClient(provider, "key", undefined, proxyUrl); + await drain(client.stream(messages, "", undefined, false)).catch(() => {}); + } + } finally { + restore(); + } + + expect(urls.length).toBeGreaterThan(0); + expect(urls.some((url) => url.includes("demo.invalid"))).toBe(false); + }); + + test("without a base URL the Gemini client still goes to Google", async () => { + const { urls, restore } = recordDestinations(); + + try { + const client = createProviderClient("google", "key-only", undefined, undefined); + await drain(client.stream(messages, "", undefined, false)).catch(() => {}); + } finally { + restore(); + } + + expect(urls.length).toBeGreaterThan(0); + expect(urls.every((url) => url.includes("generativelanguage.googleapis.com"))).toBe( + true, + ); + }); +}); diff --git a/providers/client.ts b/providers/client.ts index 1d0a7b5..6551d4c 100644 --- a/providers/client.ts +++ b/providers/client.ts @@ -128,6 +128,7 @@ export function geminiClient( apiKey: string, model = DEFAULT_MODEL_ID, injected?: Pick, + baseUrl?: string, ): ProviderClient { // Built on the first request rather than here. As a default argument this ran // whenever a client was constructed, which made merely naming a provider do @@ -135,8 +136,17 @@ export function geminiClient( // can reach for credentials, so a caller that only wanted to know a provider // is available paid for a network client and could block waiting for one. // Constructing a client should cost nothing until it is used. + // + // `baseUrl` sends the same requests somewhere other than Google. Demo mode + // uses it to reach Woopcode's proxy, which holds the real key: the SDK takes + // a full-URL override, so the wire format, the streaming and every code path + // below stay exactly what they are for a direct key. let ai = injected; - const sdk = () => (ai ??= new GoogleGenAI({ apiKey })); + const sdk = () => + (ai ??= new GoogleGenAI({ + apiKey, + ...(baseUrl ? { httpOptions: { baseUrl } } : {}), + })); return { async *stream( @@ -483,6 +493,7 @@ export function createProviderClient( provider: string, apiKey: string, model?: string, + baseUrl?: string, ): ProviderClient { // A config written before a provider existed can pair it with another // provider's model — providers.json stores the two independently. Sending a @@ -497,10 +508,19 @@ export function createProviderClient( model !== undefined && findModel(model) !== undefined && !modelBelongsToProvider(model, provider); const runnable = mismatched ? undefined : model; + // `baseUrl` is honoured only by Google, because demo mode is the only thing + // that sets it and the proxy speaks one vendor's wire format. Passing it to + // the others would point an Anthropic or OpenAI SDK at a server that cannot + // answer them, so it is ignored there rather than forwarded. switch (provider) { case "google": case "gemini": - return geminiClient(apiKey, runnable ?? defaultModelForProvider("google")); + return geminiClient( + apiKey, + runnable ?? defaultModelForProvider("google"), + undefined, + baseUrl, + ); case "anthropic": return anthropicClient(apiKey, runnable ?? defaultModelForProvider("anthropic"));