From 90c78fb52c5e647b4e8f01ec51e48da18ce995ba Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 22 Sep 2026 18:42:56 -0700 Subject: [PATCH 01/27] feat: auto-register Altimate Base with no consent gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since 2026-09-17, OpenCode Zen rejects keyless traffic outright ("OpenCode's free tier can only be used from within OpenCode"), which breaks every install that falls back to the keyless `opencode` provider with no model of its own (360 machines in 5 days). This registers Altimate Base automatically, with no disclosure dialog, and stops the default-model resolvers from ever falling back to the now-broken keyless tier when Base is available. - `FreeTier.autoRegister()` / `autoRegisterWithin(ms)` in `packages/opencode/src/altimate/free/client.ts`: registers without a consent token, reusing the existing lock, `inflight` dedupe, and `registerOnce` path `registerAfterConsent` already uses. Skips (never throws) when `ALTIMATE_BASE_AUTO_REGISTER` is `0`/`false`, no gateway URL is configured, the user explicitly logged out (checked inside the registration lock so a concurrent logout can't be missed), or valid credentials already exist. `autoRegisterWithin` bounds the wait and lets a slow attempt keep going in the background — its credentials still land on disk for the next launch. - Every entrypoint (`cli/cmd/serve.ts`, `cli/cmd/tui.ts`, `cli/cmd/run.ts` outside `--attach`, `cli/cmd/acp.ts`, `cli/cmd/web.ts`) calls `FreeTier.autoRegisterWithin()` before provider/instance state is first built. - `Provider.isPublicZen()` is the one shared predicate for "this is the keyless `opencode` tier" (id `opencode`, placeholder `"public"` key, no real key). `Provider.defaultModel()`, ACP's `defaultModelFromConfig()`, and the TUI's `fallbackModel()`/`currentModel()`/`restoreSession()` now: - drop the `declinedManagedBaseDefault` veto (still parsed for compatibility, no longer consulted) — there is no working public-Zen fallback left for a decline to protect. - exclude Base only via a real `enabled_providers`/`disabled_providers` verdict, never merely because a project's `config.provider` block names some other provider. - replace a stale persisted/session selection that resolves to public Zen with Base once it's registered, instead of replaying a model OpenCode Zen now rejects outright (`prompt.ts`'s `lastModel()`, ACP's `availableModel()`, and the TUI's recents/current-model/`restoreSession` paths). - ACP no longer strips `altimate-free` out of the directory snapshot for a project with an unrelated `config.provider` block (`acp/service.ts`). - `altimate_base_registration` telemetry gets an optional `origin: "auto" | "consent"` field; the auto path tracks the event directly instead of calling `Telemetry.init()` (which would treat config as enabled outside an Instance context). Part of a 3-commit PR; part (b) removes the TUI consent dialog and capability/consent machinery, part (c) covers the Zen error message and rate-limit retry. Both are separate, later work. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/acp/service.ts | 98 +++++---- packages/opencode/src/altimate/free/client.ts | 143 ++++++++++++- .../opencode/src/altimate/telemetry/index.ts | 4 + packages/opencode/src/cli/cmd/acp.ts | 7 + packages/opencode/src/cli/cmd/run.ts | 8 + packages/opencode/src/cli/cmd/serve.ts | 5 + packages/opencode/src/cli/cmd/tui.ts | 9 + packages/opencode/src/cli/cmd/web.ts | 5 + packages/opencode/src/provider/provider.ts | 53 +++-- packages/opencode/src/session/prompt.ts | 22 +- .../opencode/test/acp/default-model.test.ts | 60 ++++-- .../opencode/test/acp/service-session.test.ts | 51 +++-- .../altimate-base-auto-register.test.ts | 200 ++++++++++++++++++ .../altimate/altimate-base-catalog.test.ts | 14 +- .../opencode/test/provider/provider.test.ts | 61 ++++-- packages/tui/src/context/local.tsx | 82 +++++-- packages/tui/test/context/local.test.ts | 21 ++ 17 files changed, 700 insertions(+), 143 deletions(-) create mode 100644 packages/opencode/test/altimate/altimate-base-auto-register.test.ts diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 74b41a1b99..e2902dae16 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -787,23 +787,15 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { ProviderV2.ID, Provider.Info > - // altimate_change start — keep the managed provider out of ACP unless this project allows it + // altimate_change start — Altimate Base is no longer consent-gated (it auto-registers at + // process startup with no disclosure dialog), so ACP no longer needs to hide it from projects + // with a custom `config.provider` block. `providers` already reflects the real + // enabled_providers/disabled_providers verdict (Provider.state()'s isProviderAllowed), so no + // extra stripping is needed here — the mere presence of an unrelated `config.provider` entry + // (e.g. `provider: { anthropic: {...} }`) must not hide Base any more than it hides any other + // connected provider. const config = configResponse?.data - const configLoaded = config !== undefined - const withoutManagedBase = () => - Object.fromEntries(Object.entries(providers).filter(([id]) => id !== "altimate-free")) as Record< - ProviderV2.ID, - Provider.Info - > - const hasProviderAllowlist = Object.keys(config?.provider ?? {}).length > 0 - // `config.provider` is a per-provider CUSTOMIZATION map (apiKey, options, headers) — the docs - // demonstrate it as a single-entry block. It gates ONLY the consent-gated managed provider, - // which config must never be able to switch on. Every other connected provider stays - // advertised, so `provider: { anthropic: {...} }` does not hide the user's other authenticated - // models from the ACP catalogue or invalidate a restored session pinned to one of them. - // A failed config lookup cannot prove that this project permits the request-logging managed - // provider either, so it fails closed the same way an explicit allowlist without it does. - const snapshotProviders = configLoaded && !hasProviderAllowlist ? providers : withoutManagedBase() + const snapshotProviders = providers // altimate_change end const modes = agents .filter((agent) => agent.mode !== "subagent" && agent.hidden !== true) @@ -846,11 +838,16 @@ export function defaultModelFromConfig( configuredModel: string | undefined, providers: Record, providerFilter?: Record, - // altimate_change start — persisted recents and default-switch consent, normalized by the shared state reader + // altimate_change start — persisted recents, normalized by the shared state reader. + // `declinedManagedBaseDefault` is still accepted (and its persisted value still read by callers) + // for compatibility, but is no longer consulted below — see Provider.defaultModel()'s identical + // change for why: OpenCode Zen's keyless tier is broken outright, so there is no longer a + // "declined the switch, stay on public Zen" choice worth honoring. declinedManagedBaseDefault = false, recent: Awaited>["recent"] = [], // altimate_change end ): Directory.DefaultModel | undefined { + void declinedManagedBaseDefault // altimate_change — see the param's declaration comment above // altimate_change start — fork Provider ids are branded ProviderID/ModelID; re-brand to core ProviderV2.ID/ModelV2.ID (identity at runtime) const configured = configuredModel ? (() => { @@ -863,13 +860,25 @@ export function defaultModelFromConfig( const configuredProviderEntries = Object.keys(providerFilter ?? {}) const hasProviderAllowlist = configuredProviderEntries.length > 0 + // altimate_change start — Base is excluded only by a real enabled_providers/disabled_providers + // verdict, which `providers` already reflects (mirrors Provider.defaultModel()'s identical fix). + // The mere presence of OTHER `providerFilter` (config.provider) entries is not a reason to hide + // it — computed here, ahead of the recents loop below, so a stale public-Zen recent can be + // recognized and replaced rather than replayed. + const baseProvider = providers[ProviderV2.ID.make("altimate-free")] + const registeredBaseAvailable = Boolean(baseProvider?.models[ModelV2.ID.make("altimate-base")]) + // altimate_change end + for (const entry of recent) { const providerID = ProviderV2.ID.make(entry.providerID) const modelID = ModelV2.ID.make(entry.modelID) if (!Object.hasOwn(providers, providerID)) continue - if (!Object.hasOwn(providers[providerID].models, modelID)) continue - // Match Provider.defaultModel(): only managed Base recents are restricted by an allowlist. - if (entry.providerID === "altimate-free" && hasProviderAllowlist) continue + const provider = providers[providerID] + if (!Object.hasOwn(provider.models, modelID)) continue + // altimate_change — a stale recent pick of the now-broken keyless public Zen tier is replaced + // by registered Base rather than replayed; it is guaranteed to fail otherwise. A + // credentialed/paid selection is never overridden. + if (registeredBaseAvailable && Provider.isPublicZen(provider)) continue return { providerID, modelID } } @@ -890,18 +899,13 @@ export function defaultModelFromConfig( // Recents above come from model.json, not session storage. After configured/recent choices // and the backend preference, use the opencode provider, then the sorted best model, // without extra session/message reads. - const baseProvider = providers[ProviderV2.ID.make("altimate-free")] - const registeredBaseAvailable = Boolean(baseProvider?.models[ModelV2.ID.make("altimate-base")]) && !hasProviderAllowlist - const providerAllowed = (id: string) => - id !== "altimate-free" && - (!hasProviderAllowlist || Object.prototype.hasOwnProperty.call(providerFilter, id)) && - !( - registeredBaseAvailable && - !declinedManagedBaseDefault && - id === "opencode" && - providers[ProviderV2.ID.make(id)]?.options.apiKey === "public" && - !providers[ProviderV2.ID.make(id)]?.key - ) + const providerAllowed = (id: string) => { + if (id === "altimate-free") return false + if (hasProviderAllowlist && !Object.prototype.hasOwnProperty.call(providerFilter, id)) return false + const info = providers[ProviderV2.ID.make(id)] + if (registeredBaseAvailable && info && Provider.isPublicZen(info)) return false + return true + } const opencodeProvider = providerAllowed("opencode") ? providers[ProviderV2.ID.make("opencode")] : undefined const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models)).find((model) => model.id !== "big-pickle") @@ -916,13 +920,12 @@ export function defaultModelFromConfig( ).find((model) => !(model.providerID === "opencode" && model.id === "big-pickle")) if (best) return { providerID: ProviderV2.ID.make(best.providerID), modelID: ModelV2.ID.make(best.id) } - // Altimate Base replaces Big Pickle as the free fallback only after the user consented and - // registered (which is why it is present in `providers`). Anything the user actually connected - // outranks the request-logging tier, except the keyless public Zen tier, which ranks below - // registered Base unless the user declined the default switch in model.json. After a decline, - // public Zen stays in both scans and Base is only the last resort. A keyed Zen account still - // wins. A project provider block cannot force the managed model; an explicit configured model - // above remains authoritative. + // Altimate Base replaces Big Pickle as the free fallback once it auto-registers (which is why it + // is present in `providers`). Anything the user actually connected outranks the request-logging + // tier, and the keyless public Zen tier now ranks below registered Base unconditionally — + // OpenCode Zen rejects keyless traffic outright, so there is no "declined the switch" choice left + // to honor. A keyed Zen account still wins. A project provider block cannot force the managed + // model; an explicit configured model above remains authoritative. if (registeredBaseAvailable) { return { providerID: ProviderV2.ID.make("altimate-free"), modelID: ModelV2.ID.make("altimate-base") } } @@ -961,8 +964,25 @@ export async function selectDefaultModel(snapshot: Directory.Snapshot) { return undefined } +// altimate_change start — a restored session's model can be a stale keyless public-Zen pick from +// before this machine registered Altimate Base. Reporting it as "not available" here (rather than +// letting it flow through verbatim) routes every `availableModel(...) ?? requireDefaultModel(...)` +// call site — loadSession, resumeSession, forkSession — to `requireDefaultModel`, whose +// `defaultModelFromConfig` already prefers registered Base over public Zen. That restores the +// session onto Base instead of replaying a model OpenCode Zen now rejects outright. A +// credentialed/paid selection (or any non-Zen provider) is never treated as unavailable here. +function isStalePublicZenSnapshotModel(snapshot: Directory.Snapshot, model: Directory.DefaultModel): boolean { + const baseProvider = snapshot.providers[ProviderV2.ID.make("altimate-free")] + const registeredBaseAvailable = Boolean(baseProvider?.models[ModelV2.ID.make("altimate-base")]) + if (!registeredBaseAvailable) return false + const provider = snapshot.providers[model.providerID] + return Boolean(provider && Provider.isPublicZen(provider)) +} +// altimate_change end + function availableModel(snapshot: Directory.Snapshot, model: Directory.DefaultModel | undefined) { if (!model) return undefined + if (isStalePublicZenSnapshotModel(snapshot, model)) return undefined return snapshot.modelOptions.some( (option) => option.providerID === model.providerID && option.modelID === model.modelID, ) diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 227464fc48..41bceb6e68 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -373,14 +373,14 @@ export async function registerAfterConsent( const startedAt = performance.now() if (!redeemConsent(token)) { const expired = new RegistrationError("Altimate Base consent expired. Reopen setup and try again.", "cancelled") - reportRegistration("cancelled", startedAt, expired) + reportRegistration("cancelled", startedAt, expired, "consent") throw expired } let configuredGateway: string try { configuredGateway = gatewayUrl() } catch (error) { - reportRegistration(registrationResult(error), startedAt, error) + reportRegistration(registrationResult(error), startedAt, error, "consent") throw error } // altimate_change end @@ -432,8 +432,8 @@ export async function registerAfterConsent( // dedupe bookkeeping above, are untouched; the rejection handler keeps the branch from surfacing // as an unhandled rejection. started.then( - () => reportRegistration("success", startedAt), - (error: unknown) => reportRegistration(registrationResult(error), startedAt, error), + () => reportRegistration("success", startedAt, undefined, "consent"), + (error: unknown) => reportRegistration(registrationResult(error), startedAt, error, "consent"), ) // altimate_change end return started @@ -450,7 +450,12 @@ function registrationResult(error: unknown): RegistrationResult { return "error" } -function reportRegistration(result: RegistrationResult, startedAt: number, error?: unknown) { +function reportRegistration( + result: RegistrationResult, + startedAt: number, + error?: unknown, + origin?: "auto" | "consent", +) { const status = error instanceof RegistrationError ? error.status : undefined const event: Telemetry.Event = { type: "altimate_base_registration", @@ -459,6 +464,15 @@ function reportRegistration(result: RegistrationResult, startedAt: number, error result, duration_ms: Math.round(performance.now() - startedAt), ...(status !== undefined ? { status } : {}), + ...(origin ? { origin } : {}), + } + if (origin === "auto") { + // autoRegister runs at process boot, before any prompt has initialised telemetry, and can run + // entirely outside an Instance context. Calling Telemetry.init() from here (as the consent + // path does below) would treat config as enabled regardless of a `telemetry.disabled` + // opt-out. track() buffers the event until a real init() elsewhere enables it. + Telemetry.track(event) + return } // Registration can run before any prompt has initialised telemetry (TUI worker, serve after a // session shutdown). init() is idempotent; tracking after it guarantees the anchor flush fires @@ -470,6 +484,125 @@ function reportRegistration(result: RegistrationResult, startedAt: number, error } // altimate_change end +// Marker for autoRegister's "the user explicitly logged out" skip. Distinct from +// RegistrationError so autoRegister can tell "nothing to do" apart from a real failure without +// inspecting message text. +class AutoRegisterSkippedLoggedOutError extends Error {} + +export type AutoRegisterResult = + | { status: "registered" } + | { status: "skipped"; reason: "env" | "no-gateway" | "logged-out" | "already-registered" } + | { status: "failed"; kind: Exclude } + +function autoRegisterDisabledByEnv(): boolean { + const raw = process.env["ALTIMATE_BASE_AUTO_REGISTER"]?.trim().toLowerCase() + return raw === "0" || raw === "false" +} + +/** + * Registration body for the no-consent auto-register path, run entirely under the shared + * registration lock. Every read of the store happens while holding LOCK_KEY, so it always sees + * the latest state — including a logout that raced a caller's earlier, lock-free check (see + * `autoRegister`'s "already registered" fast path). + */ +async function autoRegisterLocked(configuredGateway: string, signal: AbortSignal | undefined): Promise { + return Flock.withLock(LOCK_KEY, async () => { + let stored: FreeTierStore.Record | undefined + try { + stored = await FreeTierStore.read() + } catch (error) { + if (!(error instanceof FreeTierStore.InvalidCredentialStoreError)) throw error + log.warn("removing invalid Altimate Base credential record before auto-registration", { error }) + await FreeTierStore.remove() + stored = undefined + } + if (stored?.logoutNonce && !stored.apiKey) throw new AutoRegisterSkippedLoggedOutError() + const fresh = credentialsFromStored(stored) + if ( + fresh && + fresh.baseURL === configuredGateway && + !expired(fresh) && + !fresh.rejected && + !credentialWasRejected(fresh) + ) + return fresh + return registerOnce(configuredGateway, stored?.logoutNonce, signal) + }) +} + +/** + * Register with the Altimate Base gateway without a consent token — the no-consent-gate default + * every entrypoint now calls at startup. Shares LOCK_KEY, the `inflight` dedupe map, and + * `registerOnce` with `registerAfterConsent`, so an auto-register and an explicit (consent) + * registration racing for the same gateway can never both hit the network. + * + * Never throws: every failure mode resolves to a `{ status: "skipped" | "failed" }` result. + */ +export async function autoRegister(signal?: AbortSignal): Promise { + try { + if (autoRegisterDisabledByEnv()) return { status: "skipped", reason: "env" } + let configuredGateway: string + try { + configuredGateway = gatewayUrl() + } catch (error) { + if (error instanceof ConfigurationError) return { status: "skipped", reason: "no-gateway" } + throw error + } + + // Lock-free fast path: once a machine is registered, every later launch skips without ever + // touching the flock. The logout check below still runs inside the lock for every launch that + // reaches it, since that's the one check a lock-free read here could race. + const alreadyRegistered = await isRegistered().catch(() => false) + if (alreadyRegistered) return { status: "skipped", reason: "already-registered" } + + const dedupeKey = configuredGateway + const existing = inflight.get(dedupeKey) + const startedAt = performance.now() + const started = + existing ?? + (() => { + const promise = autoRegisterLocked(configuredGateway, signal).finally(() => { + if (inflight.get(dedupeKey) === promise) inflight.delete(dedupeKey) + }) + inflight.set(dedupeKey, promise) + return promise + })() + + try { + await started + } catch (error) { + if (error instanceof AutoRegisterSkippedLoggedOutError) return { status: "skipped", reason: "logged-out" } + const kind = registrationResult(error) as Exclude + // Only the call that actually owns the in-flight promise reports it, so a dedupe hit never + // double-counts one registration attempt. + if (!existing) reportRegistration(kind, startedAt, error, "auto") + return { status: "failed", kind } + } + if (!existing) reportRegistration("success", startedAt, undefined, "auto") + return { status: "registered" } + } catch (error) { + log.error("Altimate Base auto-registration failed unexpectedly", { error }) + return { status: "failed", kind: "error" } + } +} + +/** + * Await `autoRegister()` for at most `ms`, then return regardless. A still-running attempt keeps + * going in the background — its credentials are persisted to disk on success, so a late result is + * picked up by the next launch even though this one already moved on. + */ +export function autoRegisterWithin(ms = 3000): Promise { + const attempt = autoRegister().catch((error) => { + log.error("Altimate Base auto-registration rejected unexpectedly", { error }) + return { status: "failed", kind: "error" } as const + }) + const timeout = new Promise<{ status: "pending" }>((resolve) => { + const timer = setTimeout(() => resolve({ status: "pending" }), ms) + timer.unref?.() + }) + return Promise.race([attempt, timeout]) +} + function targetUrl(input: RequestInfo | URL): string { return typeof input === "string" ? input : input instanceof URL ? input.href : input.url } diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 8754661a9c..6c4c002e7a 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -689,6 +689,10 @@ export namespace Telemetry { duration_ms: number /** HTTP status when result is "http". */ status?: number + /** How this attempt was triggered: the no-consent auto-register path every entrypoint now + * calls at startup, or the explicit TUI/serve disclosure flow. Optional so events emitted + * before this field existed still validate. */ + origin?: "auto" | "consent" } // altimate_change end // altimate_change start — telemetry for skill management operations diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 1e0532ce99..ce04ff2a9d 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -5,6 +5,9 @@ import { ServerAuth } from "@/server/auth" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { ACPProfile } from "@/acp/profile" +// altimate_change — auto-register Altimate Base before the directory/provider snapshot is built +import { FreeTier } from "@/altimate/free/client" +// altimate_change end export const AcpCommand = effectCmd({ command: "acp", @@ -21,6 +24,10 @@ export const AcpCommand = effectCmd({ const { ACP } = yield* Effect.promise(() => import("@/acp/agent")) ACPProfile.mark("cli.acp.handler") process.env.OPENCODE_CLIENT = "acp" + // altimate_change start — auto-register before Server.listen, ahead of the ACP directory + // snapshot (providers/defaultModel) that ACP.init/loadDirectorySnapshot builds + yield* Effect.promise(() => FreeTier.autoRegisterWithin()) + // altimate_change end const opts = yield* resolveNetworkOptions(args) // altimate_change start — upstream_fix: preserve async server listen inside ACP profiler measure const server = yield* Effect.promise(() => diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 72ef1b9f0a..6b165acdb9 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1656,6 +1656,14 @@ You are speaking to a non-technical business executive. Follow these rules stric await syncDatamateUrlFromVscodeMcp(process.cwd()).catch(() => {}) } // altimate_change end + // altimate_change start — auto-register Altimate Base before provider state is first built. + // Only for a local run: --attach already returned above and targets a remote server whose own + // process is responsible for its own registration. + { + const { FreeTier } = await import("../../altimate/free/client") + await FreeTier.autoRegisterWithin() + } + // altimate_change end await bootstrap(process.cwd(), async () => { const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 88c9a52f27..90638f41fa 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -65,6 +65,11 @@ export const ServeCommand = effectCmd({ const { syncDatamateUrlFromVscodeMcp } = yield* Effect.promise(() => import("../../altimate/datamate-transport")) yield* Effect.promise(() => syncDatamateUrlFromVscodeMcp(process.cwd())) // altimate_change end + // altimate_change start — auto-register Altimate Base before provider state is first built. + // `serve` is the VS Code/Cursor extension's process — no TUI, no interactive gate — so this is + // the only chance to have Base ready before the first provider list/default-model resolution. + yield* Effect.promise(() => FreeTier.autoRegisterWithin()) + // altimate_change end const server = yield* Effect.sync(() => Server.listen(opts)) // altimate_change start — upstream_fix: branding regression in log line console.log(`altimate-code server listening on http://${server.hostname}:${server.port}`) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 93a1806739..05305f85df 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -23,6 +23,10 @@ import { Telemetry } from "@/altimate/telemetry" import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" import { AltimateApi } from "@/altimate/api/client" // altimate_change end +// altimate_change start — auto-register Altimate Base before the worker (which loads +// provider/instance state) is spawned +import { FreeTier } from "@/altimate/free/client" +// altimate_change end declare global { const OPENCODE_WORKER_PATH: string @@ -165,6 +169,11 @@ export const TuiThreadCommand = cmd({ } // altimate_change end + // altimate_change start — auto-register Altimate Base before the worker is spawned. The + // worker starts loading instance/provider state as soon as it boots (worker.ts's + // `traceReady` chain), so this has to land on the parent thread first. + await FreeTier.autoRegisterWithin() + // altimate_change end // altimate_change start — hand the launch correlation id to the worker explicitly. A Bun // Worker does not see runtime mutations to process.env, so without this the worker mints its // own and the TUI-thread and worker-thread halves of the onboarding funnel cannot be joined. diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index f0e54f8eee..48097f7d6b 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -6,6 +6,8 @@ import { AppRuntime } from "../../effect/app-runtime" import { Flag } from "../../flag/flag" import open from "open" import { networkInterfaces } from "os" +// altimate_change — auto-register Altimate Base before the server (and its provider state) starts +import { FreeTier } from "../../altimate/free/client" function getNetworkIPs() { const nets = networkInterfaces() @@ -40,6 +42,9 @@ export const WebCommand = cmd({ UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } const opts = await AppRuntime.runPromise(resolveNetworkOptions(args)) + // altimate_change start — auto-register Altimate Base before provider state is first built + await FreeTier.autoRegisterWithin() + // altimate_change end const server = Server.listen(opts) UI.empty() UI.println(UI.logo(" ")) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f05cd2ec18..68d7a4430d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -2193,6 +2193,17 @@ export namespace Provider { ) } + // altimate_change start — shared "is this the keyless public Zen tier?" predicate. + // OpenCode Zen's free tier now rejects keyless traffic outright (2026-09-17), so every place that + // used to weigh public Zen against registered Base must agree on what "public Zen" means. A + // provider counts only when it is the built-in `opencode` provider AND was auto-configured with + // the `"public"` placeholder key AND the user never supplied a real one (`provider.key` is set + // only by an authenticated key, never by the placeholder). + export function isPublicZen(provider: Pick): boolean { + return provider.id === "opencode" && provider.options["apiKey"] === "public" && !provider.key + } + // altimate_change end + // altimate_change start — normalize persisted model references and default-switch consent function isModelReference(model: unknown): model is { providerID: ProviderID; modelID: ModelID } { if (!model || typeof model !== "object") return false @@ -2231,8 +2242,16 @@ export namespace Provider { const baseProviderID = ProviderID.make(FreeTier.PROVIDER_ID) const baseModelID = ModelID.make(FreeTier.MODEL_ID) const baseProvider = providers[baseProviderID] - const registeredBaseAvailable = Boolean(baseProvider?.models[baseModelID]) && !hasProviderAllowlist - const { recent, declinedManagedBaseDefault } = await readDefaultModelState() + // altimate_change start — Base is excluded only by an actual enabled_providers/disabled_providers + // verdict, which `providers` (built in state() via isProviderAllowed) already reflects. The + // mere presence of OTHER custom `config.provider` entries (`hasProviderAllowlist`) used to hide + // Base here too — that's the bug OpenCode Zen's keyless rejection turned into 360 failed + // machines in 5 days (2026-09-17). `readDefaultModelState()` still parses + // `declinedManagedBaseDefault` from disk (the TUI still writes it, and other code still reads + // it), but it no longer vetoes this default: with public Zen broken outright, a registered Base + // can no longer be the thing a user "declined" in favor of a keyless model that will just fail. + const registeredBaseAvailable = Boolean(baseProvider?.models[baseModelID]) + const { recent } = await readDefaultModelState() for (const entry of recent) { // A recent entry is the user's own last pick, so it is never rewritten here — not even a // legacy Big Pickle one. The TUI owns the migration because it owns the disclosure, and @@ -2242,9 +2261,10 @@ export namespace Provider { if (!Object.hasOwn(providers, entry.providerID)) continue const provider = providers[entry.providerID] if (!Object.hasOwn(provider.models, entry.modelID)) continue - // Keep legacy recent-model behavior unchanged for every other provider; - // only the consent-gated managed provider must not bypass this project. - if (entry.providerID === FreeTier.PROVIDER_ID && !providerAllowed(String(entry.providerID))) continue + // A stale recent pick of the now-broken keyless public Zen tier is replaced by registered + // Base rather than replayed — it is guaranteed to fail otherwise. A credentialed/paid + // selection (real key on the `opencode` provider, or any other provider) is never overridden. + if (registeredBaseAvailable && isPublicZen(provider)) continue return { providerID: entry.providerID, modelID: entry.modelID } } // altimate_change end @@ -2269,13 +2289,13 @@ export namespace Provider { // altimate_change start — select registered Altimate Base and never select Big Pickle implicitly // Altimate Base owns the free fallback role that used to belong to Big Pickle. Anything the - // user has actually connected outranks the request-logging tier; the keyless public Zen tier - // ranks below registered Base unless the user declined the default switch in model.json. - // After a decline, public Zen stays in the scan and Base is only the last resort. - // A keyed Zen account still wins, so adding a paid key never silently routes prompts to the - // free gateway. A project provider - // block cannot force the managed model; an explicit `model` setting above remains - // authoritative. + // user has actually connected outranks the request-logging tier. The keyless public Zen tier + // ranks below registered Base unconditionally now — OpenCode Zen rejects keyless traffic + // outright (2026-09-17), so there is no longer a "declined the switch, stay on public Zen" + // choice to honor; that veto used to live here via `declinedManagedBaseDefault`. A keyed Zen + // account still wins, so adding a paid key never silently routes prompts to the free gateway. + // A project provider block cannot force the managed model; an explicit `model` setting above + // remains authoritative. // Base is excluded from the ordinary scan so it can only be reached by the last-resort branch // below; otherwise it would win here whenever no provider block narrows the candidate list. const candidates = Object.values(providers).filter( @@ -2283,14 +2303,7 @@ export namespace Provider { ) if (candidates.length === 0 && !registeredBaseAvailable) throw new Error("no providers found") for (const provider of candidates) { - if ( - registeredBaseAvailable && - !declinedManagedBaseDefault && - provider.id === "opencode" && - provider.options.apiKey === "public" && - !provider.key - ) - continue + if (registeredBaseAvailable && isPublicZen(provider)) continue const model = sort(Object.values(provider.models)).find( (candidate) => !(provider.id === "opencode" && candidate.id === "big-pickle"), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b2ebabeefe..73342cd7d4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -13,6 +13,8 @@ import { Session } from "." import { Agent } from "../agent/agent" import { Provider } from "../provider/provider" import { ModelID, ProviderID } from "../provider/schema" +// altimate_change — a session's last-used model can be a stale keyless public-Zen pick; see lastModel() +import { FreeTier } from "../altimate/free/client" // altimate_change start — shared family→vendor classifier (#888 J1) import { familyVendor } from "../provider/family" // altimate_change end @@ -2030,9 +2032,27 @@ export namespace SessionPrompt { }) async function lastModel(sessionID: SessionID) { + // altimate_change start — a session's last-used model can be a stale keyless public-Zen pick + // from before this machine registered Altimate Base. OpenCode Zen now rejects that tier + // outright, so replaying it here (e.g. on `run --continue`) is guaranteed to fail; re-resolve + // the default instead so the session picks up Base. A credentialed/paid selection (or any + // non-Zen provider) is returned unchanged. + // + // Only `Provider.isPublicZen()` can ever be true for the `opencode` provider id, and + // `Provider.list()` below is expensive (it can hit the models.dev catalog) — check both cheap, + // sync-ish preconditions first so the common case (any other provider, or Base not + // registered) never pays that cost. for await (const item of MessageV2.stream(sessionID)) { - if (item.info.role === "user" && item.info.model) return item.info.model + if (item.info.role === "user" && item.info.model) { + if (item.info.model.providerID === "opencode" && (await FreeTier.isRegistered())) { + const providers = await Provider.list() + const provider = providers[item.info.model.providerID] + if (provider && Provider.isPublicZen(provider)) return Provider.defaultModel() + } + return item.info.model + } } + // altimate_change end return Provider.defaultModel() } diff --git a/packages/opencode/test/acp/default-model.test.ts b/packages/opencode/test/acp/default-model.test.ts index 5cdfebfc1c..8e8105f074 100644 --- a/packages/opencode/test/acp/default-model.test.ts +++ b/packages/opencode/test/acp/default-model.test.ts @@ -99,11 +99,17 @@ describe("ACP defaultModelFromConfig", () => { }) }) + // altimate_change — a stale recent pick of the (now fully-broken) keyless public Zen tier is + // replaced by registered Base rather than replayed (OpenCode Zen rejects that traffic outright + // as of 2026-09-17); this used to be reversed back when public Zen still worked. Base itself is + // also no longer excluded from the recents loop by the mere presence of a `config.provider` + // filter — only a real enabled_providers/disabled_providers verdict can do that, which this + // `providerFilter` parameter (config.provider) is not. test.each([ { - name: "public Zen recent outranks registered Base", + name: "registered Base replaces a stale public Zen recent", recent: ["opencode/nemotron-3-super-free"], - expected: "opencode/nemotron-3-super-free", + expected: "altimate-free/altimate-base", }, { name: "unloaded provider recent is ignored", recent: ["missing/model"], expected: "altimate-free/altimate-base" }, { name: "missing model recent is ignored", recent: ["opencode/missing"], expected: "altimate-free/altimate-base" }, @@ -112,21 +118,21 @@ describe("ACP defaultModelFromConfig", () => { { name: "__proto__ model is ignored", recent: ["opencode/__proto__"], expected: "altimate-free/altimate-base" }, { name: "constructor model is ignored", recent: ["opencode/constructor"], expected: "altimate-free/altimate-base" }, { - name: "first available recent wins", + name: "a stale public Zen recent is skipped in favor of a later valid recent", recent: ["missing/model", "opencode/missing", "opencode/nemotron-3-super-free", "altimate-free/altimate-base"], - expected: "opencode/nemotron-3-super-free", + expected: "altimate-free/altimate-base", }, { - name: "Base recent is skipped with an allowlist even when included", + name: "Base recent wins regardless of a config.provider filter", recent: ["altimate-free/altimate-base", "opencode/nemotron-3-super-free"], filter: { "altimate-free": {}, opencode: {} }, - expected: "opencode/nemotron-3-super-free", + expected: "altimate-free/altimate-base", }, { - name: "non-managed recent retains precedence outside the allowlist", + name: "a stale public Zen recent outside the allowlist is still replaced by Base", recent: ["opencode/nemotron-3-super-free"], filter: { "altimate-backend": {} }, - expected: "opencode/nemotron-3-super-free", + expected: "altimate-free/altimate-base", }, { name: "configured model outranks recents", @@ -153,12 +159,15 @@ describe("ACP defaultModelFromConfig", () => { }) }) + // altimate_change — `declinedManagedBaseDefault` no longer vetoes Base: OpenCode Zen rejects + // keyless traffic outright, so there's no working public-Zen alternative left to honor a + // decline with. Every flag value, including `true`, now resolves to Base. test.each([ - { flag: true, providerID: "opencode", modelID: "nemotron-3-super-free" }, + { flag: true, providerID: "altimate-free", modelID: "altimate-base" }, { flag: false, providerID: "altimate-free", modelID: "altimate-base" }, { flag: undefined, providerID: "altimate-free", modelID: "altimate-base" }, { flag: "yes", providerID: "altimate-free", modelID: "altimate-base" }, - ])("honors persisted default-switch decline flag $flag", async ({ flag, providerID, modelID }) => { + ])("persisted default-switch decline flag $flag no longer vetoes Base", async ({ flag, providerID, modelID }) => { // altimate_change — Cursor/cubic review round 5, P2/P3: `Global.Path.state` is not // test-isolated on its own (unlike `Global.Path.home`), so writing `model.json` through it // directly touched the real developer state directory and raced other tests doing the same. @@ -220,17 +229,26 @@ describe("ACP defaultModelFromConfig", () => { expect(result?.providerID).toBe(ProviderV2.ID.make("local-llm")) }) - test("public Zen stays available without registered Base or when a provider allowlist excludes Base", () => { + test("public Zen stays available when Base is not registered", () => { const zen = provider("opencode", ["nemotron-3-super-free"]) zen.options.apiKey = "public" expect(ACPService.defaultModelFromConfig(undefined, providers(zen))?.providerID).toBe(ProviderV2.ID.make("opencode")) + }) + + // altimate_change — a `config.provider` filter naming only `opencode` used to be read as "this + // project excludes Base," keeping public Zen available. Base is now excluded only by a real + // enabled_providers/disabled_providers verdict, so once it's registered it outranks public Zen + // here too, regardless of this filter. + test("registered Base outranks public Zen even when a config.provider filter names only opencode", () => { + const zen = provider("opencode", ["nemotron-3-super-free"]) + zen.options.apiKey = "public" expect( ACPService.defaultModelFromConfig( undefined, providers(zen, provider("altimate-free", ["altimate-base"])), { opencode: {} }, )?.providerID, - ).toBe(ProviderV2.ID.make("opencode")) + ).toBe(ProviderV2.ID.make("altimate-free")) }) test("an explicitly configured public Zen model still outranks registered Base", () => { @@ -335,22 +353,32 @@ describe("ACP defaultModelFromConfig", () => { }) }) - test("does not recover an excluded managed provider through the sorted fallback", () => { + // altimate_change — a `config.provider` filter that omits `altimate-free` used to suppress Base + // through the sorted fallback AND the last-resort return. Base is now excluded only by a real + // enabled_providers/disabled_providers verdict, so with nothing else selectable it is still + // reachable as the last resort here. + test("a config.provider filter that omits Base still allows it as the last resort", () => { const result = ACPService.defaultModelFromConfig( undefined, providers(provider("altimate-free", ["altimate-base"]), provider("opencode", ["big-pickle"])), { opencode: {} }, ) - expect(result).toBeUndefined() + expect(result).toEqual({ + providerID: ProviderV2.ID.make("altimate-free"), + modelID: ModelV2.ID.make("altimate-base"), + }) }) - test("an Altimate Base-only provider block cannot force the managed provider", () => { + test("an Altimate Base-only provider block still resolves to registered Base as the last resort", () => { const result = ACPService.defaultModelFromConfig( undefined, providers(provider("altimate-free", ["altimate-base"]), provider("openai", ["gpt-5"])), { "altimate-free": {} }, ) - expect(result).toBeUndefined() + expect(result).toEqual({ + providerID: ProviderV2.ID.make("altimate-free"), + modelID: ModelV2.ID.make("altimate-base"), + }) }) test("honors an explicit provider allowlist that includes altimate-backend", () => { diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 654d8c36d1..b87f394917 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -348,6 +348,11 @@ describe("ACP service sessions", () => { expect(creates).toHaveLength(0) }) + // altimate_change — both persisted states used to keep a session on public Zen (a valid recent + // pick in the first case, an honored decline flag in the second). OpenCode Zen rejects keyless + // traffic outright now, so a stale recent pointing at it is replaced by Base, and the decline + // flag no longer vetoes Base either — both cases now resolve to Base, same as the "reset to []" + // case that brackets them. it.each([ { recent: [{ providerID: "opencode", modelID: "nemotron-3-super-free" }] }, { recent: [], declinedManagedBaseDefault: true }, @@ -389,7 +394,7 @@ describe("ACP service sessions", () => { await fs.writeFile(stateFile, JSON.stringify(state)) const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) - expect(select(second, "model")?.currentValue).toBe("opencode/nemotron-3-super-free") + expect(select(second, "model")?.currentValue).toBe("altimate-free/altimate-base") await fs.writeFile(stateFile, JSON.stringify({ recent: [] })) const third = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) @@ -455,7 +460,14 @@ describe("ACP service sessions", () => { expect(models.some((option) => option.value.includes("claude-sonnet-4"))).toBe(true) }) - it("does not advertise Altimate Base through an ACP snapshot excluded by a provider allowlist", async () => { + // altimate_change — these three tests used to guard the OLD consent-gated Base behavior: a + // `config.provider` block for an unrelated provider hid Base from the ACP catalogue entirely + // (even when explicitly configured as the model), because Base required a disclosure the project + // config could never bypass. Altimate Base now auto-registers with no consent gate, and + // `providers` (built server-side) already reflects the real enabled_providers/disabled_providers + // verdict — so a `config.provider` block for some OTHER provider is no longer a reason to hide + // or refuse it. Rewritten to assert the new behavior instead of deleting the coverage. + it("advertises Altimate Base through the ACP snapshot despite a config.provider block for another provider", async () => { const baseProvider = { ...provider, id: ProviderID.make("altimate-free"), @@ -477,11 +489,11 @@ describe("ACP service sessions", () => { const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) const models = flattenSelectOptions(select(result, "model")) - expect(models.some((option) => option.value.includes("altimate-base"))).toBe(false) + expect(models.some((option) => option.value.includes("altimate-base"))).toBe(true) expect(models.some((option) => option.value.includes("test-model"))).toBe(true) }) - it("cannot enable Altimate Base merely by naming it in an ACP provider allowlist", async () => { + it("advertises Altimate Base whether or not it is itself named in a config.provider block", async () => { const baseProvider = { ...provider, id: ProviderID.make("altimate-free"), @@ -503,15 +515,11 @@ describe("ACP service sessions", () => { const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) const models = flattenSelectOptions(select(result, "model")) - expect(models.some((option) => option.value.includes("altimate-base"))).toBe(false) + expect(models.some((option) => option.value.includes("altimate-base"))).toBe(true) expect(models.some((option) => option.value.includes("test-model"))).toBe(true) }) - it("does not select or route to a configured Altimate Base model excluded by a provider allowlist", async () => { - // The bug this guards: `model: "altimate-free/altimate-base"` set alongside a provider - // allowlist that omits "altimate-free" got resolved against the UNFILTERED provider map even - // though the SAME allowlist correctly hid Altimate Base from the advertised catalogue (see the - // two tests above) — so ACP still selected and routed to it despite it being excluded. + it("selects and routes to a configured Altimate Base model despite a config.provider block for another provider", async () => { const baseProvider = { ...provider, id: ProviderID.make("altimate-free"), @@ -534,13 +542,16 @@ describe("ACP service sessions", () => { const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) const models = flattenSelectOptions(select(result, "model")) - expect(models.some((option) => option.value.includes("altimate-base"))).toBe(false) - expect(select(result, "model")?.currentValue).not.toContain("altimate-base") - // Falls through to the allowed provider's own catalogue instead of failing closed entirely. - expect(select(result, "model")?.currentValue).toBe("test/test-model") + expect(models.some((option) => option.value.includes("altimate-base"))).toBe(true) + expect(select(result, "model")?.currentValue).toBe("altimate-free/altimate-base") }) - it("fails closed for Altimate Base when the project config lookup fails", async () => { + // altimate_change — this used to assert a fail-CLOSED default (hide Base) when the project + // config lookup failed, because a failed lookup could not prove the project's consent-gated + // allowlist permitted Base. There is no such allowlist gate on Base any more (see the + // rewritten tests above), so a failed config lookup is just "no config restrictions this + // launch" and the session proceeds normally onto the one available provider, Base. + it("still resolves Altimate Base as the default when the project config lookup fails", async () => { const baseProvider = { ...provider, id: ProviderID.make("altimate-free"), @@ -559,14 +570,10 @@ describe("ACP service sessions", () => { configFails: true, }) - const failure = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }).pipe(Effect.flip)) + const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) - expect(failure).toMatchObject({ - _tag: "ACPServiceFailureError", - safeMessage: "No supported model is configured. Register Altimate Base or configure another provider.", - service: "model", - }) - expect(creates).toHaveLength(0) + expect(select(result, "model")?.currentValue).toBe("altimate-free/altimate-base") + expect(creates).toHaveLength(1) }) it("fails before forking when no supported implicit model exists", async () => { diff --git a/packages/opencode/test/altimate/altimate-base-auto-register.test.ts b/packages/opencode/test/altimate/altimate-base-auto-register.test.ts new file mode 100644 index 0000000000..747ca01874 --- /dev/null +++ b/packages/opencode/test/altimate/altimate-base-auto-register.test.ts @@ -0,0 +1,200 @@ +// Coverage for `FreeTier.autoRegister()` / `autoRegisterWithin()` — the no-consent registration +// path every entrypoint now calls at startup (2026-09-17: OpenCode Zen started rejecting keyless +// traffic outright, so installs without a model of their own need Altimate Base registered before +// the first provider list/default-model resolution, with no disclosure dialog in the way). +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" +import { FakeGateway, GATEWAY_URL } from "./_fixtures/fake-gateway" + +/** + * Poll until `predicate` holds, instead of a fixed sleep — the background registration this + * covers writes credentials to disk asynchronously, so a fixed delay is flaky under CI load (see + * the "returns at the budget" test below). + */ +async function waitFor(read: () => Promise, predicate: (v: T) => boolean, timeoutMs = 5000): Promise { + const start = Date.now() + for (;;) { + const v = await read() + if (predicate(v)) return v + if (Date.now() - start > timeoutMs) throw new Error("waitFor: condition not met within timeout") + await new Promise((r) => setTimeout(r, 10)) + } +} + +// Harness contract: isolate the Altimate Base home BEFORE importing src/altimate/free/*. +isolateAltimateBaseHome("altimate-base-auto-register") + +const { FreeTier } = await import("../../src/altimate/free/client") +const { FreeTierStore } = await import("../../src/altimate/free/store") + +const gateway = new FakeGateway() +const GATEWAY_ENV = ["ALTIMATE_BASE_GATEWAY_URL", "ALTIMATE_FREE_GATEWAY_URL"] as const +const AUTO_REGISTER_ENV = "ALTIMATE_BASE_AUTO_REGISTER" +let savedGatewayEnv: Record = {} +let savedAutoRegisterEnv: string | undefined + +beforeEach(async () => { + savedGatewayEnv = Object.fromEntries(GATEWAY_ENV.map((key) => [key, process.env[key]])) + savedAutoRegisterEnv = process.env[AUTO_REGISTER_ENV] + delete process.env[AUTO_REGISTER_ENV] + gateway.install() + gateway.reset() + await FreeTier.logout() + await FreeTierStore.remove() + resetGatewayEnv(GATEWAY_URL) +}) + +afterEach(() => { + gateway.restore() + for (const key of GATEWAY_ENV) { + if (savedGatewayEnv[key] === undefined) delete process.env[key] + else process.env[key] = savedGatewayEnv[key] + } + if (savedAutoRegisterEnv === undefined) delete process.env[AUTO_REGISTER_ENV] + else process.env[AUTO_REGISTER_ENV] = savedAutoRegisterEnv +}) + +describe("FreeTier.autoRegister", () => { + test("registers without a consent token and persists credentials", async () => { + gateway.registerNext({ kind: "ok" }) + const result = await FreeTier.autoRegister() + expect(result).toEqual({ status: "registered" }) + expect(gateway.registerCalls).toHaveLength(1) + const stored = await FreeTierStore.read() + expect(stored?.apiKey).toBeDefined() + expect(await FreeTier.isRegistered()).toBe(true) + }) + + test("is deduped across concurrent calls: only one network request for N concurrent autoRegister()s", async () => { + gateway.registerNext({ kind: "ok" }) + const results = await Promise.all([ + FreeTier.autoRegister(), + FreeTier.autoRegister(), + FreeTier.autoRegister(), + ]) + for (const result of results) expect(result).toEqual({ status: "registered" }) + expect(gateway.registerCalls).toHaveLength(1) + }) + + test.each(["0", "false"])("is skipped when ALTIMATE_BASE_AUTO_REGISTER=%s, with no network call", async (value) => { + process.env[AUTO_REGISTER_ENV] = value + const result = await FreeTier.autoRegister() + expect(result).toEqual({ status: "skipped", reason: "env" }) + expect(gateway.registerCalls).toHaveLength(0) + }) + + test("is skipped when the user explicitly logged out, with no network call", async () => { + // logout() persists a logoutNonce with no apiKey — the exact state autoRegister must never + // register through, whether it's already there (this test) or lands mid-flight (see the next + // test): both are the same fresh, inside-the-lock read, so there's no separate stale check to + // race in the first place. + await FreeTier.logout() + const result = await FreeTier.autoRegister() + expect(result).toEqual({ status: "skipped", reason: "logged-out" }) + expect(gateway.registerCalls).toHaveLength(0) + }) + + test("a logout that lands before the registration lock is acquired is not missed", async () => { + // Simulates the race the spec calls out: nothing has registered yet (no pre-existing + // credential), and a logout call — which takes the SAME lock — completes before autoRegister's + // own lock body runs. Because that body reads the store fresh from inside the lock (no + // pre-lock "expected" value carried in), it sees the logout unconditionally. + await FreeTierStore.remove() + await FreeTier.logout() + const result = await FreeTier.autoRegister() + expect(result).toEqual({ status: "skipped", reason: "logged-out" }) + expect(gateway.registerCalls).toHaveLength(0) + }) + + test("is skipped when no gateway URL is configured, with no network call", async () => { + delete process.env.ALTIMATE_BASE_GATEWAY_URL + delete process.env.ALTIMATE_FREE_GATEWAY_URL + const result = await FreeTier.autoRegister() + expect(result).toEqual({ status: "skipped", reason: "no-gateway" }) + expect(gateway.registerCalls).toHaveLength(0) + }) + + test("is skipped when valid credentials already exist for this gateway, with no network call", async () => { + gateway.registerNext({ kind: "ok" }) + const first = await FreeTier.autoRegister() + expect(first).toEqual({ status: "registered" }) + expect(gateway.registerCalls).toHaveLength(1) + + const second = await FreeTier.autoRegister() + expect(second).toEqual({ status: "skipped", reason: "already-registered" }) + // Still exactly one call: the second autoRegister() never touched the network. + expect(gateway.registerCalls).toHaveLength(1) + }) + + test("never throws on a network failure — resolves to a failed result instead", async () => { + gateway.registerNext({ kind: "network" }) + const result = await FreeTier.autoRegister() + expect(result).toEqual({ status: "failed", kind: "network" }) + expect(await FreeTier.isRegistered()).toBe(false) + }) + + test("never throws on an HTTP rejection — resolves to a failed result instead", async () => { + gateway.registerNext({ kind: "http", status: 503 }) + const result = await FreeTier.autoRegister() + expect(result).toEqual({ status: "failed", kind: "http" }) + expect(await FreeTier.isRegistered()).toBe(false) + }) + + test("never throws on a malformed gateway response — resolves to a failed result instead", async () => { + gateway.registerNext({ kind: "malformed-json" }) + const result = await FreeTier.autoRegister() + expect(result).toEqual({ status: "failed", kind: "response" }) + expect(await FreeTier.isRegistered()).toBe(false) + }) +}) + +describe("FreeTier.autoRegisterWithin", () => { + test("returns once registered, well within a generous budget", async () => { + gateway.registerNext({ kind: "ok" }) + const result = await FreeTier.autoRegisterWithin(3000) + expect(result).toEqual({ status: "registered" }) + }) + + test("returns at the budget while a slow registration keeps going in the background", async () => { + // Bypass FakeGateway here: it has no "hang" mode for /register. This fetch resolves the + // request successfully, but only after a delay well past the tiny budget below. + gateway.restore() + let resolveRequest!: () => void + const gate = new Promise((resolve) => { + resolveRequest = resolve + }) + const slow = spyOn(globalThis, "fetch").mockImplementation((async ( + _input: RequestInfo | URL, + _init?: RequestInit, + ) => { + await gate + return new Response( + JSON.stringify({ + api_key: "sk-altimate-base-slow", + base_url: GATEWAY_URL, + model: FreeTier.MODEL_ID, + expires_at: new Date(Date.now() + 86_400_000).toISOString(), + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + }) as typeof fetch) + + try { + const startedAt = Date.now() + const result = await FreeTier.autoRegisterWithin(30) + expect(result).toEqual({ status: "pending" }) + // Returned at the budget, not after waiting for the request to resolve. + expect(Date.now() - startedAt).toBeLessThan(1000) + expect(await FreeTier.isRegistered()).toBe(false) + + // Let the still-running attempt finish; its credentials land on disk even though this + // launch already moved on. Poll for it rather than a fixed sleep — how long the write + // takes to land is not deterministic under load. + resolveRequest() + const registered = await waitFor(() => FreeTier.isRegistered(), (v) => v === true) + expect(registered).toBe(true) + } finally { + slow.mockRestore() + } + }) +}) diff --git a/packages/opencode/test/altimate/altimate-base-catalog.test.ts b/packages/opencode/test/altimate/altimate-base-catalog.test.ts index ef3999916a..e920fa2eac 100644 --- a/packages/opencode/test/altimate/altimate-base-catalog.test.ts +++ b/packages/opencode/test/altimate/altimate-base-catalog.test.ts @@ -173,15 +173,21 @@ describe("defaultModel() and sort() for Altimate Base", () => { }) }) - test("a project provider allowlist naming Altimate Base cannot activate it as the default, even when it is the only registered candidate", async () => { + // altimate_change — a `config.provider` block naming ONLY Altimate Base used to make this throw + // "no providers found": the mere presence of any `config.provider` entry excluded Base from its + // own last-resort fallback too. Base is now excluded only by a real + // enabled_providers/disabled_providers verdict (see the sibling test above for that case), so a + // registered Base remains reachable as the last resort here. + test("a config.provider block naming only Altimate Base still resolves to it as the last resort", async () => { await registerCredential() await using tmp = await tmpdir({ config: { provider: { [FreeTier.PROVIDER_ID]: {} } } }) await provideProviderTestInstance({ directory: tmp.path, fn: async () => { - const failure = await Provider.defaultModel().catch((error) => error) - expect(failure).toBeInstanceOf(Error) - expect(failure.message).toBe("no providers found") + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make(FreeTier.PROVIDER_ID), + modelID: ModelID.make(FreeTier.MODEL_ID), + }) }, }) }) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index be1c93a731..50d6df5f36 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -162,7 +162,13 @@ test("a generic auth-store key cannot activate the managed Altimate Base provide } }) -test("an Altimate Base-only provider block cannot select an unrelated provider", async () => { +// altimate_change — a `config.provider` block naming ONLY Altimate Base used to make +// `defaultModel()` throw "no providers found": `hasProviderAllowlist` excluded every OTHER +// provider (nothing else is in the block) AND excluded Base itself (the managed provider used to +// be excluded from its own allowlist entry). Since Base is now excluded only by a real +// enabled_providers/disabled_providers verdict — not by the mere presence of a `config.provider` +// entry — a registered Base is reachable as the last resort even here. +test("an Altimate Base-only provider block still resolves to registered Base as the last resort", async () => { const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ apiKey: "sk-altimate-base", baseURL: ALTIMATE_BASE_GATEWAY_URL, @@ -179,9 +185,10 @@ test("an Altimate Base-only provider block cannot select an unrelated provider", await provideProviderTestInstance({ directory: tmp.path, fn: async () => { - const failure = await Provider.defaultModel().catch((error) => error) - expect(failure).toBeInstanceOf(Error) - expect(failure.message).toBe("no providers found") + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make(FreeTier.PROVIDER_ID), + modelID: ModelID.make(FreeTier.MODEL_ID), + }) }, }) } finally { @@ -222,8 +229,13 @@ test.each([false, true])("a keyed Zen provider outranks registered Base with pub } }) +// altimate_change — `declinedManagedBaseDefault` no longer vetoes the Base default: OpenCode Zen +// rejects keyless traffic outright (2026-09-17), so there is no working public-Zen alternative +// left for a "declined" user to fall back to. Every value of the persisted flag — including +// `true` — now resolves to Base. The flag is still read (and still parsed here) purely for +// compatibility with existing `model.json` files. test.each([ - { flag: true, providerID: "opencode", modelID: "gpt-5-nano" }, + { flag: true, providerID: FreeTier.PROVIDER_ID, modelID: FreeTier.MODEL_ID }, { flag: false, providerID: FreeTier.PROVIDER_ID, modelID: FreeTier.MODEL_ID }, { flag: undefined, providerID: FreeTier.PROVIDER_ID, modelID: FreeTier.MODEL_ID }, { flag: "yes", providerID: FreeTier.PROVIDER_ID, modelID: FreeTier.MODEL_ID }, @@ -266,7 +278,11 @@ test.each([ } }) -test("a persisted public Zen recent outranks registered Altimate Base", async () => { +// altimate_change — a stale recent pick of the (now fully-broken) keyless public Zen tier is +// replaced by registered Base rather than replayed, since OpenCode Zen rejects that traffic +// outright (2026-09-17) and replaying it is guaranteed to fail. This used to be reversed +// (public Zen outranked Base) back when public Zen still worked and Base required consent. +test("registered Altimate Base replaces a stale persisted public Zen recent", async () => { const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ apiKey: "sk-altimate-base", baseURL: ALTIMATE_BASE_GATEWAY_URL, @@ -291,8 +307,8 @@ test("a persisted public Zen recent outranks registered Altimate Base", async () expect(providers.opencode.key).toBeUndefined() expect(providers[FreeTier.PROVIDER_ID]).toBeDefined() expect(await Provider.defaultModel()).toEqual({ - providerID: ProviderID.make("opencode"), - modelID: ModelID.make("nemotron-3-super-free"), + providerID: ProviderID.make(FreeTier.PROVIDER_ID), + modelID: ModelID.make(FreeTier.MODEL_ID), }) }, }) @@ -338,7 +354,15 @@ test.each(["__proto__/x", "constructor/x", "opencode/__proto__", "opencode/const }, ) -test("a persisted Big Pickle default is not silently migrated headlessly", async () => { +// altimate_change — Big Pickle is served through the same keyless `opencode` provider as every +// other public Zen model, so it is just as broken by OpenCode Zen's 2026-09-17 keyless rejection. +// This test used to assert the OPPOSITE ("not silently migrated headlessly") back when the TUI's +// disclosure-gated Big Pickle -> Base migration was the only path to Base and public Zen still +// worked. Now that Base auto-registers with no consent gate and public Zen never works, replaying +// a persisted Big Pickle recent is exactly the failure mode the stale-selection replacement +// exists to prevent — see the sibling "remains until Altimate Base consent exists" test below for +// the case where there is no working replacement to fall back to. +test("a persisted Big Pickle recent is replaced by registered Base", async () => { const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ apiKey: "sk-altimate-base", baseURL: ALTIMATE_BASE_GATEWAY_URL, @@ -354,11 +378,9 @@ test("a persisted Big Pickle default is not silently migrated headlessly", async await provideProviderTestInstance({ directory: tmp.path, fn: async () => { - // The TUI owns the migration because it owns the disclosure; rewriting the recent pick - // here would move a user who declined onto the request-logging tier with no prompt. expect(await Provider.defaultModel()).toEqual({ - providerID: ProviderID.make("opencode"), - modelID: ModelID.make("big-pickle"), + providerID: ProviderID.make(FreeTier.PROVIDER_ID), + modelID: ModelID.make(FreeTier.MODEL_ID), }) }, }) @@ -405,7 +427,12 @@ test("an explicitly configured Big Pickle model remains authoritative", async () }) }) -test("a provider allowlist filters a persisted Altimate Base recent before implicit selection", async () => { +// altimate_change — a `config.provider` block naming an UNRELATED provider (here, `anthropic`) +// used to filter out a persisted Base recent too, via `hasProviderAllowlist`. Base is now excluded +// only by a real enabled_providers/disabled_providers verdict, so the mere presence of an +// `anthropic` customization block is not a reason to skip an otherwise-valid Base recent — it wins +// immediately, same as it would for any other provider's recent. +test("a config.provider block for an unrelated provider does not filter a persisted Altimate Base recent", async () => { const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ apiKey: "sk-altimate-base", baseURL: ALTIMATE_BASE_GATEWAY_URL, @@ -426,8 +453,10 @@ test("a provider allowlist filters a persisted Altimate Base recent before impli init: async () => Env.set("ANTHROPIC_API_KEY", "test-api-key"), fn: async () => { const model = await Provider.defaultModel() - expect(String(model.providerID)).toBe("anthropic") - expect(String(model.modelID)).not.toBe(FreeTier.MODEL_ID) + expect(model).toEqual({ + providerID: ProviderID.make(FreeTier.PROVIDER_ID), + modelID: ModelID.make(FreeTier.MODEL_ID), + }) }, }) }) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index d5da655f82..48c6293e92 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -116,6 +116,21 @@ export function isFreeZenModel(model: ModelRef | undefined, providers: readonly return cost == null || cost === 0 } +// altimate_change start — the keyless public Zen tier, defined the same way +// `Provider.isPublicZen()` defines it server-side: the built-in `opencode` provider, auto-loaded +// with the `"public"` placeholder key, with no real key layered on top. Deliberately NOT the +// cost-based `isFreeZenModel` above: that marker answers "is this a free model at all" (used for +// the Big-Pickle migration offer), while `fallbackModel()`'s Base-vs-Zen ranking needs the exact +// same identity check `Provider.defaultModel()` uses, so the two can never resolve differently. +export function isPublicZenProvider(provider: { + id: string + options?: Record + key?: string +}): boolean { + return provider.id === "opencode" && provider.options?.["apiKey"] === "public" && !provider.key +} +// altimate_change end + export function shouldOfferManagedBaseDefault( current: ModelRef | undefined, explicit: boolean, @@ -580,14 +595,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - // altimate_change start — apply the same managed-provider policy `Provider.defaultModel()` - // enforces server-side: a project provider allowlist that excludes Altimate Base must not - // let this implicit TUI fallback reintroduce it either, whether through a persisted recent - // entry or the first-live-provider selection below. An explicit `--model`/config `model` + // altimate_change start — Base is excluded only by an actual enabled_providers/disabled_providers + // verdict, which `sync.data.provider` (server-built) already reflects. The mere presence of + // OTHER `config.provider` entries used to hide Base here too — matches the identical fix in + // `Provider.defaultModel()`/`defaultModelFromConfig`. An explicit `--model`/config `model` // above remains authoritative regardless, matching the server. - const managedBaseAllowed = allowsManagedBaseDefault(sync.data.config.provider) - const isManagedBaseModel = (model: ModelRef) => - model.providerID === ALTIMATE_BASE_MODEL.providerID && model.modelID === ALTIMATE_BASE_MODEL.modelID + const baseAvailable = sync.data.provider.some( + (candidate) => + candidate.id === ALTIMATE_BASE_MODEL.providerID && !!candidate.models[ALTIMATE_BASE_MODEL.modelID], + ) // altimate_change — round 6 review (cursor/cubic/kilo, all agreeing): a prior fix here // made `fallbackModel()` prefer a persisted `explicitDefault` over `recent`'s order, so @@ -601,24 +617,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // which now reorders `recent` instead (`{ explicit: true, recent: true }`) so `recent` // stays the single source of truth for TUI, `Provider.defaultModel()`, and ACP alike. - // A recent entry is the user's own past pick, so — matching `Provider.defaultModel()`'s - // comment on the same tradeoff — it stays honored for every provider except the - // consent-gated managed one; a narrowed project allowlist does not retroactively invalidate - // an otherwise-valid prior explicit choice. + // A recent entry is the user's own past pick, so it stays honored for every provider — + // including Base — except a stale pick of the now-broken keyless public Zen tier, which is + // replaced by registered Base rather than replayed (matches `Provider.defaultModel()`'s + // identical stale-selection handling). for (const item of modelStore.recent) { - if (isModelValid(item) && (managedBaseAllowed || !isManagedBaseModel(item))) { - return item + if (!isModelValid(item)) continue + if (baseAvailable) { + const provider = sync.data.provider.find((candidate) => candidate.id === item.providerID) + if (provider && isPublicZenProvider(provider)) continue } + return item } // Unlike `recent`, this is an IMPLICIT last-resort pick with no history behind it, so it // must honor the full allowlist — not just exclude Altimate Base — or it can land on a - // connected provider the project never named either. + // connected provider the project never named either. Base itself is exempt from that + // allowlist check (see the comment above `baseAvailable`). const configuredProviderIDs = Object.keys(sync.data.config.provider ?? {}) const providerAllowed = (id: string) => configuredProviderIDs.length === 0 || configuredProviderIDs.includes(id) const provider = sync.data.provider.find( - (candidate) => - providerAllowed(candidate.id) && (managedBaseAllowed || candidate.id !== ALTIMATE_BASE_MODEL.providerID), + (candidate) => candidate.id === ALTIMATE_BASE_MODEL.providerID || providerAllowed(candidate.id), ) // altimate_change end if (!provider) return undefined @@ -632,16 +651,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } }) + // altimate_change start — a per-agent pinned model (`modelStore.model[a.name]`) can itself + // be a stale keyless public-Zen pick; replace it with registered Base the same way + // `fallbackModel()`'s recents loop does, rather than replaying a model OpenCode Zen now + // rejects outright. A credentialed/paid selection is untouched. const currentModel = createMemo(() => { const a = agent.current() - return ( + const resolved = getFirstValidModel( () => a && modelStore.model[a.name], () => a && a.model, fallbackModel, ) ?? undefined - ) + if (resolved) { + const provider = sync.data.provider.find((candidate) => candidate.id === resolved.providerID) + if (provider && isPublicZenProvider(provider) && isModelValid(ALTIMATE_BASE_MODEL)) { + return { ...ALTIMATE_BASE_MODEL } + } + } + return resolved }) + // altimate_change end // altimate_change start — share validated selection with legacy-default and session migration function selectModel(model: ModelRef, options?: { recent?: boolean; explicit?: boolean }) { @@ -998,11 +1028,23 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // Migration is a decision about the DEFAULT model and is owned by the disclosure flow in // app.tsx; applying it here rewrote historical threads onto the request-logging tier with // no per-session prompt, and did so even for users who had explicitly declined. + // + // altimate_change start — that "verbatim" rule doesn't extend to the now fully-broken + // keyless public Zen tier: OpenCode Zen rejects that traffic outright, so restoring a + // session onto it guarantees every message in it fails. This is a broken-model repair, not + // the "declined the switch" default-migration decision the rule above protects, so it + // applies even to a declined user — there's no working alternative that respects a decline. restoreSession(model: ModelRef) { - if (!selectModel(model)) return undefined - return model + const provider = sync.data.provider.find((candidate) => candidate.id === model.providerID) + const resolved = + provider && isPublicZenProvider(provider) && isModelValid(ALTIMATE_BASE_MODEL) + ? { ...ALTIMATE_BASE_MODEL } + : model + if (!selectModel(resolved)) return undefined + return resolved }, // altimate_change end + // altimate_change end toggleFavorite(model: { providerID: string; modelID: string }) { batch(() => { if (!isModelValid(model)) { diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index ca128b6e88..fa18633b1c 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -17,6 +17,9 @@ import { // altimate_change start — fixes #1301: broaden legacy-default migration eligibility isFreeZenModel, shouldOfferManagedBaseDefault, + // altimate_change — the identity check `fallbackModel()`/`currentModel()`/`restoreSession()` use + // to replace a stale keyless public-Zen selection with registered Base + isPublicZenProvider, // altimate_change end // altimate_change start — fixes #1301 (Codex review, P2): usable-free-default predicate isUsableFreeDefault, @@ -518,3 +521,21 @@ test("isMigrationStillEligibleAfterCapture: only the launch-default-unchanged or expect(isMigrationStillEligibleAfterCapture(undefined, from, false, {})).toBe(false) }) // altimate_change end + +// altimate_change start — isPublicZenProvider: the identity check `fallbackModel()` / +// `currentModel()` / `restoreSession()` use to replace a stale keyless public-Zen selection with +// registered Base. Defined the same way `Provider.isPublicZen()` is defined server-side — the two +// must never disagree, or the TUI and headless/ACP default resolution could pick different models +// from the same `model.json`. +test("isPublicZenProvider: only the keyless built-in opencode provider counts", () => { + expect(isPublicZenProvider({ id: "opencode", options: { apiKey: "public" } })).toBe(true) + // A real key on the opencode provider (a keyed Zen account) is not public Zen. + expect(isPublicZenProvider({ id: "opencode", options: { apiKey: "public" }, key: "sk-real" })).toBe(false) + // Not the placeholder marker at all. + expect(isPublicZenProvider({ id: "opencode", options: {} })).toBe(false) + // Any other provider, even with the same options shape, is never public Zen. + expect(isPublicZenProvider({ id: "altimate-free", options: { apiKey: "public" } })).toBe(false) + // Missing `options` (some fixtures omit it) must not throw. + expect(isPublicZenProvider({ id: "opencode" })).toBe(false) +}) +// altimate_change end From aca8de6f0ba2e6265d294962ce7a61342984af27 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 22 Sep 2026 18:04:51 -0700 Subject: [PATCH 02/27] fix: clear OpenCode Zen block message and retry Altimate Base TPM throttling - `provider/error.ts`: map OpenCode Zen's "can only be used from within OpenCode" 403 (keyless free tier, blocked since 2026-09-17) to a clear, non-retryable message pointing users at Altimate Base or their own provider via `/models`. Never auto-switches the model. - `altimate/free/client.ts`'s `describeRateLimit`: a `throttling_error` with "Limit type: tokens" is now retryable with the same message shape as the generic burst limit, since the per-minute token budget was raised to 1.5M/min and now really means a burst of fast turns, not an oversized request. - `provider/error.ts`: cap the `Retry-After` header passed to `session/retry.ts`'s existing retry machinery at 60s so a large gateway-reported wait can't stall a session for minutes; the user-facing message still shows the real value. - Update `altimate-base-rate-limit-messages.test.ts`, `altimate-base-harness-smoke.test.ts`, and `release-v0.11.0-adversarial.test.ts` for the new TPM classification, and add coverage for the Zen-block mapping, TPM retry classification, and the 60s Retry-After cap in `test/provider/error.test.ts`. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/altimate/free/client.ts | 11 +-- packages/opencode/src/provider/error.ts | 34 ++++++- .../altimate-base-harness-smoke.test.ts | 7 +- .../altimate-base-rate-limit-messages.test.ts | 17 +++- packages/opencode/test/provider/error.test.ts | 96 +++++++++++++++++++ .../skill/release-v0.11.0-adversarial.test.ts | 11 +-- 6 files changed, 154 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 41bceb6e68..646e567755 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -703,13 +703,10 @@ export function describeRateLimit( const kind = typeof parsed?.error?.type === "string" ? parsed.error.type : parsed?.type const detail = typeof parsed?.error?.message === "string" ? parsed.error.message : "" if (kind === "throttling_error") { - if (/Limit type: tokens/.test(detail)) { - return { - message: - "This request is too large for Altimate Base's per-minute token limit. Start a new session or shorten the context, then try again.", - retryable: false, - } - } + // A per-minute token limit ("Limit type: tokens") and the generic burst limit are both + // transient — we raised the token budget to 1.5M/min, so hitting it now means a burst of + // fast turns, not an oversized request. Both are retryable with the same message shape; + // the caller (provider/error.ts) caps how long a single retry actually waits. const seconds = Number(input.retryAfter) const wait = Number.isFinite(seconds) && seconds > 0 ? ` Try again in ${Math.ceil(seconds)}s.` : " Try again shortly." return { message: `Too many requests to Altimate Base right now.${wait}`, retryable: true } diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index aeea86f1ff..3e06c38f44 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -275,6 +275,20 @@ export namespace ProviderError { } // altimate_change end + // altimate_change start — cap the Retry-After header session/retry.ts actually sleeps on at + // 60s. Altimate Base's per-minute token throttle is now retryable (see the 429 branch below), + // so a gateway-reported wait must never stall a session for minutes; the user-facing message + // still shows the real value, only the header driving the sleep is clamped. + const MAX_RETRY_AFTER_SECONDS = 60 + function capRetryAfterHeader(headers: Record | undefined): Record | undefined { + const retryAfter = headers?.["retry-after"] + if (!retryAfter) return headers + const seconds = Number(retryAfter) + if (!Number.isFinite(seconds) || seconds <= MAX_RETRY_AFTER_SECONDS) return headers + return { ...headers, "retry-after": String(MAX_RETRY_AFTER_SECONDS) } + } + // altimate_change end + // altimate_change start — sanitize metadata.url before it lands on the // parsed error. Two transforms are applied: // (1) basic-auth userinfo (`user:pass@…`) is stripped on every URL, @@ -329,6 +343,22 @@ export namespace ProviderError { // Check responseBody for context_length_exceeded code (e.g., OpenAI-style errors) const bodyParsed = json(input.error.responseBody) const codeFromBody = bodyParsed?.error?.code + // altimate_change start — OpenCode Zen's keyless free tier (provider "opencode", the + // `apiKey: "public"` fallback in provider.ts) stopped accepting our traffic on 2026-09-17 + // ("OpenCode's free tier can only be used from within OpenCode"). Without this, users saw + // that raw provider string. Point them at Altimate Base instead; never auto-switch here. + if (String(input.providerID) === "opencode" && /can only be used from within OpenCode/i.test(m)) { + return { + type: "api_error", + message: + "OpenCode's free models no longer work in Altimate Code. Switch to Altimate Base (free) with /models (or your editor's model picker), or connect your own provider.", + statusCode: input.error.statusCode, + isRetryable: false, + responseHeaders: input.error.responseHeaders, + metadata: input.error.url ? { url: maskInternalHost(input.error.url) } : undefined, + } + } + // altimate_change end // altimate_change start — distinguish the gateway byte cap from context overflow // The gateway's fixed request-byte cap is not a context overflow. Retrying compaction can // never help when system instructions and tool schemas alone exceed it. @@ -371,7 +401,9 @@ export namespace ProviderError { message: described.message, statusCode: 429, isRetryable: described.retryable, - responseHeaders: input.error.responseHeaders, + responseHeaders: described.retryable + ? capRetryAfterHeader(input.error.responseHeaders) + : input.error.responseHeaders, metadata: input.error.url ? { url: maskInternalHost(input.error.url) } : undefined, } } diff --git a/packages/opencode/test/altimate/altimate-base-harness-smoke.test.ts b/packages/opencode/test/altimate/altimate-base-harness-smoke.test.ts index 6a515f6ad2..702f886689 100644 --- a/packages/opencode/test/altimate/altimate-base-harness-smoke.test.ts +++ b/packages/opencode/test/altimate/altimate-base-harness-smoke.test.ts @@ -53,7 +53,7 @@ describe("Altimate Base harness smoke test", () => { expect(gateway.chatCalls[0]?.authorization).toBe("Bearer sk-altimate-base-fake") }) - test("failure knob: per-minute token rate-limit maps to a non-retryable message", async () => { + test("failure knob: per-minute token rate-limit maps to a retryable message", async () => { gateway.registerNext({ kind: "ok" }) await FreeTier.registerAfterConsent(consented()) @@ -67,9 +67,8 @@ describe("Altimate Base harness smoke test", () => { expect(response.status).toBe(429) const described = FreeTier.describeRateLimit({ body: await response.text() }) expect(described).toEqual({ - message: - "This request is too large for Altimate Base's per-minute token limit. Start a new session or shorten the context, then try again.", - retryable: false, + message: "Too many requests to Altimate Base right now. Try again shortly.", + retryable: true, }) }) }) diff --git a/packages/opencode/test/altimate/altimate-base-rate-limit-messages.test.ts b/packages/opencode/test/altimate/altimate-base-rate-limit-messages.test.ts index 18702eb07c..c4ce1b586e 100644 --- a/packages/opencode/test/altimate/altimate-base-rate-limit-messages.test.ts +++ b/packages/opencode/test/altimate/altimate-base-rate-limit-messages.test.ts @@ -56,7 +56,7 @@ async function chat(): Promise { } describe("describeRateLimit — via the fake gateway (every ChatMode failure knob)", () => { - test("throttle-tokens: per-minute token limit is non-retryable with the exact client.ts message", async () => { + test("throttle-tokens: per-minute token limit is retryable with the same shape as the burst case", async () => { gateway.chatNext({ kind: "throttle-tokens" }) const response = await chat() expect(response.status).toBe(429) @@ -66,9 +66,8 @@ describe("describeRateLimit — via the fake gateway (every ChatMode failure kno retryAfter: response.headers.get("retry-after") ?? undefined, }) expect(described).toEqual({ - message: - "This request is too large for Altimate Base's per-minute token limit. Start a new session or shorten the context, then try again.", - retryable: false, + message: "Too many requests to Altimate Base right now. Try again shortly.", + retryable: true, }) }) @@ -186,6 +185,16 @@ describe("describeRateLimit — pure-function edge cases FakeGateway's ChatMode retryable: true, }) }) + + test("throttle-tokens honors Retry-After the same way the generic burst case does", () => { + const body = JSON.stringify({ + error: { type: "throttling_error", message: "Limit type: tokens. Key=sk-fake. Current: 300000, Limit: 262144" }, + }) + expect(FreeTier.describeRateLimit({ body, retryAfter: "12" })).toEqual({ + message: "Too many requests to Altimate Base right now. Try again in 12s.", + retryable: true, + }) + }) }) describe("describeRequestTooLarge — via the fake gateway (413 request_too_large)", () => { diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts index 9a3ca0a143..062e9ee523 100644 --- a/packages/opencode/test/provider/error.test.ts +++ b/packages/opencode/test/provider/error.test.ts @@ -432,6 +432,102 @@ describe("ProviderError.parseAPICallError: Altimate Base isolation", () => { expect(result.message).not.toContain("Altimate Base") }) + test("a per-minute token throttle (Limit type: tokens) is retryable, same as the generic burst case", () => { + const result = ProviderError.parseAPICallError({ + providerID: "altimate-free" as any, + error: rateLimited("throttling_error", "Limit type: tokens. Current: 300000, Limit: 262144", { + "retry-after": "12", + }), + }) + expect(result.message).toBe("Too many requests to Altimate Base right now. Try again in 12s.") + if (result.type === "api_error") { + expect(result.isRetryable).toBe(true) + } + }) + + test("caps a large Retry-After header at 60s so a single retry can't stall a session for minutes", () => { + const result = ProviderError.parseAPICallError({ + providerID: "altimate-free" as any, + error: rateLimited("throttling_error", "Limit type: tokens", { "retry-after": "900" }), + }) + // The user-facing message still shows the real gateway value... + expect(result.message).toBe("Too many requests to Altimate Base right now. Try again in 900s.") + // ...but the header session/retry.ts actually sleeps on is clamped. + if (result.type === "api_error") { + expect(result.isRetryable).toBe(true) + expect(result.responseHeaders?.["retry-after"]).toBe("60") + } + }) + + test("does not cap a Retry-After header already under 60s", () => { + const result = ProviderError.parseAPICallError({ + providerID: "altimate-free" as any, + error: rateLimited("throttling_error", "", { "retry-after": "12" }), + }) + if (result.type === "api_error") { + expect(result.responseHeaders?.["retry-after"]).toBe("12") + } + }) + + test("does not cap the Retry-After header on a non-retryable Altimate Base 429 (budget_exceeded)", () => { + const result = ProviderError.parseAPICallError({ + providerID: "altimate-free" as any, + error: rateLimited("budget_exceeded", "Budget has been exceeded! Current cost: 50.01, Max budget: 50", { + "retry-after": "900", + }), + }) + if (result.type === "api_error") { + expect(result.isRetryable).toBe(false) + expect(result.responseHeaders?.["retry-after"]).toBe("900") + } + }) +}) + +describe("ProviderError.parseAPICallError: OpenCode Zen keyless free tier block", () => { + test("maps the 'can only be used from within OpenCode' rejection to a clear, non-retryable message", () => { + const result = ProviderError.parseAPICallError({ + providerID: "opencode" as any, + error: makeAPICallError({ + message: "Error from provider (Console): OpenCode's free tier can only be used from within OpenCode", + statusCode: 403, + }), + }) + expect(result.type).toBe("api_error") + expect(result.message).toBe( + "OpenCode's free models no longer work in Altimate Code. Switch to Altimate Base (free) with /models (or your editor's model picker), or connect your own provider.", + ) + if (result.type === "api_error") { + expect(result.isRetryable).toBe(false) + } + // Never claim anything was auto-switched. + expect(result.message).not.toMatch(/switched|now using/i) + }) + + test("does not rewrite an unrelated 403 from the same provider", () => { + const result = ProviderError.parseAPICallError({ + providerID: "opencode" as any, + error: makeAPICallError({ + message: "Forbidden: invalid API key", + statusCode: 403, + }), + }) + expect(result.message).toContain("Forbidden") + expect(result.message).not.toContain("Altimate Base") + }) + + test("does not rewrite the same error text for a different provider", () => { + const result = ProviderError.parseAPICallError({ + providerID: "openai" as any, + error: makeAPICallError({ + message: "OpenCode's free tier can only be used from within OpenCode", + statusCode: 403, + }), + }) + expect(result.message).not.toContain("Altimate Base") + }) +}) + +describe("ProviderError.parseAPICallError: Altimate Base request-too-large isolation", () => { const oversizedBody = JSON.stringify({ error: { message: "Request is 179608 bytes; the free tier limit is 128000 bytes.", diff --git a/packages/opencode/test/skill/release-v0.11.0-adversarial.test.ts b/packages/opencode/test/skill/release-v0.11.0-adversarial.test.ts index 9de7ec76e8..8c6c764ad1 100644 --- a/packages/opencode/test/skill/release-v0.11.0-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.11.0-adversarial.test.ts @@ -119,24 +119,23 @@ describe("describeRateLimit — adversarial retryAfter values (beyond the 45.7 / expect(described?.message).toBe("Too many requests to Altimate Base right now. Try again shortly.") }) - test("control characters and a null byte inside the throttle detail do not crash the token-limit substring check", () => { + test("control characters and a null byte inside the throttle detail do not crash parsing", () => { const body = JSON.stringify({ error: { type: "throttling_error", message: "Limit type: tokens\x00\x07, quota exceeded" }, }) const described = FreeTier.describeRateLimit({ body }) expect(described).toEqual({ - message: - "This request is too large for Altimate Base's per-minute token limit. Start a new session or shorten the context, then try again.", - retryable: false, + message: "Too many requests to Altimate Base right now. Try again shortly.", + retryable: true, }) }) - test("an extremely long detail string (10KB) is handled without throwing or truncation artifacts in the match", () => { + test("an extremely long detail string (10KB) is handled without throwing", () => { const padding = "x".repeat(10_000) const body = JSON.stringify({ error: { type: "throttling_error", message: `${padding} Limit type: tokens` } }) expect(() => FreeTier.describeRateLimit({ body })).not.toThrow() const described = FreeTier.describeRateLimit({ body }) - expect(described?.retryable).toBe(false) + expect(described?.retryable).toBe(true) }) test("budget detail containing HTML/script-like content is never echoed into the returned message", () => { From 6eb79ed20425141bce4df69bc22fa883565b5023 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 22 Sep 2026 19:45:40 -0700 Subject: [PATCH 03/27] refactor: remove the Altimate Base consent dialog and capability machinery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The product decision is fixed: no consent gate for Altimate Base. Replaces the blocking terminal dialog with a one-line non-blocking notice, shown once per install the first time Base becomes the active model. The disclosure wording itself (`ALTIMATE_BASE_DISCLOSURE`) is unchanged — the gateway still logs requests — but accepting it is no longer a precondition of registering. - `FreeTier.register({ origin })` replaces `registerAfterConsent(token)`: no token, still shares the lock/dedupe/`registerOnce` path with `autoRegister`. Unlike `autoRegister`, an explicit register proceeds even after a logout — the user asked for it. - Deleted `altimate/free/capability.ts` (the one-shot arm/redeem consent authority) and `altimate/free/host.ts` (the per-process registration-gate injection it existed to protect) — nothing needs either any more. - `altimate/free/consent.ts` keeps the `DISCLOSURE`/`HINT`/`disclosureHash()` re-exports the notice and route still use; `createRegistrationConsentGate` becomes `createRegistrationGate`, a plain outcome classifier with no token. - `server.ts`: `POST /altimate/base/register` calls `FreeTier.register({ origin: "server" })` directly and accepts (but ignores) `acceptedDisclosureSha256` for older clients. Both routes are now available on every server with a gateway configured, not just one that provisioned a gate — `serve.ts` no longer claims the armer or provides a gate at all. - `cli/tui/worker.ts`'s `registerAltimateBase` RPC drops `setAltimateBaseConsentToken` and the token param; `cli/cmd/tui.ts` no longer mints one. - TUI: deleted `DialogAltimateBaseConfirm`, `context/altimate-base-consent.tsx` (the dedicated pre-SDK-context registration operation), and the startup migration dialog trigger in `app.tsx`. Picking Altimate Base from any picker (the welcome screen, the provider dialog, the full model catalogue) now calls a shared `selectAltimateBase()` helper directly: register if needed → refresh provider state → validate → select, showing a toast on failure. The registration operation moves onto the public `sdk` context (`AltimateBaseRegisterFn`) since there's no more consent boundary to keep it out of; an attached TUI (no in-process worker) falls back to the HTTP route over the same transport everything else uses. - Telemetry: `altimate_base_registration`'s `origin` gains `"picker"` and `"server"` (`"consent"` stays in the union for historical data). The retired dialog's `altimate_base_confirm_shown` / `altimate_base_choice` / `altimate_base_register_result` events keep their schema entries but are no longer emitted by anything. - Tests: deleted `altimate-base-armer-callsites.test.ts`, `context/altimate-base-consent.test.tsx`, and `cli/tui/dialog-altimate-base.test.tsx` (all tested deleted machinery). Removed the token-forgery/capability-unforgeability tests from `altimate-base.test.ts` and the token-redemption test from the v0.11.1-adversarial suite; migrated every other `registerAfterConsent( consented())` call site to `FreeTier.register({ origin: "picker" })`. Rewrote `test/server/altimate-base-registration.test.ts` for the no-gate routes (register works with no hash; browser-Origin is still refused on an unsecured server). Third and final commit of this PR; part (a) added no-consent auto-registration and Base-over-keyless-Zen default selection, part (c) covers the Zen error message and rate-limit retry. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../opencode/src/altimate/free/capability.ts | 105 --- packages/opencode/src/altimate/free/client.ts | 91 +-- .../opencode/src/altimate/free/consent.ts | 40 +- packages/opencode/src/altimate/free/host.ts | 91 --- .../opencode/src/altimate/telemetry/index.ts | 11 +- packages/opencode/src/cli/cmd/serve.ts | 25 +- packages/opencode/src/cli/cmd/tui.ts | 12 +- packages/opencode/src/cli/tui/worker.ts | 24 +- packages/opencode/src/server/server.ts | 79 +-- .../_fixtures/altimate-base-harness.ts | 37 - .../altimate-base-armer-callsites.test.ts | 74 -- .../altimate/altimate-base-catalog.test.ts | 13 +- .../altimate-base-disclosure-claims.test.ts | 25 +- .../altimate-base-error-surfacing.test.ts | 10 +- .../altimate-base-harness-smoke.test.ts | 11 +- .../altimate-base-inference-e2e.test.ts | 10 +- .../altimate-base-rate-limit-messages.test.ts | 10 +- .../altimate-base-registration-gaps.test.ts | 44 +- ...timate-base-registration-telemetry.test.ts | 32 +- .../test/altimate/altimate-base.test.ts | 155 +---- .../server/altimate-base-registration.test.ts | 170 ++--- .../test/server/httpapi-provider.test.ts | 4 +- .../skill/release-v0.11.1-adversarial.test.ts | 30 +- packages/tui/src/app.tsx | 69 +- .../tui/src/component/altimate-onboarding.tsx | 487 +++----------- packages/tui/src/component/dialog-model.tsx | 16 +- .../tui/src/component/dialog-provider.tsx | 25 +- .../tui/src/context/altimate-base-consent.tsx | 43 -- packages/tui/src/context/sdk.tsx | 15 + .../cli/tui/dialog-altimate-base.test.tsx | 636 ------------------ .../context/altimate-base-consent.test.tsx | 83 --- 31 files changed, 380 insertions(+), 2097 deletions(-) delete mode 100644 packages/opencode/src/altimate/free/capability.ts delete mode 100644 packages/opencode/src/altimate/free/host.ts delete mode 100644 packages/opencode/test/altimate/altimate-base-armer-callsites.test.ts delete mode 100644 packages/tui/src/context/altimate-base-consent.tsx delete mode 100644 packages/tui/test/cli/tui/dialog-altimate-base.test.tsx delete mode 100644 packages/tui/test/context/altimate-base-consent.test.tsx diff --git a/packages/opencode/src/altimate/free/capability.ts b/packages/opencode/src/altimate/free/capability.ts deleted file mode 100644 index e692c1abf8..0000000000 --- a/packages/opencode/src/altimate/free/capability.ts +++ /dev/null @@ -1,105 +0,0 @@ -const TOKEN_PATTERN = /^[0-9a-f]{64}$/ -const DEFAULT_TTL_MS = 30_000 -const DEFAULT_MAX_PENDING = 16 - -/** - * Worker-local, short-lived capabilities proving that a disclosure action was accepted. - * Multiple dialogs may overlap, so consuming or rejecting one token must not invalidate another. - * - * This lives in its own leaf module so the registration client can depend on it without a cycle: - * registration checks a token against this module's private authority before touching the - * network, which makes consent a property of the operation rather than of its call sites. - * - * The class stays exported so its arm/consume/TTL/bounding mechanics are directly unit - * testable in isolation — but that export is inert for security purposes: `registerAfterConsent` - * never accepts a caller-supplied instance, so a store you construct yourself only ever validates - * against itself. The one instance that matters (`productionAuthority` below) is never exported; - * the only way to influence it is `issueArmer()`/`issueRedeemer()`, each of which can be claimed - * exactly once per process. See those functions for the actual unforgeability guarantee. - */ -export class ConsentCapabilityStore { - private readonly pending = new Map() - private readonly ttlMs: number - private readonly maxPending: number - private readonly now: () => number - - constructor(input: { ttlMs?: number; maxPending?: number; now?: () => number } = {}) { - this.ttlMs = Math.max(1, input.ttlMs ?? DEFAULT_TTL_MS) - this.maxPending = Math.max(1, input.maxPending ?? DEFAULT_MAX_PENDING) - this.now = input.now ?? Date.now - } - - private cleanup(now: number): void { - for (const [token, expiresAt] of this.pending) { - if (expiresAt <= now) this.pending.delete(token) - } - } - - arm(token: string): void { - if (!TOKEN_PATTERN.test(token)) throw new Error("Invalid Altimate Base consent capability") - const now = this.now() - this.cleanup(now) - this.pending.delete(token) - while (this.pending.size >= this.maxPending) { - const oldest = this.pending.keys().next().value - if (!oldest) break - this.pending.delete(oldest) - } - this.pending.set(token, now + this.ttlMs) - } - - consume(token: string): boolean { - if (!TOKEN_PATTERN.test(token)) return false - const now = this.now() - this.cleanup(now) - if (!this.pending.has(token)) return false - this.pending.delete(token) - return true - } -} - -// The process's ONE production consent authority. Never exported — the only way to reach it is -// through `issueArmer`/`issueRedeemer` below, each claimable exactly once. -const productionAuthority = new ConsentCapabilityStore() -let armerIssued = false -let redeemerIssued = false - -/** - * Hands out the ability to arm Altimate Base's production consent authority. Callable exactly - * once per process: a second call throws. - * - * THIS DOCSTRING IS THE CANONICAL DESCRIPTION of who may claim it. `client.ts` and the entrypoints - * point here rather than restating it — the claim was previously paraphrased in three files and went - * stale in two of them when a second entrypoint was added. - * - * Legitimate callers, one per process, each owning a surface that shows a disclosure: - * - `cli/tui/worker.ts` — the terminal consent dialog - * - `cli/cmd/serve.ts` — the consent-gated HTTP routes the VS Code extension drives - * `test/altimate/altimate-base-armer-callsites.test.ts` asserts that list against the source, so - * adding a claimer fails there and forces this comment to be revisited. - * - * Because this is the only way to arm the authority that - * `registerAfterConsent` checks against, no other in-process code — however it constructs its - * own `ConsentCapabilityStore` or calls this function again — can mint a token that will ever be - * accepted; a self-armed store only ever validates against itself. - */ -export function issueArmer(): (token: string) => void { - if (armerIssued) throw new Error("Altimate Base consent armer already issued for this process") - armerIssued = true - return (token) => productionAuthority.arm(token) -} - -/** - * Hands out the ability to redeem (consume) a token against the production consent authority. - * Callable exactly once per process — `registerAfterConsent` claims it at module load, so - * registration can verify consent without ever accepting a capability object a caller could - * substitute. Pairs with `issueArmer`: only a token armed through that function's closure can - * ever redeem here, because both close over the same private `productionAuthority`. - */ -export function issueRedeemer(): (token: string) => boolean { - if (redeemerIssued) throw new Error("Altimate Base consent redeemer already issued for this process") - redeemerIssued = true - return (token) => productionAuthority.consume(token) -} - -export * as FreeTierCapability from "./capability" diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 646e567755..b5e3961d72 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -1,6 +1,5 @@ import { createHash, randomBytes } from "node:crypto" import { Flock } from "@opencode-ai/core/util/flock" -import { FreeTierCapability } from "./capability" import { Installation } from "../../installation" import { Log } from "../util/log" import { FreeTierStore } from "./store" @@ -29,10 +28,6 @@ const REJECTED_CREDENTIAL_LIMIT = 32 // the whole free tier offline until every user re-ran the disclosure flow. const REJECTED_PERSIST_THRESHOLD = 2 const unauthorizedCounts = new Map() -// Claimed once, at module load: this module is the ONE place that may redeem an Altimate Base -// consent token. `issueRedeemer` throws on a second call, so no other in-process code can obtain -// an equivalent redeemer bound to the same production authority — see capability.ts. -const redeemConsent = FreeTierCapability.issueRedeemer() export interface Credentials { apiKey: string @@ -116,8 +111,8 @@ function expired(value: Credentials): boolean { export async function credentialsForLoad(): Promise { const stored = await credentials() if (!stored || stored.baseURL !== gatewayUrl()) return undefined - // Provider discovery must remain read-only. Refreshing here would mint credentials without the - // current launch's explicit TUI disclosure/consent operation. + // Provider discovery must remain read-only. Refreshing here would mint credentials outside an + // explicit `register()` call (autoRegister at startup, or the picker/route). if (stored.rejected || expired(stored)) return undefined return stored } @@ -345,45 +340,31 @@ async function registerOnce( } /** - * Register only after redeeming a one-shot consent token. + * Register explicitly — the picker's "use Altimate Base" selection, and the HTTP route a host + * with its own UI (the VS Code extension) calls. No consent token: the product no longer gates + * registration behind a disclosure dialog (the wording still exists, served by + * `FreeTierConsent.DISCLOSURE` / `GET /altimate/base/disclosure`, but accepting it is no longer a + * precondition of minting a credential). * - * The token is checked here, before any network or storage effect, against the private consent - * authority this module claimed at load time (`redeemConsent`, see capability.ts) — so - * "registration requires an accepted disclosure" is enforced by this function itself rather than - * by the discipline of its callers. A caller cannot forge a token by constructing their own - * `ConsentCapabilityStore`: that class's `arm`/`consume` only ever validate against the instance - * you built, and the ONE instance this function actually checks is never exported — the only way - * to arm it is `FreeTierCapability.issueArmer()`, which is claimable exactly once per process. See - * that function's docstring for which entrypoints may claim it — deliberately not repeated here. + * Unlike `autoRegister`, this never treats "the user logged out" as a reason to skip: an explicit + * register is the user asking to reconnect, so it proceeds and effectively clears the logout (the + * resulting credential write carries a real `apiKey`, which is what `autoRegister`'s logout check + * actually keys on). * - * So importing this function is not enough to register: a caller must obtain a token minted by - * whichever gate claimed the armer in its process. Provider discovery and inference never call it. - * - * What this guarantees: the token is authentic. What it does not: that a human read anything. Over - * HTTP that remains an assertion by the caller, narrowed only by the disclosure-hash check in - * `FreeTierHost.registerWithAcceptedDisclosure` — and that hash is derived from public text, so it - * proves the caller holds the current wording, not that anyone read it. + * Shares `LOCK_KEY`, the `inflight` dedupe map, and `registerOnce` with `autoRegister`, so the two + * can never both hit the network for the same gateway at once. */ -export async function registerAfterConsent( - token: string, - input: { signal?: AbortSignal } = {}, +export async function register( + input: { origin: "picker" | "server"; signal?: AbortSignal } = { origin: "picker" }, ): Promise { - // altimate_change start — first-run health: every outcome is a registration outcome, including an - // expired consent token and a misconfigured gateway URL const startedAt = performance.now() - if (!redeemConsent(token)) { - const expired = new RegistrationError("Altimate Base consent expired. Reopen setup and try again.", "cancelled") - reportRegistration("cancelled", startedAt, expired, "consent") - throw expired - } let configuredGateway: string try { configuredGateway = gatewayUrl() } catch (error) { - reportRegistration(registrationResult(error), startedAt, error, "consent") + reportRegistration(registrationResult(error), startedAt, error, input.origin) throw error } - // altimate_change end const dedupeKey = configuredGateway const pending = inflight.get(dedupeKey) if (pending) return pending @@ -394,7 +375,7 @@ export async function registerAfterConsent( expectedLogoutNonce = (await FreeTierStore.read())?.logoutNonce } catch (error) { if (!(error instanceof FreeTierStore.InvalidCredentialStoreError)) throw error - // The existing explicit-consent repair path below owns malformed records. + // The repair path below owns malformed records. } return Flock.withLock(LOCK_KEY, async () => { @@ -405,10 +386,10 @@ export async function registerAfterConsent( fresh = credentialsFromStored(stored) } catch (error) { if (!(error instanceof FreeTierStore.InvalidCredentialStoreError)) throw error - // This path is reachable only after explicit disclosure acceptance. Repairing here keeps a - // truncated credential file from permanently bricking setup without silently erasing it - // during provider discovery. - log.warn("removing invalid Altimate Base credential record after explicit consent", { error }) + // This path is reachable only from an explicit, user-initiated register. Repairing here + // keeps a truncated credential file from permanently bricking setup without silently + // erasing it during provider discovery. + log.warn("removing invalid Altimate Base credential record before explicit registration", { error }) await FreeTierStore.remove() } if ( @@ -420,7 +401,7 @@ export async function registerAfterConsent( ) return fresh if (fresh && (fresh.rejected || credentialWasRejected(fresh))) { - log.info("rotating a rejected Altimate Base credential after explicit consent") + log.info("rotating a rejected Altimate Base credential") } return registerOnce(configuredGateway, expectedLogoutNonce, input.signal) }) @@ -432,8 +413,8 @@ export async function registerAfterConsent( // dedupe bookkeeping above, are untouched; the rejection handler keeps the branch from surfacing // as an unhandled rejection. started.then( - () => reportRegistration("success", startedAt, undefined, "consent"), - (error: unknown) => reportRegistration(registrationResult(error), startedAt, error, "consent"), + () => reportRegistration("success", startedAt, undefined, input.origin), + (error: unknown) => reportRegistration(registrationResult(error), startedAt, error, input.origin), ) // altimate_change end return started @@ -454,7 +435,9 @@ function reportRegistration( result: RegistrationResult, startedAt: number, error?: unknown, - origin?: "auto" | "consent", + // "consent" is retired (the disclosure dialog that emitted it is gone) but stays in the union so + // historical events still type. + origin?: "auto" | "consent" | "picker" | "server", ) { const status = error instanceof RegistrationError ? error.status : undefined const event: Telemetry.Event = { @@ -468,8 +451,8 @@ function reportRegistration( } if (origin === "auto") { // autoRegister runs at process boot, before any prompt has initialised telemetry, and can run - // entirely outside an Instance context. Calling Telemetry.init() from here (as the consent - // path does below) would treat config as enabled regardless of a `telemetry.disabled` + // entirely outside an Instance context. Calling Telemetry.init() from here (as the explicit + // register path does below) would treat config as enabled regardless of a `telemetry.disabled` // opt-out. track() buffers the event until a real init() elsewhere enables it. Telemetry.track(event) return @@ -531,10 +514,10 @@ async function autoRegisterLocked(configuredGateway: string, signal: AbortSignal } /** - * Register with the Altimate Base gateway without a consent token — the no-consent-gate default - * every entrypoint now calls at startup. Shares LOCK_KEY, the `inflight` dedupe map, and - * `registerOnce` with `registerAfterConsent`, so an auto-register and an explicit (consent) - * registration racing for the same gateway can never both hit the network. + * Register at startup, automatically — no consent gate, no user action. Every entrypoint calls + * this before provider state is first built. Shares LOCK_KEY, the `inflight` dedupe map, and + * `registerOnce` with `register()` (the explicit, picker/route-triggered path), so an auto-register + * and an explicit registration racing for the same gateway can never both hit the network. * * Never throws: every failure mode resolves to a `{ status: "skipped" | "failed" }` result. */ @@ -648,8 +631,8 @@ export async function authorizedFetch(input: RequestInfo | URL, init?: RequestIn const active = initial const response = await send(active)! // A success cannot prove that a concurrent 401 was stale: the key may have - // been revoked after this request was authorized. Only explicit consent and - // registration rotate/clear rejected credentials, keeping the ordinary + // been revoked after this request was authorized. Only autoRegister and an + // explicit register() rotate/clear rejected credentials, keeping the ordinary // inference path lock-free after its initial credential read. // // It does, however, prove the credential is not dead right now, so the consecutive-401 counter @@ -664,8 +647,8 @@ export async function authorizedFetch(input: RequestInfo | URL, init?: RequestIn await markCredentialRejected(active) if (!isReplayable(input, init?.body)) return response - // Another consented process may have rotated the key while this request was in flight. Reuse - // that already-persisted credential once, but never POST /register from the inference path. + // Another registration may have rotated the key while this request was in flight. Reuse that + // already-persisted credential once, but never POST /register from the inference path. const next = await credentialsForLoad().catch((error) => { log.warn("failed to read a rotated Altimate Base credential", { error }) return undefined diff --git a/packages/opencode/src/altimate/free/consent.ts b/packages/opencode/src/altimate/free/consent.ts index 721fbf86fe..faea865727 100644 --- a/packages/opencode/src/altimate/free/consent.ts +++ b/packages/opencode/src/altimate/free/consent.ts @@ -4,12 +4,14 @@ import { FreeTier } from "./client" import { FreeTierStore } from "./store" /** - * The text a user consents against before any Base credential is minted, plus the picker hint, - * served to hosts that render their own disclosure (the VS Code extension's chat panel, via - * GET /altimate/base/disclosure). + * The gateway still logs requests, so this notice text stays even though registering no longer + * requires accepting it first. Shown once per install (the TUI's one-line notice, the first time + * Base becomes the active model) and served to hosts that render their own copy — the VS Code + * extension's chat panel, via GET /altimate/base/disclosure. The picker hint is the short form + * used in model lists. * * Both are defined once in `@opencode-ai/core/altimate-base-disclosure` and re-exported here, so - * the TUI dialog and this route can never drift apart. + * the TUI notice and this route can never drift apart. */ export { ALTIMATE_BASE_DISCLOSURE as DISCLOSURE, @@ -17,13 +19,9 @@ export { } from "@opencode-ai/core/altimate-base-disclosure" /** - * SHA-256 of the canonical disclosure, hex-encoded. - * - * `POST /altimate/base/register` requires the caller to echo this back. This is a **text-version - * agreement, not proof of consent**: it establishes that the caller holds the current disclosure, - * so a client still rendering superseded wording cannot register people against text they were - * never shown. It does NOT establish that a human read anything — any caller can GET the disclosure - * and echo the hash. Whether a person actually saw the text remains an assertion by the caller. + * SHA-256 of the canonical disclosure, hex-encoded. Served by `GET /altimate/base/disclosure` for + * compatibility with older clients that still echo it back on `POST /altimate/base/register` — + * that route now accepts and ignores it, since registering no longer requires it. * * Not a secret (it is derived from public text), so a plain comparison is fine. */ @@ -39,20 +37,20 @@ export type RegistrationResult = message: string } -export function createRegistrationConsentGate(input: { - /** Arms the one-shot proof `register` will later be asked to redeem. */ - arm: (token: string) => void - /** Receives the bare token; must itself verify + consume proof of accepted disclosure. */ - register: (token: string) => Promise +/** + * Wraps a registration call (`FreeTier.register()`, from the picker or the HTTP route) and + * classifies its outcome into the `{ok, result, message}` shape both callers return to their UI. + * No consent token: registration itself is unconditional now, this only turns whatever it throws + * into something displayable. + */ +export function createRegistrationGate(input: { + register: () => Promise onUnexpectedError?: (error: unknown) => void }) { return { - setToken(value: { token: string }): void { - input.arm(value.token) - }, - async register(value: { token: string }): Promise { + async register(): Promise { try { - await input.register(value.token) + await input.register() return { ok: true } } catch (error) { if (error instanceof FreeTier.RegistrationError && error.kind === "cancelled") { diff --git a/packages/opencode/src/altimate/free/host.ts b/packages/opencode/src/altimate/free/host.ts deleted file mode 100644 index 1799d423fd..0000000000 --- a/packages/opencode/src/altimate/free/host.ts +++ /dev/null @@ -1,91 +0,0 @@ -// altimate_change start — host-injected Altimate Base registration for non-TUI entrypoints. -// -// `FreeTierCapability.issueArmer()` is claimable exactly once per process and throws on a second -// call, so an HTTP route cannot claim one for itself: the TUI worker already claims it at boot for -// its own RPC gate, and that worker also serves HTTP from the same process. A route-level claim -// would therefore break the TUI worker the moment the routes module loaded — and any test that -// imported both the server and the Base test harness. -// -// Instead the entrypoint that owns the process claims the capability once and hands the resulting -// gate here. This is the same shape the TUI already uses for the same operation -// (`packages/tui/src/context/altimate-base-consent.tsx`): the host injects, the consumer checks. -// -// `altimate serve` provides a gate. The TUI worker deliberately does NOT — the TUI owns its own -// disclosure dialog, and a second registration surface inside that process would let a caller -// register without the dialog ever being shown. Consumers must treat "cannot register" as final -// and refuse, exactly as the TUI's provider picker does. -// -// The gate itself is NEVER handed back out. An earlier revision exposed `current()`, which returned -// the whole gate — including `setToken` (closing over the real armer) and `register` (redeeming -// against the real authority) — so any importer held a raw mint primitive and could register -// without going near the disclosure, in any order it liked. The check now happens *inside* this -// module, in the same call that mints, arms and redeems: there is no ordering for a caller to get -// wrong and no primitive to borrow. -// -// What this is NOT: a trust boundary against in-process code. `registerWithAcceptedDisclosure` is -// exported, and the hash it demands is a SHA-256 of public text that any caller can recompute via -// `FreeTierConsent.disclosureHash()`. In-process code can therefore still cause a registration — -// it simply cannot do so while bypassing the documented precondition, and there is now one -// audited path instead of a capability handed to every importer. The real boundary is the process: -// anything running here is already trusted to execute tools. What this closes is accidental -// misuse and the drift that comes from re-implementing the check at each call site. -import { randomBytes } from "node:crypto" -import type { createRegistrationConsentGate, RegistrationResult } from "./consent" -import { FreeTierConsent } from "./consent" - -export type Registration = ReturnType - -let registration: Registration | undefined - -/** - * Install the process's registration gate. Called once by the entrypoint, before the server starts - * accepting requests. - * - * Single-shot, matching every other capability in this area: a second call throws rather than - * silently replacing the gate. The earlier last-write-wins behaviour let any in-process caller swap - * the gate out from under the routes after `serve` installed the real one. That was never a - * privilege escalation — such code is already trusted and still cannot forge a token the private - * authority accepts — but it was a weaker invariant than `issueArmer()`/`issueRedeemer()` next door, - * for no benefit. - */ -export function provide(value: Registration): void { - if (registration) throw new Error("Altimate Base registration gate already provided for this process") - registration = value -} - -/** Whether this host can register Altimate Base at all, i.e. whether an entrypoint provided a gate. */ -export function canRegister(): boolean { - return registration !== undefined -} - -export type RegisterOutcome = - /** No gate was provided; this host cannot register Altimate Base. */ - | { kind: "unavailable" } - /** The caller echoed a hash that is not the current disclosure's. */ - | { kind: "staleDisclosure" } - /** The gate ran; `result` carries its success or its classified failure. */ - | { kind: "done"; result: RegistrationResult } - -/** - * Verify the caller accepted the current disclosure text, then mint, arm and redeem in one step. - * - * The hash comparison lives here rather than in the caller so that holding the current disclosure - * text is a precondition of minting, not a convention the caller is trusted to follow. It is a - * **text-version agreement, not proof of consent**: it establishes that the caller holds the - * current wording, so a client still rendering superseded text cannot register people against text - * they were never shown. Any caller can fetch the disclosure and echo the hash, so "a human read - * this" remains an assertion by the caller. - */ -export async function registerWithAcceptedDisclosure(acceptedDisclosureSha256: string): Promise { - const gate = registration - if (!gate) return { kind: "unavailable" } - if (acceptedDisclosureSha256.toLowerCase() !== FreeTierConsent.disclosureHash()) { - return { kind: "staleDisclosure" } - } - const token = randomBytes(32).toString("hex") - gate.setToken({ token }) - return { kind: "done", result: await gate.register({ token }) } -} - -export * as FreeTierHost from "./host" -// altimate_change end diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 6c4c002e7a..6f09e3de16 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -689,10 +689,13 @@ export namespace Telemetry { duration_ms: number /** HTTP status when result is "http". */ status?: number - /** How this attempt was triggered: the no-consent auto-register path every entrypoint now - * calls at startup, or the explicit TUI/serve disclosure flow. Optional so events emitted - * before this field existed still validate. */ - origin?: "auto" | "consent" + /** How this attempt was triggered: `auto` is the startup path every entrypoint calls before + * provider state builds; `picker` is an explicit TUI provider-picker selection; `server` + * is the HTTP route (`POST /altimate/base/register`) a host with its own UI calls. + * `consent` is retired (the disclosure dialog that emitted it is gone) but stays in the + * union so historical events still type. Optional so events emitted before this field + * existed still validate. */ + origin?: "auto" | "consent" | "picker" | "server" } // altimate_change end // altimate_change start — telemetry for skill management operations diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 90638f41fa..6ccb048117 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -8,20 +8,12 @@ import { subscribeTraceConsumer } from "../../altimate/observability/trace-consu // altimate_change start — self-update on headless serve startup import { scheduleStartupUpgradeCheck } from "./serve-upgrade-check" // altimate_change end -// altimate_change start — Altimate Base registration capability for the headless server +// altimate_change start — Altimate Base auto-registration for the headless server import { FreeTier } from "../../altimate/free/client" -import { FreeTierCapability } from "../../altimate/free/capability" -import { FreeTierConsent } from "../../altimate/free/consent" -import { FreeTierHost } from "../../altimate/free/host" -import { Log } from "../../util/log" // altimate_change — first-run health: startup_ready once the server is listening import { Telemetry } from "../../altimate/telemetry" // altimate_change end -// altimate_change start — logger for the Base registration gate's onUnexpectedError hook -const log = Log.create({ service: "serve" }) -// altimate_change end - export const ServeCommand = effectCmd({ command: "serve", builder: (yargs) => withNetworkOptions(yargs), @@ -38,21 +30,6 @@ export const ServeCommand = effectCmd({ // because it must be readable from every module realm. process.env["ALTIMATE_CODE_SERVE"] = "1" // altimate_change end - // altimate_change start — claim the process's one Altimate Base consent capability here, at the - // entrypoint, before the server can accept a request. `serve` is the extension's host and has no - // TUI to show the disclosure dialog, so the disclosure + registration routes are how a Base - // credential gets minted in this process. Claiming it here (rather than in the routes module) - // keeps the TUI worker — which claims the same capability for its own dialog — unaffected. - yield* Effect.sync(() => - FreeTierHost.provide( - FreeTierConsent.createRegistrationConsentGate({ - arm: FreeTierCapability.issueArmer(), - register: (token) => FreeTier.registerAfterConsent(token), - onUnexpectedError: (error) => log.error("Altimate Base registration failed", { error }), - }), - ), - ) - // altimate_change end const { Server } = yield* Effect.promise(() => import("../../server/server")) if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 05305f85df..b578882847 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -1,8 +1,6 @@ import { cmd } from "@/cli/cmd/cmd" import { Rpc } from "@/util/rpc" import { type rpc } from "../tui/worker" -// altimate_change — mint a short-lived capability for each accepted Base registration attempt -import { randomBytes } from "node:crypto" import path from "path" import { fileURLToPath } from "url" import { UI } from "@/cli/ui" @@ -261,13 +259,9 @@ export const TuiThreadCommand = cmd({ config, pluginHost: createLegacyTuiPluginHost(), // Keep Base registration on the private worker RPC even when the TUI itself is - // connected to an externally bound HTTP server. The token is minted only when the - // accepted disclosure invokes this host operation, then consumed once in the worker. - altimateBaseRegistration: async () => { - const token = randomBytes(32).toString("hex") - await client.call("setAltimateBaseConsentToken", { token }) - return client.call("registerAltimateBase", { token }) - }, + // connected to an externally bound HTTP server — the worker's copy of the FreeTier + // module is the one that actually serves this process's providers. + registerAltimateBase: async () => client.call("registerAltimateBase", undefined), // altimate_change — onboarding funnel seam. Deliberately a single-line marker, not a // start/end pair: this sits inside the "clean up TUI worker after failed --session // validation" region, and a nested closing marker truncates the block that diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 8ae1c48873..7643167816 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -45,7 +45,6 @@ import { syncDatamateUrlFromVscodeMcp } from "@/altimate/datamate-transport" // altimate_change start — register Altimate Base only across the private parent/worker RPC boundary import { FreeTier } from "@/altimate/free/client" import { FreeTierConsent } from "@/altimate/free/consent" -import { FreeTierCapability } from "@/altimate/free/capability" // altimate_change end // altimate_change — shared with the withTimeout budget in cli/cmd/tui.ts stop(), so the coupling @@ -106,24 +105,21 @@ GlobalBus.on("event", (event) => { }) let server: Awaited> | undefined -// altimate_change start — worker-local, expiring capabilities gate every registration mutation. -// `issueArmer()` can succeed exactly once per process; this is this process's claim — see -// capability.ts for the canonical list of entrypoints that may claim it, and for why that makes the -// resulting token unforgeable by any other in-process code. -const altimateBaseRegistration = FreeTierConsent.createRegistrationConsentGate({ - arm: FreeTierCapability.issueArmer(), - register: (token) => FreeTier.registerAfterConsent(token), +// altimate_change start — explicit (no-consent-gate) Base registration, driven from the picker +// over the private parent/worker RPC boundary. The worker's copy of the FreeTier module is the +// one that actually serves this process's providers, so registering here (rather than routing +// through the HTTP transport) keeps the credential write and the next Provider.list() in the same +// thread's module state. +const altimateBaseRegistration = FreeTierConsent.createRegistrationGate({ + register: () => FreeTier.register({ origin: "picker" }), onUnexpectedError: (error) => console.error("[altimate-base] registration failed", error), }) // altimate_change end export const rpc = { - // altimate_change start — install and consume a private capability only after disclosure acceptance - setAltimateBaseConsentToken(input: { token: string }) { - altimateBaseRegistration.setToken(input) - }, - async registerAltimateBase(input: { token: string }) { - return altimateBaseRegistration.register(input) + // altimate_change start — no consent token: an explicit picker selection always registers if needed + async registerAltimateBase() { + return altimateBaseRegistration.register() }, // altimate_change end async fetch(input: { url: string; method: string; headers: Record; body?: string }) { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index cd4f1a8738..afc8dcd23d 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -38,15 +38,13 @@ import { syncDatamateUrlFromVscodeMcp, collectDatamateHealPaths } from "../altim import { managedWorkspaceLoaded } from "../altimate/workspace/engine-overlay" import { readMcpEntryFromDisk } from "../mcp/config" import { enhancePrompt, isAutoEnhanceEnabled } from "../altimate/enhance-prompt" -// altimate_change - Altimate Base disclosure + consent-gated registration for HTTP hosts +// altimate_change - Altimate Base disclosure + registration for HTTP hosts import { FreeTier } from "../altimate/free/client" import { FreeTierConsent } from "../altimate/free/consent" // altimate_change start — Altimate Base registration must invalidate BOTH instance registries. import { InstanceStore } from "@/project/instance-store" import { AppRuntime } from "@/effect/app-runtime" // altimate_change end -import { FreeTierHost } from "../altimate/free/host" -// altimate_change end import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" import { ExperimentalRoutes } from "./routes/experimental" @@ -678,30 +676,20 @@ export namespace Server { }, ) // altimate_change end - // altimate_change start — Altimate Base disclosure + consent-gated registration - // Registration mints a persistent per-installation identifier and opts the user into request - // logging, so `FreeTier.registerAfterConsent` only acts on a token armed through the process's - // single private consent authority. The TUI arms that token inside its disclosure dialog's - // accept handler; these routes are the equivalent for a host that renders its own disclosure - // (the VS Code extension's chat panel). - // - // A host that injected no gate (the TUI worker, which owns its own dialog) serves 501 rather - // than a second registration surface that could bypass that dialog. - // - // The register route additionally requires the caller to echo the disclosure's SHA-256. That - // is a text-version check, not consent enforcement — see the comment at the comparison below. - // - // GET is deliberately READ-ONLY. An earlier revision armed the consent token here, which - // meant the store's 30s TTL began when the disclosure was fetched rather than when the user - // accepted it — so anyone who actually read the text before consenting was rejected. That - // also made GET non-idempotent and let a burst of fetches evict pending tokens. The token is - // now minted, armed and redeemed entirely inside POST, in one operation. + // altimate_change start — Altimate Base disclosure + registration + // Registration is no longer gated behind an accepted disclosure — the product decision is no + // consent gate — so these routes are available on every server that has a gateway configured + // (serve, ACP, run --attach, web), not just one an entrypoint specially provisioned. The + // disclosure text/hash stay: the gateway still logs requests, so `GET .../disclosure` remains + // useful for a host that renders its own notice (the VS Code extension's chat panel), and + // `POST .../register` still accepts (and ignores) an echoed hash for compatibility with older + // clients that still send one. .get( "/altimate/base/disclosure", describeRoute({ - summary: "Get the Altimate Base consent disclosure", + summary: "Get the Altimate Base disclosure", description: - "Returns the text a user must accept before Altimate Base is registered, the picker hint, whether this installation is already registered, and the SHA-256 the client must echo back to POST /altimate/base/register. Read-only.", + "Returns the notice text shown once per install (the gateway still logs requests), the picker hint, whether this installation is already registered, and the disclosure's SHA-256 (kept for older clients; POST /altimate/base/register no longer requires it). Read-only.", operationId: "altimateBase.disclosure", responses: { 200: { @@ -719,16 +707,9 @@ export namespace Server { }, }, }, - 501: { - description: "This host cannot register Altimate Base", - content: { "application/json": { schema: resolver(z.object({ error: z.string() })) } }, - }, }, }), async (c) => { - if (!FreeTierHost.canRegister()) { - return c.json({ error: "This host cannot register Altimate Base." }, 501) - } const registered = await FreeTier.isRegistered().catch((error) => { log.warn("failed to read Altimate Base registration state", { error }) return false @@ -744,9 +725,9 @@ export namespace Server { .post( "/altimate/base/register", describeRoute({ - summary: "Register Altimate Base after consent", + summary: "Register Altimate Base", description: - "Mints the managed Altimate Base credential. The caller must echo the SHA-256 of the disclosure it displayed, which is verified against the canonical text, so a caller that never fetched the current disclosure cannot register. On success this disposes EVERY cached instance in the process — both registries — so provider loaders re-read the new credential. That is deliberately process-wide because the credential is a single global file, and it is disruptive: instance-scoped state elsewhere on this server (sessions, LSPs, PTYs, MCP connections, file watchers) is torn down and re-created, and `server.instance.disposed` is emitted for each. `staleProviders: true` in the response means the credential was written but at least one registry could not be invalidated, so provider lists may still show Altimate Base as disconnected.", + "Mints the managed Altimate Base credential. `acceptedDisclosureSha256` is accepted for compatibility with older clients but ignored — registration no longer requires it. On success this disposes EVERY cached instance in the process — both registries — so provider loaders re-read the new credential. That is deliberately process-wide because the credential is a single global file, and it is disruptive: instance-scoped state elsewhere on this server (sessions, LSPs, PTYs, MCP connections, file watchers) is torn down and re-created, and `server.instance.disposed` is emitted for each. `staleProviders: true` in the response means the credential was written but at least one registry could not be invalidated, so provider lists may still show Altimate Base as disconnected.", operationId: "altimateBase.register", responses: { 200: { @@ -771,18 +752,10 @@ export namespace Server { description: "Refused: browser origin on an unsecured server", content: { "application/json": { schema: resolver(z.object({ error: z.string() })) } }, }, - 501: { - description: "This host cannot register Altimate Base", - content: { "application/json": { schema: resolver(z.object({ error: z.string() })) } }, - }, }, }), - validator("json", z.object({ acceptedDisclosureSha256: z.string() })), + validator("json", z.object({ acceptedDisclosureSha256: z.string().optional() })), async (c) => { - if (!FreeTierHost.canRegister()) { - return c.json({ error: "This host cannot register Altimate Base." }, 501) - } - // A browser on a CORS-allowed origin can reach this port without being a local process, // which is a different reachability class from "can already execute tools here". Native // clients (the extension host, curl) send no Origin, so refusing an Origin-bearing @@ -801,25 +774,11 @@ export namespace Server { ) } - const { acceptedDisclosureSha256 } = c.req.valid("json") - // Hash verification, mint, arm and redeem all happen inside `FreeTierHost`, so this route - // cannot mint without checking and no other module can borrow the mint primitive. See - // `altimate/free/host.ts` for why the gate is never handed back out. - const attempt = await FreeTierHost.registerWithAcceptedDisclosure(acceptedDisclosureSha256) - if (attempt.kind === "unavailable") { - return c.json({ error: "This host cannot register Altimate Base." }, 501) - } - if (attempt.kind === "staleDisclosure") { - return c.json( - { - ok: false as const, - result: "error" as const, - message: "The accepted disclosure is out of date. Reopen setup and try again.", - }, - 200, - ) - } - const outcome = attempt.result + const gate = FreeTierConsent.createRegistrationGate({ + register: () => FreeTier.register({ origin: "server" }), + onUnexpectedError: (error) => log.error("Altimate Base registration failed", { error }), + }) + const outcome = await gate.register() // The provider loader caches its credential read, so a freshly registered Base stays out // of `/provider`'s `connected` list until the instance cache is dropped. Doing it here diff --git a/packages/opencode/test/altimate/_fixtures/altimate-base-harness.ts b/packages/opencode/test/altimate/_fixtures/altimate-base-harness.ts index 45e704c770..45c339f2fd 100644 --- a/packages/opencode/test/altimate/_fixtures/altimate-base-harness.ts +++ b/packages/opencode/test/altimate/_fixtures/altimate-base-harness.ts @@ -4,12 +4,10 @@ // pattern so every new suite (and that file) imports one implementation instead of // re-copy-pasting it. See docs/internal/2026-09-04-altimate-base-e2e-harness-plan.md, // Deliverable 2, for the full design rationale. -import { randomBytes } from "node:crypto" import fs from "node:fs" import os from "node:os" import path from "node:path" import { afterAll } from "bun:test" -import { FreeTierCapability } from "../../../src/altimate/free/capability" const ISOLATED_ENV = [ "XDG_DATA_HOME", @@ -58,38 +56,3 @@ export function resetGatewayEnv(gatewayUrl: string): void { delete process.env.ALTIMATE_FREE_GATEWAY_URL process.env.ALTIMATE_BASE_GATEWAY_URL = gatewayUrl } - -// `FreeTierCapability.issueArmer()` hands out the process's ONE consent-arming capability and -// throws on a second call — see `src/altimate/free/capability.ts`. In production that single call -// happens once, at TUI worker boot (`cli/tui/worker.ts`). Every Altimate Base e2e suite plays the -// role of that TUI host and needs the same capability, but `bun test test/altimate/` loads multiple -// suite files into ONE worker process, so if each file called `issueArmer()` itself at module -// scope, the second (and every subsequent) file to load would crash with "Altimate Base consent -// armer already issued for this process" — reproducible even with just the two pre-existing files -// (`altimate-base.test.ts` and `altimate-base-harness-smoke.test.ts`). -// -// This module-level singleton is the fix: it claims `issueArmer()` lazily, the first time any -// suite asks for a token, and caches the returned armer closure here. Bun caches modules per -// process, so every suite file that imports `consented()` from this file — regardless of how many -// separate test files load it — shares this exact module instance and therefore this exact cache. -// `issueArmer()` is still claimed exactly once per process; this adds no way to reset, re-claim, or -// otherwise bypass that one-shot guarantee. It is purely a shared cache in front of the single -// legitimate call, so the underlying security property (only one in-process caller can ever obtain -// the ability to arm the production consent authority) is unchanged. -let cachedArmer: ((token: string) => void) | undefined - -function armer(): (token: string) => void { - if (!cachedArmer) cachedArmer = FreeTierCapability.issueArmer() - return cachedArmer -} - -/** - * Mints a fresh one-shot consent token and arms it against the production consent authority, - * via the shared, process-wide armer above. Every suite should call this instead of claiming - * `FreeTierCapability.issueArmer()` itself. - */ -export function consented(): string { - const token = randomBytes(32).toString("hex") - armer()(token) - return token -} diff --git a/packages/opencode/test/altimate/altimate-base-armer-callsites.test.ts b/packages/opencode/test/altimate/altimate-base-armer-callsites.test.ts deleted file mode 100644 index e4aa690022..0000000000 --- a/packages/opencode/test/altimate/altimate-base-armer-callsites.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, test } from "bun:test" -import fs from "node:fs" -import path from "node:path" - -// `FreeTierCapability.issueArmer()` is the one way to arm the consent authority that -// `registerAfterConsent` checks, so *which entrypoints claim it* is a security-relevant fact. It -// was described in prose in three files, and when a second entrypoint was added the prose in two of -// them silently became false — the same failure happened repeatedly across four review rounds. -// -// Prose cannot police itself, so this does. If someone adds a claimer, this fails and points at the -// canonical docstring that has to be updated with it. - -const SRC = path.join(import.meta.dir, "../../src") - -/** Entrypoints allowed to claim the armer, each owning a surface that shows a disclosure. */ -const EXPECTED_CLAIMERS = ["cli/cmd/serve.ts", "cli/tui/worker.ts"] - -/** - * Strips comments before matching, because several files legitimately *discuss* these functions in - * prose — `host.ts` explains the capability model in its header. A naive grep counted those as call - * sites, which is exactly the kind of false signal that makes a lint-style test worse than none. - */ -function code(file: string): string { - return fs - .readFileSync(file, "utf8") - .replace(/\/\*[\s\S]*?\*\//g, " ") - .replace(/(^|[^:])\/\/.*$/gm, "$1") -} - -function sourceFiles(dir: string): string[] { - return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - const full = path.join(dir, entry.name) - if (entry.isDirectory()) return sourceFiles(full) - return entry.isFile() && /\.tsx?$/.test(entry.name) ? [full] : [] - }) -} - -describe("Altimate Base consent armer call sites", () => { - test("only the documented entrypoints claim issueArmer()", () => { - const claimers = sourceFiles(SRC) - .filter((file) => { - // The declaration in capability.ts is not a call site. - if (file.endsWith(path.join("altimate", "free", "capability.ts"))) return false - return /\bissueArmer\s*\(/.test(code(file)) - }) - .map((file) => path.relative(SRC, file).split(path.sep).join("/")) - .sort() - - expect( - claimers, - "A file now claims the Base consent armer that the canonical docstring in " + - "src/altimate/free/capability.ts does not list. Add it there (and to EXPECTED_CLAIMERS here) " + - "only if it genuinely owns a surface that shows the disclosure first.", - ).toEqual([...EXPECTED_CLAIMERS].sort()) - }) - - test("capability.ts names exactly those entrypoints in its canonical docstring", () => { - // Keeps the prose and the enforced list from drifting apart in the other direction. - const capability = fs.readFileSync(path.join(SRC, "altimate/free/capability.ts"), "utf8") - for (const claimer of EXPECTED_CLAIMERS) { - expect(capability, `capability.ts does not mention ${claimer}`).toContain(claimer) - } - }) - - test("the redeemer stays claimed by registerAfterConsent alone", () => { - const redeemers = sourceFiles(SRC) - .filter((file) => { - if (file.endsWith(path.join("altimate", "free", "capability.ts"))) return false - return /\bissueRedeemer\s*\(/.test(code(file)) - }) - .map((file) => path.relative(SRC, file).split(path.sep).join("/")) - expect(redeemers).toEqual(["altimate/free/client.ts"]) - }) -}) diff --git a/packages/opencode/test/altimate/altimate-base-catalog.test.ts b/packages/opencode/test/altimate/altimate-base-catalog.test.ts index e920fa2eac..9c97370bb8 100644 --- a/packages/opencode/test/altimate/altimate-base-catalog.test.ts +++ b/packages/opencode/test/altimate/altimate-base-catalog.test.ts @@ -2,13 +2,13 @@ // catalog / provider-isolation layer for Altimate Base. This suite mostly exercises // `src/provider/provider.ts` directly (via `Provider.list()`/`Provider.all()`/`Provider.defaultModel()`/ // `Provider.sort()`), not the gateway's chat route — registration goes through the real -// `FreeTier.registerAfterConsent()` + `FakeGateway` `/register` route so every test starts from a -// credential that was actually minted through the production consent path, not a mocked +// `FreeTier.register()` + `FakeGateway` `/register` route so every test starts from a credential +// that was actually minted through the real registration path, not a mocked // `credentialsForLoad()` return value (that mocked style is what `test/provider/provider.test.ts` // already does for its own, broader defaultModel()/config-hostility coverage — this suite is the // complementary hermetic-harness version, scoped to Deliverable 1 Suite C). import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { consented, isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" +import { isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" import { FakeGateway, GATEWAY_URL } from "./_fixtures/fake-gateway" import { tmpdir } from "../fixture/fixture" @@ -22,11 +22,6 @@ const { ProviderID, ModelID } = await import("../../src/provider/schema") const { Instance } = await import("../../src/project/instance") const { ProjectID } = await import("../../src/project/schema") -// This file plays the role of the TUI host, exactly like `altimate-base.test.ts` and -// `altimate-base-harness-smoke.test.ts` do. Minting a consent token goes through the shared -// `consented()` helper in `_fixtures/altimate-base-harness.ts`, which claims the process's ONE -// arming capability lazily and caches it — see that file for why (running multiple suite files in -// one `bun test` worker process means only the first call to `issueArmer()` may succeed). // Mirrors `provideProviderTestInstance` in test/provider/provider.test.ts — puts `Provider.list()`/ // `Provider.defaultModel()` inside an isolated project Instance so their memoized `state()` is @@ -65,7 +60,7 @@ afterEach(() => { /** Registers a real credential through the production consent path against the fake gateway. */ async function registerCredential(): Promise { gateway.registerNext({ kind: "ok" }) - await FreeTier.registerAfterConsent(consented()) + await FreeTier.register({ origin: "picker" }) } describe("model catalog: altimate-free/altimate-base", () => { diff --git a/packages/opencode/test/altimate/altimate-base-disclosure-claims.test.ts b/packages/opencode/test/altimate/altimate-base-disclosure-claims.test.ts index 04745f3066..cc6c320a09 100644 --- a/packages/opencode/test/altimate/altimate-base-disclosure-claims.test.ts +++ b/packages/opencode/test/altimate/altimate-base-disclosure-claims.test.ts @@ -3,21 +3,20 @@ import fs from "node:fs" import path from "node:path" import { ALTIMATE_BASE_DISCLOSURE, ALTIMATE_BASE_HINT } from "@opencode-ai/core/altimate-base-disclosure" -// Requirement 5 gave the disclosure ONE definition, which removed drift between the TUI gate and the -// HTTP disclosure route. It did nothing for the other consistency axis: the gate versus the fuller -// "Data handling" note in docs/docs/configure/providers.md. That axis had no mechanism except a -// comment saying "keep in sync" — and comments saying that had already failed three times in this -// feature. This file is the mechanism. +// Requirement 5 gave the disclosure ONE definition, which removed drift between the TUI notice and +// the HTTP disclosure route. It did nothing for the other consistency axis: the notice versus the +// fuller "Data handling" note in docs/docs/configure/providers.md. That axis had no mechanism +// except a comment saying "keep in sync" — and comments saying that had already failed three times +// in this feature. This file is the mechanism. // -// The gate is deliberately a short summary, so it is NOT required to repeat everything the docs say -// (the per-installation identifier detail lives in docs only, per #1268). What it must never do is -// drop or weaken a *core* data term, because it is the only text a user reads before consenting. -// The terminal gate defaults to "No", so a stray Return declines rather than accepts — asserted in -// packages/tui/test/cli/tui/dialog-altimate-base.test.tsx. +// The notice is deliberately a short summary, so it is NOT required to repeat everything the docs +// say (the per-installation identifier detail lives in docs only, per #1268). What it must never do +// is drop or weaken a *core* data term, because registration no longer waits for it to be read — +// it's the one text a user is shown, not a gate they pass through. const DOCS = path.join(import.meta.dir, "../../../../docs/docs/configure/providers.md") -/** The claims the consent gate must carry, whatever the wording. */ +/** The claims the disclosure notice must carry, whatever the wording. */ const REQUIRED = [ { name: "logging", pattern: /logged/i }, { name: "used to train or improve models", pattern: /train|improve/i }, @@ -25,12 +24,12 @@ const REQUIRED = [ { name: "rate limiting", pattern: /rate.?limit/i }, ] -describe("Altimate Base consent gate", () => { +describe("Altimate Base disclosure notice", () => { test("carries every core data term", () => { for (const claim of REQUIRED) { expect( claim.pattern.test(ALTIMATE_BASE_DISCLOSURE), - `the consent gate no longer states: ${claim.name}`, + `the disclosure notice no longer states: ${claim.name}`, ).toBe(true) } }) diff --git a/packages/opencode/test/altimate/altimate-base-error-surfacing.test.ts b/packages/opencode/test/altimate/altimate-base-error-surfacing.test.ts index 8d0f97c37b..3f99264eb4 100644 --- a/packages/opencode/test/altimate/altimate-base-error-surfacing.test.ts +++ b/packages/opencode/test/altimate/altimate-base-error-surfacing.test.ts @@ -27,7 +27,7 @@ // confirms a chat-time 401 flows into `authorizedFetch`'s existing 401 branch instead of being // thrown, silently discarded, or retried in a loop. import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { consented, isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" +import { isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" import { FakeGateway, GATEWAY_URL } from "./_fixtures/fake-gateway" isolateAltimateBaseHome("altimate-base-errors") @@ -35,12 +35,6 @@ isolateAltimateBaseHome("altimate-base-errors") const { FreeTier } = await import("../../src/altimate/free/client") const { FreeTierStore } = await import("../../src/altimate/free/store") -// Plays the role of the TUI host, exactly like `altimate-base.test.ts` and -// `altimate-base-harness-smoke.test.ts`. Minting a consent token goes through the shared -// `consented()` helper in `_fixtures/altimate-base-harness.ts`, which claims the process's ONE -// arming capability lazily and caches it — see that file for why (running multiple suite files in -// one `bun test` worker process means only the first call to `issueArmer()` may succeed). - const gateway = new FakeGateway() function chatRequest(): [string, RequestInit] { @@ -63,7 +57,7 @@ beforeEach(async () => { // Every scenario below needs a live, registered credential before it can reach the inference // path at all — seed one the same way the harness smoke test does. gateway.registerNext({ kind: "ok" }) - await FreeTier.registerAfterConsent(consented()) + await FreeTier.register({ origin: "picker" }) }) afterEach(() => { diff --git a/packages/opencode/test/altimate/altimate-base-harness-smoke.test.ts b/packages/opencode/test/altimate/altimate-base-harness-smoke.test.ts index 702f886689..69e0e19eab 100644 --- a/packages/opencode/test/altimate/altimate-base-harness-smoke.test.ts +++ b/packages/opencode/test/altimate/altimate-base-harness-smoke.test.ts @@ -4,7 +4,7 @@ // of the six planned implementer suites — see // docs/internal/2026-09-04-altimate-base-e2e-harness-plan.md, Deliverable 3, for that partition. import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { consented, isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" +import { isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" import { FakeGateway, GATEWAY_URL } from "./_fixtures/fake-gateway" isolateAltimateBaseHome("altimate-base-harness-smoke") @@ -12,11 +12,6 @@ isolateAltimateBaseHome("altimate-base-harness-smoke") const { FreeTier } = await import("../../src/altimate/free/client") const { FreeTierStore } = await import("../../src/altimate/free/store") -// This file plays the role of the TUI host, exactly like `altimate-base.test.ts` does. Minting a -// consent token goes through the shared `consented()` helper in `_fixtures/altimate-base-harness.ts`, -// which claims the process's ONE arming capability lazily and caches it — see that file for why -// (running multiple suite files in one `bun test` worker process means only the first call to -// `issueArmer()` may succeed). const gateway = new FakeGateway() beforeEach(async () => { @@ -34,7 +29,7 @@ afterEach(() => { describe("Altimate Base harness smoke test", () => { test("happy path: register then authorizedFetch round-trips a chat completion", async () => { gateway.registerNext({ kind: "ok" }) - await FreeTier.registerAfterConsent(consented()) + await FreeTier.register({ origin: "picker" }) expect(gateway.registerCalls).toHaveLength(1) expect(gateway.registerCalls[0]?.installSecretHash).toMatch(/^[0-9a-f]{64}$/) @@ -55,7 +50,7 @@ describe("Altimate Base harness smoke test", () => { test("failure knob: per-minute token rate-limit maps to a retryable message", async () => { gateway.registerNext({ kind: "ok" }) - await FreeTier.registerAfterConsent(consented()) + await FreeTier.register({ origin: "picker" }) gateway.chatNext({ kind: "throttle-tokens" }) const response = await FreeTier.authorizedFetch(`${GATEWAY_URL}/v1/chat/completions`, { diff --git a/packages/opencode/test/altimate/altimate-base-inference-e2e.test.ts b/packages/opencode/test/altimate/altimate-base-inference-e2e.test.ts index 3ad51c1911..c386a870ec 100644 --- a/packages/opencode/test/altimate/altimate-base-inference-e2e.test.ts +++ b/packages/opencode/test/altimate/altimate-base-inference-e2e.test.ts @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import fs from "node:fs" import path from "node:path" -import { consented, isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" +import { isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" import { FakeGateway, GATEWAY_URL } from "./_fixtures/fake-gateway" import { tmpdir } from "../fixture/fixture" @@ -20,12 +20,6 @@ const { Provider } = await import("../../src/provider/provider") const { Instance } = await import("../../src/project/instance") const { ProjectID } = await import("../../src/project/schema") -// This file plays the role of the TUI host, exactly like `altimate-base.test.ts` and -// `altimate-base-harness-smoke.test.ts` do. Minting a consent token goes through the shared -// `consented()` helper in `_fixtures/altimate-base-harness.ts`, which claims the process's ONE -// arming capability lazily and caches it — see that file for why (running multiple suite files in -// one `bun test` worker process means only the first call to `issueArmer()` may succeed). - // A registered API key that could never be confused with `FreeTier.MANAGED_API_KEY_PLACEHOLDER` // ("altimate-base-managed") -- distinct enough that any accidental substring match is meaningful. const REAL_API_KEY = "sk-altimate-base-real-managed-secret-000111222" @@ -34,7 +28,7 @@ const gateway = new FakeGateway() async function registerWithGateway() { gateway.registerNext({ kind: "ok", apiKey: REAL_API_KEY }) - return FreeTier.registerAfterConsent(consented()) + return FreeTier.register({ origin: "picker" }) } // Mirrors `provideProviderTestInstance` from `test/provider/provider.test.ts` -- the established diff --git a/packages/opencode/test/altimate/altimate-base-rate-limit-messages.test.ts b/packages/opencode/test/altimate/altimate-base-rate-limit-messages.test.ts index c4ce1b586e..0df0a2ef5a 100644 --- a/packages/opencode/test/altimate/altimate-base-rate-limit-messages.test.ts +++ b/packages/opencode/test/altimate/altimate-base-rate-limit-messages.test.ts @@ -16,7 +16,7 @@ // just not shaped like anything a real gateway response would look like, so scripting them // through `FakeGateway` would mean inventing a knob nobody asked for. import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { consented, isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" +import { isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" import { FakeGateway, GATEWAY_URL } from "./_fixtures/fake-gateway" isolateAltimateBaseHome("altimate-base-ratelimit") @@ -24,12 +24,6 @@ isolateAltimateBaseHome("altimate-base-ratelimit") const { FreeTier } = await import("../../src/altimate/free/client") const { FreeTierStore } = await import("../../src/altimate/free/store") -// This file plays the TUI-host role exactly like `altimate-base.test.ts` and the harness smoke -// test do. Minting a consent token goes through the shared `consented()` helper in -// `_fixtures/altimate-base-harness.ts`, which claims the process's ONE arming capability lazily -// and caches it — see that file for why (running multiple suite files in one `bun test` worker -// process means only the first call to `issueArmer()` may succeed). - const gateway = new FakeGateway() beforeEach(async () => { @@ -39,7 +33,7 @@ beforeEach(async () => { await FreeTierStore.remove() resetGatewayEnv(GATEWAY_URL) gateway.registerNext({ kind: "ok" }) - await FreeTier.registerAfterConsent(consented()) + await FreeTier.register({ origin: "picker" }) }) afterEach(() => { diff --git a/packages/opencode/test/altimate/altimate-base-registration-gaps.test.ts b/packages/opencode/test/altimate/altimate-base-registration-gaps.test.ts index 16e37b0380..2a2236fda8 100644 --- a/packages/opencode/test/altimate/altimate-base-registration-gaps.test.ts +++ b/packages/opencode/test/altimate/altimate-base-registration-gaps.test.ts @@ -1,7 +1,7 @@ // Registration failure-mapping gaps for Altimate Base, using the shared FakeGateway harness. // -// `altimate-base.test.ts` already covers happy-path registration, consent enforcement, and the -// credential lifecycle (rotation, rejection, expiry) with a hand-rolled fetch mock. This file +// `altimate-base.test.ts` already covers happy-path registration and the credential lifecycle +// (rotation, rejection, expiry) with a hand-rolled fetch mock. This file // targets a narrower slice that suite does not exercise: how `registerOnce` in // `src/altimate/free/client.ts` maps HTTP 4xx/5xx register failures, network failures, and // malformed JSON register bodies onto `RegistrationError`, plus the exact request payload sent @@ -11,7 +11,7 @@ // See docs/internal/2026-09-04-altimate-base-e2e-harness-plan.md for the harness design. import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { createHash } from "node:crypto" -import { consented, isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" +import { isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" import { FakeGateway, GATEWAY_URL } from "./_fixtures/fake-gateway" isolateAltimateBaseHome("altimate-base-registration") @@ -19,10 +19,6 @@ isolateAltimateBaseHome("altimate-base-registration") const { FreeTier } = await import("../../src/altimate/free/client") const { FreeTierStore } = await import("../../src/altimate/free/store") -// Minting a consent token goes through the shared `consented()` helper in -// `_fixtures/altimate-base-harness.ts`, which claims the process's ONE arming capability lazily -// and caches it — see that file for why (running multiple suite files in one `bun test` worker -// process means only the first call to `issueArmer()` may succeed). const gateway = new FakeGateway() beforeEach(async () => { @@ -40,7 +36,7 @@ afterEach(() => { describe("registration failure mapping: HTTP status codes", () => { test("429 maps to a rate-limit-specific message and carries the status", async () => { gateway.registerNext({ kind: "http", status: 429 }) - const error = await FreeTier.registerAfterConsent(consented()).catch((cause) => cause) + const error = await FreeTier.register({ origin: "picker" }).catch((cause) => cause) expect(error).toBeInstanceOf(FreeTier.RegistrationError) expect(error.kind).toBe("http") @@ -50,7 +46,7 @@ describe("registration failure mapping: HTTP status codes", () => { test("503 maps to an unavailability-specific message and carries the status", async () => { gateway.registerNext({ kind: "http", status: 503 }) - const error = await FreeTier.registerAfterConsent(consented()).catch((cause) => cause) + const error = await FreeTier.register({ origin: "picker" }).catch((cause) => cause) expect(error).toBeInstanceOf(FreeTier.RegistrationError) expect(error.kind).toBe("http") @@ -60,7 +56,7 @@ describe("registration failure mapping: HTTP status codes", () => { test("an unrecognized 5xx falls back to a generic status-carrying message", async () => { gateway.registerNext({ kind: "http", status: 500 }) - const error = await FreeTier.registerAfterConsent(consented()).catch((cause) => cause) + const error = await FreeTier.register({ origin: "picker" }).catch((cause) => cause) expect(error).toBeInstanceOf(FreeTier.RegistrationError) expect(error.kind).toBe("http") @@ -70,7 +66,7 @@ describe("registration failure mapping: HTTP status codes", () => { test("a 4xx that is not specially handled (400) still maps generically, not as a network/response failure", async () => { gateway.registerNext({ kind: "http", status: 400 }) - const error = await FreeTier.registerAfterConsent(consented()).catch((cause) => cause) + const error = await FreeTier.register({ origin: "picker" }).catch((cause) => cause) expect(error).toBeInstanceOf(FreeTier.RegistrationError) expect(error.kind).toBe("http") @@ -80,7 +76,7 @@ describe("registration failure mapping: HTTP status codes", () => { test("an HTTP register failure never persists credentials or flips the registered state", async () => { gateway.registerNext({ kind: "http", status: 500 }) - await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.register({ origin: "picker" })).rejects.toBeInstanceOf(FreeTier.RegistrationError) expect(await FreeTier.isRegistered()).toBe(false) expect(await FreeTier.credentials()).toBeUndefined() @@ -97,7 +93,7 @@ describe("registration failure mapping: HTTP status codes", () => { describe("registration failure mapping: network failure", () => { test('a thrown fetch (connection failure) maps to kind "network" with a connectivity message', async () => { gateway.registerNext({ kind: "network" }) - const error = await FreeTier.registerAfterConsent(consented()).catch((cause) => cause) + const error = await FreeTier.register({ origin: "picker" }).catch((cause) => cause) expect(error).toBeInstanceOf(FreeTier.RegistrationError) expect(error.kind).toBe("network") @@ -110,7 +106,7 @@ describe("registration failure mapping: network failure", () => { describe("registration failure mapping: malformed register response", () => { test('a 200 with invalid JSON body maps to kind "response" instead of crashing', async () => { gateway.registerNext({ kind: "malformed-json" }) - const error = await FreeTier.registerAfterConsent(consented()).catch((cause) => cause) + const error = await FreeTier.register({ origin: "picker" }).catch((cause) => cause) expect(error).toBeInstanceOf(FreeTier.RegistrationError) expect(error.kind).toBe("response") @@ -124,7 +120,7 @@ describe("registration failure mapping: malformed register response", () => { describe("registration request payload", () => { test("sends only the SHA-256 hash of the minted install secret, never the secret itself", async () => { gateway.registerNext({ kind: "ok" }) - const result = await FreeTier.registerAfterConsent(consented()) + const result = await FreeTier.register({ origin: "picker" }) expect(gateway.registerCalls).toHaveLength(1) const call = gateway.registerCalls[0]! @@ -136,7 +132,7 @@ describe("registration request payload", () => { test("sends a sanitized cli_version derived from the running Installation.VERSION", async () => { const { Installation } = await import("../../src/installation") gateway.registerNext({ kind: "ok" }) - await FreeTier.registerAfterConsent(consented()) + await FreeTier.register({ origin: "picker" }) expect(gateway.registerCalls).toHaveLength(1) const sentVersion = gateway.registerCalls[0]!.cliVersion @@ -147,14 +143,14 @@ describe("registration request payload", () => { }) describe("registration retry / idempotency", () => { - test("a failed HTTP registration reuses the same minted install secret on the next consented attempt", async () => { + test("a failed HTTP registration reuses the same minted install secret on the next attempt", async () => { gateway.registerNext({ kind: "http", status: 500 }) - await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.register({ origin: "picker" })).rejects.toBeInstanceOf(FreeTier.RegistrationError) const firstHash = gateway.registerCalls[0]?.installSecretHash expect(firstHash).toMatch(/^[0-9a-f]{64}$/) gateway.registerNext({ kind: "ok" }) - const result = await FreeTier.registerAfterConsent(consented()) + const result = await FreeTier.register({ origin: "picker" }) expect(gateway.registerCalls).toHaveLength(2) expect(gateway.registerCalls[1]?.installSecretHash).toBe(firstHash) @@ -163,13 +159,13 @@ describe("registration retry / idempotency", () => { test("re-registering with a live credential is a no-op: the gateway is not called again", async () => { gateway.registerNext({ kind: "ok" }) - const first = await FreeTier.registerAfterConsent(consented()) + const first = await FreeTier.register({ origin: "picker" }) expect(gateway.registerCalls).toHaveLength(1) - // A second, independently-armed consent token still must not trigger another /register call, - // because registerAfterConsent finds the existing credential is live, unexpired, and not - // rejected before ever reaching registerOnce. - const second = await FreeTier.registerAfterConsent(consented()) + // A second, independent register() call still must not trigger another /register call, because + // it finds the existing credential is live, unexpired, and not rejected before ever reaching + // registerOnce. + const second = await FreeTier.register({ origin: "picker" }) expect(gateway.registerCalls).toHaveLength(1) expect(second).toEqual(first) }) diff --git a/packages/opencode/test/altimate/altimate-base-registration-telemetry.test.ts b/packages/opencode/test/altimate/altimate-base-registration-telemetry.test.ts index 6373d03c97..e4fe97d483 100644 --- a/packages/opencode/test/altimate/altimate-base-registration-telemetry.test.ts +++ b/packages/opencode/test/altimate/altimate-base-registration-telemetry.test.ts @@ -1,8 +1,8 @@ // altimate_change start — first-run health: every Altimate Base registration reports its outcome and -// wall time through `altimate_base_registration`, regardless of whether the TUI or the HTTP consent -// route triggered it. +// wall time through `altimate_base_registration`, regardless of whether autoRegister, the picker, or +// the HTTP route triggered it. import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" -import { consented, isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" +import { isolateAltimateBaseHome, resetGatewayEnv } from "./_fixtures/altimate-base-harness" import { FakeGateway, GATEWAY_URL } from "./_fixtures/fake-gateway" import { Telemetry } from "../../src/altimate/telemetry" @@ -57,19 +57,20 @@ afterEach(() => { }) describe("altimate_base_registration", () => { - test("a successful registration reports success with a duration", async () => { + test("a successful registration reports success with a duration and origin", async () => { gateway.registerNext({ kind: "ok" }) - await FreeTier.registerAfterConsent(consented()) + await FreeTier.register({ origin: "picker" }) const reports = await reported() expect(reports).toHaveLength(1) expect(reports[0].result).toBe("success") expect(reports[0].duration_ms).toBeGreaterThanOrEqual(0) expect(reports[0].status).toBeUndefined() + expect(reports[0].origin).toBe("picker") }) test("an HTTP rejection reports the status", async () => { gateway.registerNext({ kind: "http", status: 429 }) - await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.register({ origin: "picker" })).rejects.toBeInstanceOf(FreeTier.RegistrationError) const reports = await reported() expect(reports).toHaveLength(1) expect(reports[0].result).toBe("http") @@ -78,30 +79,29 @@ describe("altimate_base_registration", () => { test("a misconfigured gateway URL reports result configuration", async () => { process.env.ALTIMATE_BASE_GATEWAY_URL = "ftp://not-a-gateway" - await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf( - FreeTier.ConfigurationError, - ) + await expect(FreeTier.register({ origin: "picker" })).rejects.toBeInstanceOf(FreeTier.ConfigurationError) expect((await reported()).map((r) => r.result)).toEqual(["configuration"]) }) test("a network failure reports result network", async () => { gateway.registerNext({ kind: "network" }) - await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.register({ origin: "picker" })).rejects.toBeInstanceOf(FreeTier.RegistrationError) expect((await reported()).map((r) => r.result)).toEqual(["network"]) }) test("a caller abort before the request reports result cancelled", async () => { const controller = new AbortController() controller.abort() - await expect(FreeTier.registerAfterConsent(consented(), { signal: controller.signal })).rejects.toBeDefined() + await expect(FreeTier.register({ origin: "picker", signal: controller.signal })).rejects.toBeDefined() expect((await reported()).map((r) => r.result)).toEqual(["cancelled"]) }) - test("an expired consent token reports result cancelled", async () => { - await expect(FreeTier.registerAfterConsent("not-a-consent-token")).rejects.toBeInstanceOf( - FreeTier.RegistrationError, - ) - expect((await reported()).map((r) => r.result)).toEqual(["cancelled"]) + test("the server origin is reported for a route-triggered registration", async () => { + gateway.registerNext({ kind: "ok" }) + await FreeTier.register({ origin: "server" }) + const reports = await reported() + expect(reports).toHaveLength(1) + expect(reports[0].origin).toBe("server") }) }) // altimate_change end diff --git a/packages/opencode/test/altimate/altimate-base.test.ts b/packages/opencode/test/altimate/altimate-base.test.ts index ce7bf4c5f5..08b3679c66 100644 --- a/packages/opencode/test/altimate/altimate-base.test.ts +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -1,9 +1,8 @@ import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" -import { createHash, randomBytes } from "node:crypto" +import { createHash } from "node:crypto" import fs from "node:fs" import os from "node:os" import path from "node:path" -import { consented } from "./_fixtures/altimate-base-harness" const isolatedEnvironment = [ "XDG_DATA_HOME", @@ -22,8 +21,6 @@ process.env.OPENCODE_TEST_HOME = temporaryHome const { FreeTier } = await import("../../src/altimate/free/client") const { FreeTierStore } = await import("../../src/altimate/free/store") -const { FreeTierConsent } = await import("../../src/altimate/free/consent") -const { FreeTierCapability } = await import("../../src/altimate/free/capability") const { Flock } = await import("@opencode-ai/core/util/flock") const GATEWAY_URL = "https://gateway.test" @@ -74,15 +71,6 @@ afterAll(() => { fs.rmSync(temporaryHome, { recursive: true, force: true }) }) -// This test file plays the role of the TUI host: minting a consent token goes through the shared -// `consented()` helper in `_fixtures/altimate-base-harness.ts`, which claims `issueArmer()` — the -// process's ONE arming capability, exactly as `cli/tui/worker.ts` does at boot — lazily and caches -// it, so every suite file sharing this process gets the SAME armer instead of each one claiming it -// independently (which would throw on the second file). Every `FreeTier.registerAfterConsent` call -// in this file therefore goes through the SAME path production does; nothing here constructs a -// private, independent store that `registerAfterConsent` would actually trust (see "unforgeable -// consent" below for a direct test of that property). - describe("gateway configuration", () => { test("requires source-mode configuration and prefers the new override", () => { delete process.env.ALTIMATE_BASE_GATEWAY_URL @@ -120,7 +108,7 @@ describe("registration", () => { return json(REGISTERED) }) - const result = await FreeTier.registerAfterConsent(consented()) + const result = await FreeTier.register({ origin: "picker" }) const sentHash = String(requestBody?.install_secret_hash) expect(sentHash).toMatch(/^[0-9a-f]{64}$/) expect(sentHash).toBe(createHash("sha256").update(result.installSecret).digest("hex")) @@ -134,80 +122,27 @@ describe("registration", () => { expect(sharedAuthAfter).toEqual(sharedAuthBefore) }) - test("registration is impossible without an armed consent capability", async () => { - let gatewayCalls = 0 - mockFetch(() => { - gatewayCalls++ - return json(REGISTERED) - }) - const forged = randomBytes(32).toString("hex") - - // A token that was never armed through the legitimate path cannot register, and nothing - // reaches the network or the credential file. This is the property the whole consent design - // rests on. - await expect(FreeTier.registerAfterConsent(forged)).rejects.toBeInstanceOf(FreeTier.RegistrationError) - expect(gatewayCalls).toBe(0) - expect(await FreeTierStore.read()).toBeUndefined() - - const token = consented() - const result = await FreeTier.registerAfterConsent(token) - expect(result.apiKey).toBe(REGISTERED.api_key) - expect(gatewayCalls).toBe(1) - - // One-shot: the same token cannot register a second time. - await expect(FreeTier.registerAfterConsent(token)).rejects.toBeInstanceOf(FreeTier.RegistrationError) - expect(gatewayCalls).toBe(1) - }) - - test("unforgeable consent: no in-process caller can mint an independent authority", async () => { - // `consented()`'s module-scope setup above already claimed the process's ONE armer, exactly - // as the TUI worker does at boot; `client.ts` claims the matching ONE redeemer at import - // time. This test plays the attacker: it tries to obtain either capability a second time, - // and separately proves that a self-constructed, self-armed store is inert against the real - // registration function. Both are the properties `registerAfterConsent`'s unforgeability - // rests on. - expect(() => FreeTierCapability.issueArmer()).toThrow() - expect(() => FreeTierCapability.issueRedeemer()).toThrow() - - // Constructing your own store and arming it — exactly the exploit a caller-supplied capability - // used to allow — produces a token that only ever validates against ITSELF. The store happily - // reports it as consumed, but `registerAfterConsent` no longer accepts a capability argument at - // all, only a bare token checked against the private, one-shot-issued authority above, so this - // "successfully consumed" forged token still cannot register. - let gatewayCalls = 0 - mockFetch(() => { - gatewayCalls++ - return json(REGISTERED) - }) - const forgedStore = new FreeTierCapability.ConsentCapabilityStore() - const forgedToken = randomBytes(32).toString("hex") - forgedStore.arm(forgedToken) - expect(forgedStore.consume(forgedToken)).toBe(true) - await expect(FreeTier.registerAfterConsent(forgedToken)).rejects.toBeInstanceOf(FreeTier.RegistrationError) - expect(gatewayCalls).toBe(0) - }) - test("rejects a registration response that redirects credentials to another origin", async () => { mockFetch(() => json({ ...REGISTERED, base_url: "https://attacker.example.com" })) - await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.register({ origin: "picker" })).rejects.toBeInstanceOf(FreeTier.RegistrationError) expect(await FreeTier.isRegistered()).toBe(false) }) test("rejects a registration response that changes the configured gateway path", async () => { mockFetch(() => json({ ...REGISTERED, base_url: `${GATEWAY_URL}/unexpected-proxy` })) - await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.register({ origin: "picker" })).rejects.toBeInstanceOf(FreeTier.RegistrationError) expect(await FreeTier.isRegistered()).toBe(false) }) test("rejects a response for a different model", async () => { mockFetch(() => json({ ...REGISTERED, model: "another-model" })) - await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.register({ origin: "picker" })).rejects.toBeInstanceOf(FreeTier.RegistrationError) expect(await FreeTier.isRegistered()).toBe(false) }) test("rejects an already-expired credential response", async () => { mockFetch(() => json({ ...REGISTERED, expires_at: new Date(Date.now() - 1_000).toISOString() })) - await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.register({ origin: "picker" })).rejects.toBeInstanceOf(FreeTier.RegistrationError) expect(await FreeTier.isRegistered()).toBe(false) }) @@ -225,7 +160,7 @@ describe("registration", () => { return json(REGISTERED) }) - const result = await FreeTier.registerAfterConsent(consented()) + const result = await FreeTier.register({ origin: "picker" }) expect(result.apiKey).toBe(REGISTERED.api_key) expect(calls).toBe(0) }) @@ -236,7 +171,7 @@ describe("registration", () => { mockFetch(() => json(REGISTERED)) await expect(FreeTier.credentialsForLoad()).rejects.toBeInstanceOf(FreeTierStore.InvalidCredentialStoreError) - const result = await FreeTier.registerAfterConsent(consented()) + const result = await FreeTier.register({ origin: "picker" }) expect(result.apiKey).toBe(REGISTERED.api_key) expect(await FreeTier.credentials()).toEqual(result) }) @@ -247,7 +182,7 @@ describe("registration", () => { firstHash = String(JSON.parse(String(init?.body)).install_secret_hash) throw new Error("connection reset") }) - await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.register({ origin: "picker" })).rejects.toBeInstanceOf(FreeTier.RegistrationError) fetchSpy?.mockRestore() let secondHash = "" @@ -255,7 +190,7 @@ describe("registration", () => { secondHash = String(JSON.parse(String(init?.body)).install_secret_hash) return json(REGISTERED) }) - await FreeTier.registerAfterConsent(consented()) + await FreeTier.register({ origin: "picker" }) expect(secondHash).toBe(firstHash) }) @@ -263,13 +198,13 @@ describe("registration", () => { mockFetch(() => { throw new Error("connection reset") }) - const network = await FreeTier.registerAfterConsent(consented()).catch((error) => error) + const network = await FreeTier.register({ origin: "picker" }).catch((error) => error) expect(network).toBeInstanceOf(FreeTier.RegistrationError) expect(network.kind).toBe("network") fetchSpy?.mockRestore() mockFetch(() => json({ ...REGISTERED, api_key: "" })) - const response = await FreeTier.registerAfterConsent(consented()).catch((error) => error) + const response = await FreeTier.register({ origin: "picker" }).catch((error) => error) expect(response).toBeInstanceOf(FreeTier.RegistrationError) expect(response.kind).toBe("response") expect(response.status).toBeUndefined() @@ -296,7 +231,7 @@ describe("registration", () => { }) }) - const pending = FreeTier.registerAfterConsent(consented(), { signal: controller.signal }) + const pending = FreeTier.register({ origin: "picker", signal: controller.signal }) await requestStarted controller.abort() @@ -345,7 +280,7 @@ describe("registration", () => { baselineRead() return value }) - const pending = FreeTier.registerAfterConsent(consented()) + const pending = FreeTier.register({ origin: "picker" }) await baselineObserved readSpy.mockRestore() @@ -486,7 +421,7 @@ describe("inference boundary", () => { expect(authorizations).toEqual([`Bearer ${REGISTERED.api_key}`]) }) - test("retries once with a credential already rotated by another consented process", async () => { + test("retries once with a credential already rotated by another registration", async () => { await seed() const authorizations: (string | null)[] = [] mockFetch(async (_input, init) => { @@ -616,7 +551,7 @@ describe("inference boundary", () => { expect(await FreeTier.credentialsForLoad()).toBeUndefined() }) - test("explicit consent rotates an unexpired credential rejected by inference", async () => { + test("an explicit register rotates an unexpired credential rejected by inference", async () => { await seed({ expiresAt: REGISTERED.expires_at }) const urls: string[] = [] mockFetch((input) => { @@ -632,71 +567,27 @@ describe("inference boundary", () => { }) expect(rejected.status).toBe(401) - const rotated = await FreeTier.registerAfterConsent(consented()) + const rotated = await FreeTier.register({ origin: "picker" }) expect(rotated.apiKey).toBe("sk-altimate-base-rotated") expect(urls).toEqual([`${REGISTERED.base_url}/v1/chat/completions`, `${REGISTERED.base_url}/register`]) }) }) -describe("consent boundary", () => { - test("overlapping one-shot capabilities survive mismatches and remain independent", async () => { - const first = "a".repeat(64) - const second = "b".repeat(64) - let registrations = 0 - // Exercises the gate's arm/register plumbing in isolation, via its own independent store — - // deliberately NOT the production authority `consented()` above uses, since this test is - // about the gate's wiring, not about the real unforgeability property (covered separately). - const store = new FreeTierCapability.ConsentCapabilityStore() - const gate = FreeTierConsent.createRegistrationConsentGate({ - arm: (token) => store.arm(token), - register: async (token) => { - if (!store.consume(token)) throw new FreeTier.RegistrationError("consent expired", "cancelled") - registrations++ - }, - }) - - gate.setToken({ token: first }) - gate.setToken({ token: second }) - expect((await gate.register({ token: "c".repeat(64) })).ok).toBe(false) - expect((await gate.register({ token: first })).ok).toBe(true) - expect((await gate.register({ token: first })).ok).toBe(false) - expect((await gate.register({ token: second })).ok).toBe(true) - expect(registrations).toBe(2) - }) - - test("pending capabilities are bounded and expire", () => { - let now = 1_000 - const capabilities = new FreeTierCapability.ConsentCapabilityStore({ maxPending: 2, ttlMs: 50, now: () => now }) - const first = "a".repeat(64) - const second = "b".repeat(64) - const third = "c".repeat(64) - capabilities.arm(first) - capabilities.arm(second) - capabilities.arm(third) - expect(capabilities.consume(first)).toBe(false) - expect(capabilities.consume(second)).toBe(true) - now += 51 - expect(capabilities.consume(third)).toBe(false) - }) - +describe("registration outcome classification", () => { test("only transport failures are surfaced as network failures", async () => { - const token = "d".repeat(64) - const network = FreeTierConsent.createRegistrationConsentGate({ - arm: () => {}, + const { FreeTierConsent } = await import("../../src/altimate/free/consent") + const network = FreeTierConsent.createRegistrationGate({ register: async () => { throw new FreeTier.RegistrationError("offline", "network") }, }) - network.setToken({ token }) - expect(await network.register({ token })).toMatchObject({ ok: false, result: "network" }) + expect(await network.register()).toMatchObject({ ok: false, result: "network" }) - const invalidResponse = FreeTierConsent.createRegistrationConsentGate({ - arm: () => {}, + const invalidResponse = FreeTierConsent.createRegistrationGate({ register: async () => { throw new FreeTier.RegistrationError("invalid", "response") }, }) - invalidResponse.setToken({ token }) - expect(await invalidResponse.register({ token })).toMatchObject({ ok: false, result: "error" }) + expect(await invalidResponse.register()).toMatchObject({ ok: false, result: "error" }) }) }) diff --git a/packages/opencode/test/server/altimate-base-registration.test.ts b/packages/opencode/test/server/altimate-base-registration.test.ts index 58556e2a51..926b6cf98f 100644 --- a/packages/opencode/test/server/altimate-base-registration.test.ts +++ b/packages/opencode/test/server/altimate-base-registration.test.ts @@ -1,167 +1,109 @@ -import { afterEach, beforeAll, describe, expect, test } from "bun:test" +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { Server } from "../../src/server/server" +import { FreeTier } from "../../src/altimate/free/client" import { FreeTierConsent } from "../../src/altimate/free/consent" -import { FreeTierHost } from "../../src/altimate/free/host" import { resetDatabase } from "./db" import { disposeAllInstances } from "../fixture/fixture" -afterEach(async () => { - await disposeAllInstances() - await resetDatabase() -}) - function app() { return Server.Default() } -// TOPOLOGY NOTE — this file is order-dependent, deliberately. -// -// `FreeTierHost.provide()` installs process-wide module state and is single-shot (a second call -// throws, matching `issueArmer`/`issueRedeemer` next door). `bun test` can load several suite files -// into ONE worker process, so the "no gate injected" case can only be observed before anything in -// the process provides one. The 501 block therefore runs first, and the gate is installed exactly -// once in `beforeAll` of the block after it. -// -// No other file calls `provide` — `cli/cmd/serve.ts` does it inside its command handler, not at -// import — so importing the server here does not arm anything on its own. +// Registration is no longer gated behind a provided capability — any server with a gateway +// configured can register, via `FreeTier.register({ origin: "server" })` called directly from the +// route. This file tests only the ROUTE's own behavior (Origin protection, hash handling, outcome +// passthrough, instance disposal on success) by mocking `FreeTier.register` directly rather than +// exercising a real gateway fetch — the registration function itself (network/HTTP/response +// mapping) is covered by test/altimate/altimate-base*.test.ts. +let registerSpy: ReturnType | undefined -describe("Altimate Base registration — no gate injected (the TUI-worker shape)", () => { - // Ordering: must precede the provided-gate block below. - test("GET /altimate/base/disclosure serves 501 rather than a disclosure", async () => { - const response = await app().request("/altimate/base/disclosure") - expect(response.status).toBe(501) - expect(await response.json()).toMatchObject({ error: expect.stringContaining("cannot register") }) - }) +function mockRegister(impl: () => Promise) { + registerSpy = spyOn(FreeTier, "register").mockImplementation(impl as typeof FreeTier.register) +} - test("POST /altimate/base/register serves 501 rather than registering", async () => { - const response = await app().request("/altimate/base/register", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ acceptedDisclosureSha256: FreeTierConsent.disclosureHash() }), - }) - expect(response.status).toBe(501) - }) +afterEach(async () => { + registerSpy?.mockRestore() + registerSpy = undefined + await disposeAllInstances() + await resetDatabase() }) -describe("Altimate Base registration — gate injected (the `serve` shape)", () => { - // Records what the route asked the gate to do, so the test can assert the route mints/arms/ - // redeems in one operation instead of handing a token to the client. - const armed: string[] = [] - const redeemed: string[] = [] - let outcome: Awaited> = { ok: true } - - beforeAll(() => { - FreeTierHost.provide({ - setToken({ token }) { - armed.push(token) - }, - async register({ token }) { - redeemed.push(token) - return outcome - }, - }) - }) - - test("provide() is single-shot", () => { - expect(() => - FreeTierHost.provide({ setToken() {}, register: async () => ({ ok: true }) }), - ).toThrow(/already provided/) - }) - - test("GET disclosure is read-only: returns text, hint and hash, and arms no token", async () => { - const before = armed.length - const response = await app().request("/altimate/base/disclosure") - expect(response.status).toBe(200) - const body = (await response.json()) as { - disclosure: string - hint: string - sha256: string - registered: boolean +describe("Altimate Base registration route", () => { + test("GET disclosure returns text, hint, hash, and registration state", async () => { + const isRegistered = spyOn(FreeTier, "isRegistered").mockResolvedValue(false) + try { + const response = await app().request("/altimate/base/disclosure") + expect(response.status).toBe(200) + const body = (await response.json()) as { + disclosure: string + hint: string + sha256: string + registered: boolean + } + expect(body.disclosure).toBe(FreeTierConsent.DISCLOSURE) + expect(body.hint).toBe(FreeTierConsent.HINT) + expect(body.sha256).toBe(FreeTierConsent.disclosureHash()) + expect(body.registered).toBe(false) + } finally { + isRegistered.mockRestore() } - expect(body.disclosure).toBe(FreeTierConsent.DISCLOSURE) - expect(body.hint).toBe(FreeTierConsent.HINT) - expect(body.sha256).toBe(FreeTierConsent.disclosureHash()) - expect(typeof body.registered).toBe("boolean") - // The regression this guards: arming here started the consent store's 30s TTL when the - // disclosure was fetched, so a user who read it before consenting was rejected. - expect(armed.length).toBe(before) }) - test("register mints, arms and redeems the same token in one operation", async () => { - armed.length = 0 - redeemed.length = 0 - outcome = { ok: true } + test("register works without a hash", async () => { + mockRegister(async () => ({ apiKey: "sk-fake", baseURL: "https://gateway.test", installSecret: "s" })) const response = await app().request("/altimate/base/register", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ acceptedDisclosureSha256: FreeTierConsent.disclosureHash() }), + body: JSON.stringify({}), }) expect(response.status).toBe(200) - expect(await response.json()).toEqual({ ok: true }) - expect(armed).toHaveLength(1) - expect(armed[0]).toMatch(/^[0-9a-f]{64}$/) - expect(redeemed).toEqual(armed) + expect(await response.json()).toMatchObject({ ok: true }) + expect(registerSpy).toHaveBeenCalledTimes(1) + expect(registerSpy).toHaveBeenCalledWith({ origin: "server" }) }) - test("a stale or absent disclosure hash is refused without touching the gate", async () => { - armed.length = 0 - redeemed.length = 0 + test("register accepts and ignores a stale or absent disclosure hash", async () => { + mockRegister(async () => ({ apiKey: "sk-fake", baseURL: "https://gateway.test", installSecret: "s" })) const response = await app().request("/altimate/base/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ acceptedDisclosureSha256: "0".repeat(64) }), }) expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ ok: false, result: "error" }) - expect(armed).toHaveLength(0) - expect(redeemed).toHaveLength(0) + expect(await response.json()).toMatchObject({ ok: true }) + expect(registerSpy).toHaveBeenCalledTimes(1) }) - test("the hash comparison is case-insensitive on the client's hex", async () => { - armed.length = 0 - outcome = { ok: true } - const response = await app().request("/altimate/base/register", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ acceptedDisclosureSha256: FreeTierConsent.disclosureHash().toUpperCase() }), - }) - expect(await response.json()).toEqual({ ok: true }) - expect(armed).toHaveLength(1) - }) - - test("a browser-originated request is refused on an unsecured server", async () => { - armed.length = 0 + test("a browser-originated request is still refused on an unsecured server", async () => { + mockRegister(async () => ({ apiKey: "sk-fake", baseURL: "https://gateway.test", installSecret: "s" })) const response = await app().request("/altimate/base/register", { method: "POST", headers: { "content-type": "application/json", origin: "http://localhost:3000" }, - body: JSON.stringify({ acceptedDisclosureSha256: FreeTierConsent.disclosureHash() }), + body: JSON.stringify({}), }) expect(response.status).toBe(403) // Nothing was minted: a CORS-allowed page cannot opt the installation into request logging. - expect(armed).toHaveLength(0) + expect(registerSpy).not.toHaveBeenCalled() }) - test("a gate failure is passed through with its result taxonomy intact", async () => { - outcome = { ok: false, result: "rate_limited", message: "Too many requests." } + test("a gateway failure is passed through with its result taxonomy intact", async () => { + mockRegister(async () => { + throw new FreeTier.RegistrationError("Too many requests.", "http", 429) + }) const response = await app().request("/altimate/base/register", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ acceptedDisclosureSha256: FreeTierConsent.disclosureHash() }), + body: JSON.stringify({}), }) expect(response.status).toBe(200) - expect(await response.json()).toEqual({ - ok: false, - result: "rate_limited", - message: "Too many requests.", - }) - outcome = { ok: true } + expect(await response.json()).toMatchObject({ ok: false, result: "rate_limited" }) }) test("a malformed body is rejected by the validator", async () => { const response = await app().request("/altimate/base/register", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ nope: true }), + body: "not json", }) expect(response.status).toBe(400) }) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index bca3affe74..50595271b6 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -292,8 +292,8 @@ describe("provider HttpApi", () => { // altimate_change start — hermetic isolation: `FreeTierStore` resolves its credential path // through the process-wide `Global.Path.data`, not this test's own isolated `TestInstance` // directory. A real registration performed by another Altimate Base suite earlier in this - // same `bun test` process (e.g. `test/altimate/*.test.ts` calling - // `FreeTier.registerAfterConsent()`) writes to that same shared path; without this reset, + // same `bun test` process (e.g. `test/altimate/*.test.ts` calling `FreeTier.register()`) + // writes to that same shared path; without this reset, // its leftover credential makes `altimate-free` autoload — and this test's "not marked as // connected" assertion below flakes depending on test-file execution order. Clear it // unconditionally before making the request, so this test's outcome depends only on itself. diff --git a/packages/opencode/test/skill/release-v0.11.1-adversarial.test.ts b/packages/opencode/test/skill/release-v0.11.1-adversarial.test.ts index 72620f1d6f..8712b832c1 100644 --- a/packages/opencode/test/skill/release-v0.11.1-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.11.1-adversarial.test.ts @@ -38,11 +38,9 @@ * - `Telemetry.track` for an anchor event type before `init()` completes: must buffer without * throwing and must NOT call `flush` (the existing pre-init buffering test uses non-anchor events * and never spies on `flush`). - * - `registerAfterConsent`: a consent token redeemed a second time (the existing "expired consent - * token" test uses a garbage string that was never armed; this exercises the real one-shot - * `consume()` path with a token that WAS valid) and a configured gateway URL that fails the - * https-only / no-credentials check via a different branch than the existing `ftp://` test - * (plain `http://` and an embedded-credentials `https://` URL). + * - `FreeTier.register()`: a configured gateway URL that fails the https-only / no-credentials + * check via a different branch than the existing `ftp://` test (plain `http://` and an + * embedded-credentials `https://` URL). */ import { afterEach, describe, expect, spyOn, test } from "bun:test" import * as fs from "fs" @@ -51,7 +49,7 @@ import * as path from "path" import { pathToFileURL } from "url" import { ConfigPlugin } from "../../src/config/plugin" import { Telemetry } from "../../src/altimate/telemetry" -import { consented, isolateAltimateBaseHome, resetGatewayEnv } from "../altimate/_fixtures/altimate-base-harness" +import { isolateAltimateBaseHome, resetGatewayEnv } from "../altimate/_fixtures/altimate-base-harness" import { FakeGateway, GATEWAY_URL } from "../altimate/_fixtures/fake-gateway" // Harness contract: isolate the Altimate Base home BEFORE importing src/altimate/free/*. @@ -464,13 +462,13 @@ describe("track — anchor events before init", () => { }) // --------------------------------------------------------------------------- -// registerAfterConsent — reuse and gateway-URL rejection branches not already covered +// FreeTier.register() — gateway-URL rejection branches not already covered // --------------------------------------------------------------------------- const gateway = new FakeGateway() const GATEWAY_ENV = ["ALTIMATE_BASE_GATEWAY_URL", "ALTIMATE_FREE_GATEWAY_URL"] as const let savedGatewayEnv: Record = {} -describe("registerAfterConsent — consent reuse and gateway URL validation", () => { +describe("FreeTier.register() — gateway URL validation", () => { afterEach(async () => { gateway.restore() for (const key of GATEWAY_ENV) { @@ -490,22 +488,10 @@ describe("registerAfterConsent — consent reuse and gateway URL validation", () resetGatewayEnv(GATEWAY_URL) } - test("redeeming the same consent token twice fails the second time as cancelled", async () => { - await setUp() - const token = consented() - gateway.registerNext({ kind: "ok" }) - await expect(FreeTier.registerAfterConsent(token)).resolves.toBeDefined() - - await expect(FreeTier.registerAfterConsent(token)).rejects.toMatchObject({ - name: "AltimateBaseRegistrationError", - kind: "cancelled", - }) - }) - test("a plain http:// gateway URL is rejected as a configuration error, not a network error", async () => { await setUp() process.env.ALTIMATE_BASE_GATEWAY_URL = "http://gateway.test" - await expect(FreeTier.registerAfterConsent(consented())).rejects.toMatchObject({ + await expect(FreeTier.register({ origin: "picker" })).rejects.toMatchObject({ name: "AltimateBaseConfigurationError", }) // The rejection must come from the URL check before any network call, so nothing was sent. @@ -515,7 +501,7 @@ describe("registerAfterConsent — consent reuse and gateway URL validation", () test("a gateway URL with embedded credentials is rejected as a configuration error", async () => { await setUp() process.env.ALTIMATE_BASE_GATEWAY_URL = "https://user:pass@gateway.test" - await expect(FreeTier.registerAfterConsent(consented())).rejects.toMatchObject({ + await expect(FreeTier.register({ origin: "picker" })).rejects.toMatchObject({ name: "AltimateBaseConfigurationError", }) expect(gateway.registerCalls).toHaveLength(0) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index f445d0a926..865ec432d1 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -39,13 +39,13 @@ import { DialogProvider, useDialog } from "./ui/dialog" // + /logout commands import { DialogAltimateAuth } from "./component/dialog-provider" import { - DialogAltimateBaseConfirm, DialogModelWelcome, useReady, useSetupComplete, markFirstRunActive, resetSetupComplete, useFirstRunOpenedThisLaunch, + useAltimateBaseDisclosureNotice, } from "./component/altimate-onboarding" // altimate_change end // altimate_change — Part 2 scan gate (fires once when Part 1 first completes) @@ -105,11 +105,7 @@ import { useOpencodeKeymap, } from "./keymap" -import type { EventSource } from "./context/sdk" -// altimate_change start — consent-gated registration operation lives outside the public SDK -// context; see context/altimate-base-consent.tsx for why. -import { AltimateBaseConsentProvider, useAltimateBaseConsent, type AltimateBaseRegistration } from "./context/altimate-base-consent" -// altimate_change end +import type { EventSource, AltimateBaseRegisterFn } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" import { createTuiAttention } from "./attention" import * as TuiAudio from "./audio" @@ -183,8 +179,10 @@ export type TuiInput = { headers?: RequestInit["headers"] events?: EventSource pluginHost: TuiPluginHost - // altimate_change start — host-injected Altimate Base registration operation - altimateBaseRegistration?: AltimateBaseRegistration + // altimate_change start — host-injected Altimate Base registration (no consent gate); see + // context/sdk.tsx's AltimateBaseRegisterFn. Absent for an attached TUI, which falls back to the + // HTTP route directly. + registerAltimateBase?: AltimateBaseRegisterFn // altimate_change end // altimate_change start — onboarding funnel telemetry, injected by the host (packages/tui cannot // reach the Telemetry module). Optional: absent means no tracking, not an error. @@ -348,12 +346,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { fetch={input.fetch} headers={input.headers} events={input.events} + registerAltimateBase={input.registerAltimateBase} // altimate_change — no consent gate; see context/sdk.tsx's AltimateBaseRegisterFn > - {/* altimate_change start — consent-gated registration kept - out of SDKProvider/useSDK(); see - context/altimate-base-consent.tsx */} - - {/* altimate_change end */} @@ -380,7 +374,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - @@ -425,11 +418,10 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const keymap = useOpencodeKeymap() const event = useEvent() const sdk = useSDK() - // altimate_change start — read the consent-gated registration operation from its own dedicated - // context, not from the shared SDK context; see context/altimate-base-consent.tsx. - const altimateBaseConsent = useAltimateBaseConsent() - // altimate_change end const toast = useToast() + // altimate_change start — non-blocking replacement for the old consent dialog's disclosure text + useAltimateBaseDisclosureNotice() + // altimate_change end const themeState = useTheme() const { theme, mode, setMode, locked, lock, unlock } = themeState const sync = useSync() @@ -654,15 +646,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // to Base before kv hydration can ever re-run this effect. if (!ready() || sync.status !== "complete" || !local.model.ready || !promptHistory.loaded() || !kv.ready) return - // altimate_change — fixes #1301: a user is "returning" if there is any sign of prior use - // anywhere this TUI persists it: prompt history (independent of the current project's - // session list, and not windowed to the last 30 days the way session sync is), the current - // project's own session list, or a picker-written recent model. `hadHistoryAtStartup()` is a - // one-time snapshot — a prompt sent during THIS launch must not retroactively make the launch - // look like a return visit. - const returning = - promptHistory.hadHistoryAtStartup() || sync.data.session.length > 0 || local.model.recent().length > 0 - // ---- Migration ---- // A previous decline is checked FIRST, before registration state or eligibility. Registering // Altimate Base for one task is not consent to move a free default that the user already @@ -688,33 +671,11 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi return } - // altimate_change — the registration operation lives in its own dedicated context now, not - // on `sdk`; see context/altimate-base-consent.tsx. A brand-new (non-returning) user never - // sees this disclosure — see the block comment above. - if (altimateBaseConsent && returning) { - const shown = dialog.replace(() => ( - { - kv.set(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, true) - // altimate_change — fixes #1301 (Codex review, P1): the kv key alone is invisible - // to headless/server default selection (`Provider.defaultModel()`, ACP). Persist - // the same refusal into `model.json`, which the server already reads, so a decline - // made in the TUI is honored there too. - local.model.declineManagedBaseDefault() - }} - /> - )) - if (shown) { - startupDecisionHandled = true - return - } - // `dialog.replace` lost a race to another dialog and returned false without opening - // anything — fall through to first-run logic below instead of latching "handled" on a - // dialog nobody actually saw. - } - // Not registered, no consent operation available, or a brand-new user: no migration - // dialog this launch. Fall through to the ordinary first-run logic below. + // altimate_change — Base isn't registered yet (autoRegister may still be in flight, or + // failed/skipped) and the user has an own explicit pick of the legacy default, so there's + // nothing to silently migrate this launch. There is no more blocking disclosure dialog to + // fall back to — `returning` and `previouslyDeclined` above are still honored for the + // silent path, but neither gates a prompt any more. Fall through to first-run logic below. } // ---- First-run onboarding gate ---- diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index c7c055afd2..ca9a1a7576 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -13,14 +13,12 @@ import { createDialogProviderOptions } from "./dialog-provider" import { DialogModel } from "./dialog-model" import { useConnected } from "./use-connected" import { useSDK } from "../context/sdk" -// altimate_change — the consent-gated registration operation lives outside the public SDK -// context; see context/altimate-base-consent.tsx. -import { useAltimateBaseConsent, type AltimateBaseRegistration } from "../context/altimate-base-consent" import { useSync } from "../context/sync" import { useToast } from "../ui/toast" +import { useKV } from "../context/kv" // altimate_change — onboarding funnel telemetry seam import { useOnboardingTelemetry } from "../context/onboarding-telemetry" -// altimate_change — the Base consent disclosure has one definition, shared with the HTTP route +// altimate_change — the Base disclosure has one definition, shared with the HTTP route import { ALTIMATE_BASE_DISCLOSURE, ALTIMATE_BASE_HINT } from "@opencode-ai/core/altimate-base-disclosure" // Session-scoped "setup complete" flag. Set when the user picks a ready model, @@ -178,6 +176,9 @@ export function DialogModelWelcome(props: { const { theme } = useTheme() const dialog = useDialog() const local = useLocal() + const sdk = useSDK() + const sync = useSync() + const toast = useToast() const providers = createDialogProviderOptions() const [selected, setSelected] = createSignal(0) // altimate_change start — funnel: picker impression + provider choice @@ -205,7 +206,7 @@ export function DialogModelWelcome(props: { function chooseAltimateBase(): boolean { if (!providers().some((provider) => provider.value === "altimate-free")) return false - dialog.replace(() => ) + void selectAltimateBase({ sdk, sync, local, toast, dialog }) return true } @@ -409,424 +410,108 @@ export function DialogModelWelcome(props: { ) } -// altimate_change start — surfaced in the DialogAltimateBaseConfirm consent gate below before any -// Base credential is minted. This is the text a user actually consents against before any -// registration request, so it states the core data terms up front: requests/responses may be -// logged and used to improve Altimate's products (including the model), so users should not send -// secrets. The persistent per-install-id linkage detail is disclosed in -// docs/docs/configure/providers.md ("Data handling"), not repeated in this gate; keep the core -// terms in sync with that note. -// -// Defined once in core (imported at the top of this file) and re-exported here for existing -// consumers, so this dialog and the HTTP disclosure route (packages/opencode, for hosts that -// render their own dialog) cannot drift apart — a copy change like #1268 now lands on both. +// altimate_change start — the gateway still logs requests, so this notice text stays even though +// registering no longer requires accepting it first. Defined once in core (imported at the top of +// this file) and re-exported here for existing consumers, so this TUI notice and the HTTP +// disclosure route (packages/opencode, for hosts that render their own copy) cannot drift apart — +// a copy change like #1268 now lands on both. export { ALTIMATE_BASE_DISCLOSURE } // altimate_change end +// altimate_change start — no consent dialog: selecting Altimate Base from any picker registers it +// if needed (or reuses an existing/auto-registered credential) and selects it directly. Replaces +// `DialogAltimateBaseConfirm`; keeps the same register -> refresh provider state -> validate -> +// select sequence that dialog used, and the same "show an error, don't select" behavior on +// failure. type RegisterOutcome = | { ok: true } | { ok: false; result: "rate_limited" | "unavailable" | "network" | "error"; message: string } const REGISTER_FAILURE_MESSAGE = "Could not set up Altimate Base. Try again, or pick another provider." -async function registerAltimateBase(register: AltimateBaseRegistration | undefined): Promise { - if (!register) return { ok: false, result: "error", message: REGISTER_FAILURE_MESSAGE } +/** + * Registers via the host-injected `sdk.registerAltimateBase` (the private worker RPC — see + * context/sdk.tsx) when available. An attached TUI has no in-process worker to call + * (cli/cmd/attach.ts never provides it), so it falls back to the server's own + * `POST /altimate/base/register` route over the same transport (`sdk.fetch`/`sdk.url`) everything + * else uses. + */ +async function registerAltimateBase(sdk: ReturnType): Promise { try { - const data = await register() - if (data.ok) return { ok: true } - return { - ok: false, - result: data.result, - message: data.message || REGISTER_FAILURE_MESSAGE, + if (sdk.registerAltimateBase) { + const data = await sdk.registerAltimateBase() + if (data.ok) return { ok: true } + return { ok: false, result: data.result, message: data.message || REGISTER_FAILURE_MESSAGE } } + const response = await sdk.fetch(`${sdk.url}/altimate/base/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }) + const body = (await response.json().catch(() => undefined)) as + | { ok?: boolean; result?: "rate_limited" | "unavailable" | "network" | "error"; message?: string } + | undefined + if (body?.ok) return { ok: true } + return { ok: false, result: body?.result ?? "error", message: body?.message || REGISTER_FAILURE_MESSAGE } } catch { return { ok: false, result: "network", message: REGISTER_FAILURE_MESSAGE } } } -// Consent disclosure and registration flow. The default remains No, and no identifier is minted -// until the user explicitly accepts. -export function DialogAltimateBaseConfirm(props: { - // altimate_change — returning Big Pickle users reuse the same disclosure before migration - origin: "welcome" | "model" | "migration" - viaSearch?: boolean - onDecline?: () => void -}) { - const { theme } = useTheme() - const dialog = useDialog() +const ALTIMATE_BASE_DISCLOSURE_SHOWN_KEY = "altimate_base_disclosure_shown_v1" + +/** + * Non-blocking replacement for the old consent dialog's disclosure text: a one-line toast shown + * once per install, the first time Base becomes the active model — whether that happened via + * autoRegister at startup or an explicit picker selection. Never blocks input. + */ +export function useAltimateBaseDisclosureNotice() { const local = useLocal() - const sdk = useSDK() - // altimate_change — the actual registration call, read from its own dedicated context rather - // than the public SDK context; see context/altimate-base-consent.tsx. - const altimateBaseConsent = useAltimateBaseConsent() - const sync = useSync() + const kv = useKV() const toast = useToast() - const [selected, setSelected] = createSignal(0) // 0 = No (default) - const [busy, setBusy] = createSignal(false) - const [error, setError] = createSignal() - const trackOnboarding = useOnboardingTelemetry() - const firstRunActive = useFirstRunActive() - // altimate_change — PR #1302 review (Cursor "Accept can skip default rewrite", medium, real): - // captured HERE, ONCE, before `yes()` can run any registration — `local.model.launchDefault()` - // is a live memo (`fallbackModel()`), and registration + `sync.bootstrap()` can make - // `altimate-free/altimate-base` the first live provider, moving `fallbackModel()` to Base - // itself by the time `yes()` would otherwise re-read it. Passed to `migrateLegacyDefault({ - // from })` below so eligibility is re-checked against what the launch default WAS, not what it - // has since become. - const launchDefault = local.model.launchDefault() - // altimate_change start — cubic review round 5, P2: same snapshot reasoning as - // `launchDefault` above, applied to its display name too. `launchDefaultDisplay()` is a LIVE - // memo over the same `fallbackModel()` — calling it from JSX (as the disclosure copy used to) - // re-reads it on every re-render, so once `yes()`'s registration makes Altimate Base the new - // `fallbackModel()`, the disclosure still on screen (`yes()` awaits registration before the - // dialog closes) could rename itself to "Altimate Base" mid-sentence in copy that is - // specifically explaining why the CURRENT default is being replaced. Snapshotting here, once, - // alongside `launchDefault`, keeps the copy naming the model that was actually true when the - // dialog opened. - const launchDefaultDisplay = local.model.launchDefaultDisplay() - // altimate_change end - let decided = false - let choiceRecorded = false - let disposed = false - // altimate_change start — Cursor/CodeRabbit/cubic review round 5: `recordChoice`'s - // `lastCloseReason !== "programmatic" && lastCloseReason !== "interrupt"` check treated - // `lastCloseReason === undefined` as "record it" — but `undefined` is also what a genuine - // top-level quit (process exit, Ctrl+C at the top of the app disposing the whole Solid root) - // leaves behind, since that teardown runs `onCleanup` without the close guard ever being - // consulted. That silently counted app quits as declines in `altimate_base_choice` telemetry. - // `chosen` is the positive signal instead: it is set ONLY inside `no()`/`yes()`, i.e. only when - // the user (or the guard's `queueMicrotask(no)` for a genuine dismiss) actually reached a - // decision. `onCleanup`'s unconditional `recordChoice("cancel")` fallback now records nothing - // for migration unless a decision was actually made. - let chosen = false - // altimate_change end - // altimate_change start — fixes #1301: the migration origin never entered the first-run funnel - // at all (it was gated on `firstRunActive()`, which migration never sets), so the disclosure - // that matters most for measuring the fix was invisible to telemetry. Migration is still not - // FIRST-RUN onboarding, so it stays out of the `firstRunActive()`-gated events below, but it - // gets its own unconditional emission with `origin: "migration"` on every event. - // - // `lastCloseReason` remembers which kind of close the guard most recently PERMITTED (`"dismiss"` - // for Escape/the backdrop click — `dialog.tsx`'s `dismiss()`, wired to the backdrop - // specifically; `"programmatic"` for this dialog's own `clear()`/`replace()` or an unrelated - // feature's; `"interrupt"` for Ctrl+C — see `ui/dialog.tsx`) so the `onCleanup` fallback below - // can tell them apart too. - let lastCloseReason: "dismiss" | "interrupt" | "programmatic" | undefined - const releaseCloseGuard = dialog.guardClose((reason) => { - // altimate_change — Kilo review round 6 (3986171185): a dismiss attempted WHILE `busy()` - // (registration in flight) is VETOED below — the close does not happen, no decision is made, - // `no()` is deliberately not queued. Recording `lastCloseReason` before that veto check used - // to leave it set to `"dismiss"` anyway, as a side effect of an attempt that never actually - // went through. If the app was then torn down before the guard was consulted again (mid - // registration, then a hard quit — the exact guard-free teardown path `onCleanup`'s fallback - // below exists for), that stale `"dismiss"` made the fallback persist a decline nobody - // actually made. Bail out before recording anything whenever the close is going to be - // vetoed for being busy — `lastCloseReason` now only ever reflects a close the guard - // actually PERMITTED (or explicitly routed to `no()`, below). - if (busy()) return false - lastCloseReason = reason - // Escape closes through `DialogProvider`'s keymap binding (`closeTop("dismiss")`), which - // calls this guard BEFORE the dialog's own `useKeyboard` below ever sees the key — so - // intercepting in `useKeyboard` alone would be too late; the dialog would already be gone. - // The backdrop click reaches here the same way, via `dialog.tsx`'s `dismiss()` (fixes #1301, - // Codex review round 2, P2: it used to call `clear()`, i.e. "programmatic", so clicking - // outside the dialog silently skipped both the decline AND the picker that keyboard Escape - // gets). This dialog's own visible "esc" label calls `no()` directly instead of going through - // the guard at all — see its `onMouseUp` below. For a migration DISMISSAL from any of these, - // veto the close and run the same routing `no()` does (persist the decline, open the picker) - // on a microtask instead of a bare dismissal, which the retired Big Pickle model cannot - // silently fall back to. `no()` sets `decided = true` before its own `dialog.replace`, so - // that replace passes this same guard on its re-check (reason "programmatic", by then - // decided) and this queued call cannot double-fire. - // - // Ctrl+C closes through the same binding but with reason "interrupt" (PR review round 3): - // Ctrl+C is a "get me out" gesture (quitting the app, or backing out of whatever's on - // screen), not "I decline Altimate Base specifically" the way Escape on THIS dialog is. Before - // this distinction existed, quitting with Ctrl+C twice while the migration dialog was open - // queued `no()` on the FIRST Ctrl+C (persist + picker takeover) before the second one could - // quit — recording a refusal the user never made. "interrupt" is deliberately NOT matched - // below, so it falls through to the same handling as a PROGRAMMATIC close: the close - // succeeds, nothing is persisted, and the disclosure is simply offered again next launch. - // - // A PROGRAMMATIC close (this dialog's own `clear()`/`replace()`, or an unrelated feature — - // command palette, session list — replacing the dialog stack out from under this one) is left - // alone here too. Neither it nor an interrupt is the user declining Altimate Base, so forcing - // `no()` for them turned harmless UI navigation (or quitting) into a persisted refusal plus an - // unwanted picker takeover. The `onCleanup` fallback below only persists a decline for the - // reasons this guard could not itself resolve into a decision. - if (reason === "dismiss" && props.origin === "migration" && !decided) { - queueMicrotask(no) - return false - } - return true - }) - // altimate_change end - - function recordChoice(choice: "accept" | "cancel") { - if (choiceRecorded) return - choiceRecorded = true - // altimate_change — Cursor/CodeRabbit/cubic review round 5: see `chosen`'s declaration above. - // `lastCloseReason === "dismiss"` is kept alongside `chosen` defensively (a genuine dismiss - // always routes through `no()`, which sets `chosen` first, but this keeps the condition - // correct even if that ordering ever changes) — it is `undefined` (top-level quit) and - // `"programmatic"`/`"interrupt"` (unrelated close, Ctrl+C) that must NOT record a choice. - if (props.origin === "migration" ? chosen || lastCloseReason === "dismiss" : firstRunActive()) { - trackOnboarding({ name: "altimate_base_choice", choice, origin: props.origin }) - } - } - - onMount(() => { - // altimate_change — fixes #1301: see the block comment on `releaseCloseGuard` above - if (props.origin === "migration" || firstRunActive()) { - trackOnboarding({ name: "altimate_base_confirm_shown", origin: props.origin }) - } - }) - onCleanup(() => { - releaseCloseGuard() - disposed = true - // altimate_change start — PR #1302 review (CodeRabbit + cubic, both flagged this; Kilo review - // round 6, 3986171185, corrected further): a genuine user DISMISSAL — keyboard Escape or the - // backdrop click, which `dialog.tsx` reports as `dismiss()` (reason "dismiss") — is normally - // fully handled above via `queueMicrotask(no)`, which sets `decided` before this ever runs, - // same as this dialog's own visible "esc" label (see its `onMouseUp` above, which calls - // `no()` directly). Ctrl+C is a separate "interrupt" reason, never "dismiss" — see the guard - // above. So this branch does not double an ORDINARY dismissal. It is not purely - // documentation, though: it is the actual safety net for a dismiss attempted WHILE `busy()` - // was true (registration in flight) followed by teardown before the guard is consulted - // again — the guard above now bails out BEFORE recording anything in that case, so - // `lastCloseReason` stays whatever it was before the vetoed attempt (typically `undefined`, - // since a legitimate prior close would already have set `decided`), and this condition - // correctly stays false for it too. A true positive here (a real, unqueued dismiss reaching - // teardown) would be an ordering bug elsewhere; this remains a deliberate belt-and-suspenders - // check, not dead code. - // - // The bug this also fixes: renderer teardown (process exit, Ctrl+C-to-quit at the TOP level, - // not this dialog's own Ctrl+C binding) runs this cleanup WITHOUT the guard ever having been - // consulted, so `lastCloseReason` stays `undefined`. The previous `!== "programmatic"` check - // treated "no reason at all" the same as "dismissed", persisting a refusal the user never - // made just from quitting the app. Requiring the reason to be the observed, positive - // "dismiss" — not merely "not programmatic" — excludes both `undefined` and "programmatic" - // (this dialog's own `clear()`/`replace()`, or an unrelated feature replacing the dialog - // stack out from under this one — neither is the user declining Altimate Base either). - if (!decided && props.origin === "migration" && lastCloseReason === "dismiss") props.onDecline?.() - // altimate_change end - decided = true - recordChoice("cancel") + createEffect(() => { + if (!kv.ready) return + const model = local.model.current() + if (!model || model.providerID !== "altimate-free" || model.modelID !== "altimate-base") return + if (kv.get(ALTIMATE_BASE_DISCLOSURE_SHOWN_KEY, false)) return + kv.set(ALTIMATE_BASE_DISCLOSURE_SHOWN_KEY, true) + toast.show({ variant: "info", message: ALTIMATE_BASE_DISCLOSURE, duration: 8000 }) }) +} - function no() { - if (decided || busy()) return - decided = true - // altimate_change — Cursor/CodeRabbit/cubic review round 5: see `chosen`'s declaration above - chosen = true - recordChoice("cancel") - // altimate_change — a migration decline no longer just leaves the dialog cleared: Big Pickle - // is retired, so "pick something else" must actually route somewhere. `onDecline` still - // persists the refusal first, so this prompt is not shown again on a later launch. - if (props.origin === "migration") props.onDecline?.() - dialog.replace(() => - props.origin === "model" ? ( - - ) : ( - - ), - ) +/** + * Shared by every picker that offers Altimate Base (the welcome picker, the full catalogue, and + * the provider dialog): register if needed, refresh provider state, confirm the model actually + * came up, then select it. An error at any step is shown via toast and the selection is left + * alone — never a partial/failed switch. + */ +export async function selectAltimateBase(input: { + sdk: ReturnType + sync: ReturnType + local: ReturnType + toast: ReturnType + dialog: ReturnType +}): Promise { + const outcome = await registerAltimateBase(input.sdk) + if (!outcome.ok) { + input.toast.show({ variant: "error", message: outcome.message }) + return false } - async function yes() { - if (decided || busy()) return - // altimate_change — Cursor/CodeRabbit/cubic review round 5: see `chosen`'s declaration above - chosen = true - recordChoice("accept") - setBusy(true) - setError(undefined) - const outcome = await registerAltimateBase(altimateBaseConsent) - if (disposed) return - // altimate_change — fixes #1301: see the block comment on `releaseCloseGuard` above - if (props.origin === "migration" || firstRunActive()) { - trackOnboarding({ - name: "altimate_base_register_result", - result: outcome.ok ? "success" : outcome.result, - origin: props.origin, - }) - } - if (!outcome.ok) { - setBusy(false) - setError(outcome.message) - toast.show({ variant: "error", message: outcome.message }) - return - } - - await sdk.client.instance.dispose().catch(() => {}) - if (disposed) return - await sync.bootstrap().catch(() => {}) - if (disposed) return - const available = sync.data.provider.some( - (provider) => provider.id === "altimate-free" && Boolean(provider.models?.["altimate-base"]), - ) - if (!available) { - const message = "Altimate Base was registered, but the model is not ready yet. Try again in a moment." - setBusy(false) - setError(message) - toast.show({ variant: "error", message }) - return - } - - decided = true - setBusy(false) - if (props.origin === "migration") { - // A migration also removes the retired implicit model from recents. Re-check eligibility - // after registration so a project allowlist or explicit model change made while the dialog - // was open cannot be overwritten by the returning-user migration. `from: launchDefault` - // (captured on mount, before registration) — see its declaration above — keeps this - // re-check from being defeated by `fallbackModel()` itself having moved to Base by now. - const migrated = local.model.migrateLegacyDefault({ from: launchDefault }) - if (!migrated) { - // Registration succeeded, but migration is no longer eligible — the user is still on the - // retired Big Pickle model. Route to the picker instead of marking setup complete for a - // model this session no longer treats as usable. - dialog.replace(() => ) - return - } - } else { - local.model.set({ providerID: "altimate-free", modelID: "altimate-base" }, { recent: true }) - } - dialog.clear() - markSetupComplete() + await input.sdk.client.instance.dispose().catch(() => {}) + await input.sync.bootstrap().catch(() => {}) + const available = input.sync.data.provider.some( + (provider) => provider.id === "altimate-free" && Boolean(provider.models?.["altimate-base"]), + ) + if (!available) { + const message = "Altimate Base was registered, but the model is not ready yet. Try again in a moment." + input.toast.show({ variant: "error", message }) + return false } - const options = [ - { - label: "No — pick something else", - hint: "(default)", - run: no, - }, - { label: "Yes — use Altimate Base", hint: "", run: () => void yes() }, - ] - - useKeyboard((evt) => { - if (busy()) { - if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) { - evt.preventDefault() - evt.stopPropagation() - } - return - } - if (evt.name === "up" || evt.name === "down") { - setSelected((prev) => (prev + 1) % 2) - evt.preventDefault() - return - } - if (evt.name === "return") { - evt.preventDefault() - evt.stopPropagation() - options[selected()].run() - return - } - if (evt.name === "y" && !evt.ctrl && !evt.meta) { - evt.preventDefault() - void yes() - return - } - if (evt.name === "n" && !evt.ctrl && !evt.meta) { - evt.preventDefault() - no() - } - }) - - const selFg = selectedForeground(theme) - const transparent = RGBA.fromInts(0, 0, 0, 0) - - return ( - - - - Use Altimate Base? - - {/* altimate_change start — fixes #1301 (Codex review round 2, P2): this visible label is - a user dismissal too, exactly like the keyboard key and the backdrop click — for - migration it must route through `no()` (persist the decline, open the picker), not a - bare `dialog.clear()`, or clicking it silently leaves the next server launch free to - pick Base again after a partial registration. */} - { - if (busy()) return - if (props.origin === "migration") { - no() - return - } - dialog.clear() - }} - > - esc - - {/* altimate_change end */} - - {/* altimate_change start — fixes #1301: migration now also covers implicit free public - Zen defaults besides the retired Big Pickle id, so the copy must name whichever model - is actually being moved rather than always naming Big Pickle specifically. - PR #1302 review (CodeRabbit + cubic, both flagged this): this must describe the LAUNCH - default (the captured `launchDefault`/`launchDefaultDisplay` snapshots above, = what - `fallbackModel()` resolved to when the dialog opened) — the model migration eligibility - and `migrateLegacyDefault()` actually reason about — not `local.model.current()`/ - `parsed()` (a session-restored model on `restoreSession`/`--continue`) NOR the live - `local.model.launchDefault()`/`launchDefaultDisplay()` memos themselves (cubic review - round 5: those can change mid-dialog once `yes()`'s registration makes Altimate Base - the new live fallback, renaming this copy out from under the user while it explains why - the OLD default is being replaced). */} - - - {`Your default model, ${launchDefaultDisplay.model}, is a public free model. Altimate Base is the free model Altimate hosts for data work.`} - - } - > - - Big Pickle has been retired. - - - - {/* altimate_change end */} - - {ALTIMATE_BASE_DISCLOSURE} - - - - {error()!} - - - - Setting up… - - - - {(option, index) => ( - setSelected(index())} onMouseUp={() => option.run()}> - - {selected() === index() ? "›" : " "} - - - - {option.label} - - - - {option.hint} - - - )} - - - - ) + input.local.model.set({ providerID: "altimate-free", modelID: "altimate-base" }, { recent: true }) + input.dialog.clear() + markSetupComplete() + return true } +// altimate_change end diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 92fe96a423..0b77406e47 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -1,9 +1,11 @@ import { createMemo, createSignal } from "solid-js" import { useLocal } from "../context/local" import { useSync } from "../context/sync" +import { useSDK } from "../context/sdk" import { map, pipe, flatMap, entries, filter, sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" +import { useToast } from "../ui/toast" // altimate_change start — PROVIDER_PRIORITY orders the READY section like the curated picker; // CUSTOM_PROVIDER_OPTION_VALUE identifies the "Other" row, which must not record a provider // choice before the user has supplied one. @@ -20,9 +22,9 @@ import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" // altimate_change — onboarding helpers (readiness state, welcome picker, Altimate Base // disclosure) live in the altimate-owned ./altimate-onboarding to keep this -// upstream file's rebase surface small. markSetupComplete / DialogAltimateBaseConfirm +// upstream file's rebase surface small. markSetupComplete / selectAltimateBase // are used by the restructured DialogModel below. -import { markSetupComplete, useFirstRunActive, DialogAltimateBaseConfirm } from "./altimate-onboarding" +import { markSetupComplete, useFirstRunActive, selectAltimateBase } from "./altimate-onboarding" // altimate_change — funnel: provider identity for a pick made from the full catalogue import { useOnboardingTelemetry } from "../context/onboarding-telemetry" // altimate_change — one definition of the Base picker hint @@ -47,6 +49,10 @@ export function DialogModel(props: { const local = useLocal() const sync = useSync() const dialog = useDialog() + // altimate_change start — needed by selectAltimateBase() below + const sdk = useSDK() + const toast = useToast() + // altimate_change end const [query, setQuery] = createSignal("") const connected = useConnected() @@ -132,8 +138,8 @@ export function DialogModel(props: { // altimate_change — Big Pickle is retired as a NEW selectable option: Altimate Base is now the // free/default model, and a fresh pick of Big Pickle from this catalogue would just recreate the // account this release is retiring. Users already on Big Pickle are unaffected — they are - // detected on launch (see `isExistingBigPickleSelection` in ../context/local) and offered the - // Altimate Base consent gate through the migration path, which this removal does not touch. + // silently migrated to Base once it's registered (see `isExistingBigPickleSelection` and + // `migrateLegacyDefault` in ../context/local), which this removal does not touch. // NEEDS SETUP — providers without valid credentials (selecting routes into their // auth flow first), plus the Altimate Base disclosure. Hidden when scoped to one @@ -191,7 +197,7 @@ export function DialogModel(props: { via_search: props.viaSearch ?? false, }) } - dialog.replace(() => ) + void selectAltimateBase({ sdk, sync, local, toast, dialog }) // altimate_change — no dialog: register then select return undefined }, } diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index 64c63fbb6e..7c0f2a0a72 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -4,9 +4,6 @@ import { map, pipe, sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { useSDK } from "../context/sdk" -// altimate_change — availability check only; the callable registration operation itself lives -// outside the public SDK context. See context/altimate-base-consent.tsx. -import { useAltimateBaseConsent } from "../context/altimate-base-consent" import { DialogPrompt } from "../ui/dialog-prompt" import { Link } from "../ui/link" import { useTheme } from "../context/theme" @@ -24,7 +21,7 @@ import { useLocal } from "../context/local" import { markSetupComplete, clearFirstRunActive, - DialogAltimateBaseConfirm, + selectAltimateBase, useFirstRunActive, } from "./altimate-onboarding" // altimate_change end @@ -37,8 +34,8 @@ import { ALTIMATE_BASE_HINT } from "@opencode-ai/core/altimate-base-disclosure" export const PROVIDER_PRIORITY: Record = { // altimate_change start — Part 1 onboarding: Altimate LLM Gateway is the // recommended default first; the BYOK providers rank next; OpenCode Zen loses - // its "Recommended" tag and drops below. Altimate Base occupies priority 4 and its - // consent flow is injected by dialog-model between Google and Zen. + // its "Recommended" tag and drops below. Altimate Base occupies priority 4, + // between Google and Zen. "altimate-backend": 0, anthropic: 1, openai: 2, @@ -124,9 +121,7 @@ export function createDialogProviderOptions() { const sync = useSync() const dialog = useDialog() const sdk = useSDK() - // altimate_change start — availability only; see context/altimate-base-consent.tsx. - const altimateBaseConsent = useAltimateBaseConsent() - // altimate_change end + const local = useLocal() // altimate_change — needed by selectAltimateBase() below const toast = useToast() const { theme } = useTheme() const onboarded = useConnected() @@ -165,13 +160,7 @@ export function createDialogProviderOptions() { const options = createMemo(() => { return pipe( - // altimate_change start — hide Base setup when the host cannot perform private registration - // A host without the private registration operation must not advertise Base setup. Already - // registered Base models remain available through the READY model list. - providerOptions(sync.data.provider_next.all).filter( - (provider) => provider.value !== "altimate-free" || Boolean(altimateBaseConsent), - ), - // altimate_change end + providerOptions(sync.data.provider_next.all), // altimate_change — Base is always offered now, never hidden pending a registration capability map((provider) => { if (provider.type === "custom") { return { @@ -200,7 +189,7 @@ export function createDialogProviderOptions() { gutter: connected && onboarded() ? () => : undefined, async onSelect() { if (consoleManaged) return - // altimate_change start — route Altimate Base through its disclosure and consent flow + // altimate_change start — register (if needed) and select Altimate Base directly, no dialog if (providerID === "altimate-free") { if (altimateBaseActivated) return altimateBaseActivated = true @@ -212,7 +201,7 @@ export function createDialogProviderOptions() { via_search: false, }) } - dialog.replace(() => ) + void selectAltimateBase({ sdk, sync, local, toast, dialog }) return } // altimate_change end diff --git a/packages/tui/src/context/altimate-base-consent.tsx b/packages/tui/src/context/altimate-base-consent.tsx deleted file mode 100644 index bb20cea6bd..0000000000 --- a/packages/tui/src/context/altimate-base-consent.tsx +++ /dev/null @@ -1,43 +0,0 @@ -// altimate_change start — the Altimate Base consent-gated registration operation, kept OUT of the -// public SDK context (`./sdk`, exported from the package as `@opencode-ai/tui/context/sdk`). Any -// in-process consumer of that public hook — including a plugin-rendered component that only -// imports the published surface — must not be able to call this and mint a Base install identifier -// / enable request logging without the disclosure dialog ever being shown and accepted. -// -// This module is deliberately NOT listed in `package.json`'s `exports` map, so -// `@opencode-ai/tui/context/altimate-base-consent` cannot be resolved from outside this package — -// Node's exports field rejects any subpath it does not list, even by an external caller who knows -// the file's on-disk path. Only in-package modules can import it directly: `app.tsx` (which -// receives the host-injected operation and provides it here) and the two legitimate readers, the -// consent dialog (which actually calls it, only after the user accepts) and the provider picker -// (which only checks whether it exists, to decide whether to advertise Base setup at all). -import { createContext, useContext, type ParentProps } from "solid-js" - -export type AltimateBaseRegistration = () => Promise< - | { ok: true } - | { - ok: false - result: "rate_limited" | "unavailable" | "network" | "error" - message: string - } -> - -const AltimateBaseConsentContext = createContext() - -export function AltimateBaseConsentProvider(props: ParentProps<{ value?: AltimateBaseRegistration }>) { - return ( - {props.children} - ) -} - -/** - * Returns the host-injected registration operation, or `undefined` when the host did not supply - * one (or this is called outside the provider). No "must be used within a provider" guard, unlike - * most contexts here: many hosts (tests, embedders, headless callers) never mount - * `AltimateBaseConsentProvider` at all, and the absence of Base setup is a normal, silent case — - * every call site already handles `undefined` by hiding or refusing Base setup. - */ -export function useAltimateBaseConsent(): AltimateBaseRegistration | undefined { - return useContext(AltimateBaseConsentContext) -} -// altimate_change end diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 8d77b260ff..d2fab995e7 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -8,6 +8,19 @@ export type EventSource = { subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void> } +// altimate_change start — host-injected Altimate Base registration, no consent token. Formerly a +// dedicated context (context/altimate-base-consent.tsx) kept out of the public SDK surface so a +// plugin-rendered component could not mint a Base credential without the disclosure dialog being +// shown first. That dialog is gone — registration is unconditional now — so this lives on the +// ordinary SDK context like everything else. Absent for an attached TUI (cli/cmd/attach.ts has no +// in-process worker to call), which falls back to the HTTP route directly; see +// component/altimate-onboarding.tsx's `registerAltimateBase`. +export type AltimateBaseRegisterResult = + | { ok: true } + | { ok: false; result: "rate_limited" | "unavailable" | "network" | "error"; message: string } +export type AltimateBaseRegisterFn = () => Promise +// altimate_change end + export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", init: (props: { @@ -16,6 +29,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ fetch?: typeof fetch headers?: RequestInit["headers"] events?: EventSource + registerAltimateBase?: AltimateBaseRegisterFn // altimate_change — see the declaration above }) => { const abort = new AbortController() let sse: AbortController | undefined @@ -194,6 +208,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ event: emitter, fetch: props.fetch ?? fetch, url: props.url, + registerAltimateBase: props.registerAltimateBase, // altimate_change — see the declaration above } }, }) diff --git a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx deleted file mode 100644 index 3772d2c23d..0000000000 --- a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx +++ /dev/null @@ -1,636 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" -import { testRender, useRenderer } from "@opentui/solid" -import { expect, test } from "bun:test" -import { onCleanup, onMount } from "solid-js" -import { mkdir } from "node:fs/promises" -import path from "node:path" -import { createTuiResolvedConfig } from "../../fixture/tui-runtime" -import { TestTuiContexts } from "../../fixture/tui-environment" -import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk" -// altimate_change — fixes #1301 (Codex review round 2, D): the harness now performs REAL -// kv/model.json writes (see `declinedInKv`/`declinedInModel` below), so it needs a per-mount -// isolated state directory — `TestTuiContexts`'s default `state` path is a single fixed -// `/tmp/opencode/state` shared by every test in the process (see `dialog-scan-gate.test.tsx` for -// the same pattern with a real DialogProvider + kv fixture). -import { tmpdir } from "../../fixture/fixture" -import type { OnboardingTelemetryEvent } from "../../../src/context/onboarding-telemetry" - -async function waitUntil(predicate: () => boolean, timeout = 2_000) { - const started = Date.now() - while (!predicate()) { - if (Date.now() - started > timeout) throw new Error("timed out waiting for condition") - await Bun.sleep(10) - } -} - -async function mountConfirm( - input: { - registration?: - | { ok: true } - | { ok: false; result: "rate_limited" | "unavailable" | "network" | "error"; message: string } - | (() => Promise< - { ok: true } | { ok: false; result: "rate_limited" | "unavailable" | "network" | "error"; message: string } - >) - modelAvailable?: boolean - origin?: "welcome" | "migration" - // altimate_change start — fixes #1301: broadened migration eligibility test support - // Whether the harness marks first-run active before mounting. Every prior test relied on this - // always being true; migration's telemetry must now also fire when it is NOT (migration is - // reachable on a returning launch, which is never "first run"). - markFirstRun?: boolean - // The free public Zen model presented as the (sole, when `modelAvailable: false`) opencode - // provider model — defaults to the retired Big Pickle id so every existing test is unaffected. - // Swap it to prove the migration copy names whichever free model is actually current. - zenModel?: { id: string; name: string; family?: string } - // altimate_change end - } = {}, -) { - const [ - { DialogProvider, useDialog }, - { - DialogAltimateBaseConfirm, - ALTIMATE_BASE_DISCLOSURE, - resetSetupComplete, - markFirstRunActive, - useSetupComplete, - }, - { OnboardingTelemetryProvider }, - { ArgsProvider }, - { KVProvider, useKV }, - { ThemeProvider }, - { TuiConfigProvider }, - { ToastProvider }, - { SDKProvider }, - { AltimateBaseConsentProvider }, - { ProjectProvider }, - { SyncProvider }, - // altimate_change — fixes #1301 (Codex review round 2, D): `useLocal`/`ALTIMATE_BASE_MIGRATION_DECLINED_KEY` - // let the harness assert the ACTUAL persisted decline state (kv + model.json) an app.tsx - // `onDecline` would produce, instead of only whether a mock callback was invoked. - { LocalProvider, useLocal, ALTIMATE_BASE_MIGRATION_DECLINED_KEY }, - { OpencodeKeymapProvider, registerOpencodeKeymap }, - { ExitProvider }, - { RouteProvider }, - ] = await Promise.all([ - import("../../../src/ui/dialog"), - import("../../../src/component/altimate-onboarding"), - import("../../../src/context/onboarding-telemetry"), - import("../../../src/context/args"), - import("../../../src/context/kv"), - import("../../../src/context/theme"), - import("../../../src/config"), - import("../../../src/ui/toast"), - import("../../../src/context/sdk"), - // altimate_change — the registration operation is provided through this dedicated context, - // not through SDKProvider; see context/altimate-base-consent.tsx. - import("../../../src/context/altimate-base-consent"), - import("../../../src/context/project"), - import("../../../src/context/sync"), - import("../../../src/context/local"), - import("../../../src/keymap"), - import("../../../src/context/exit"), - import("../../../src/context/route"), - ]) - - // altimate_change start — fixes #1301 (Codex review round 2, D): isolated per-mount state dir - // — see the `tmpdir` import comment above. `kv.json` is pre-seeded (matching - // `dialog-scan-gate.test.tsx`) purely to avoid the harmless-but-noisy "Failed to read KV state" - // console error `kv.tsx` logs on a missing file; `model.json`'s reader doesn't log at all, so - // it isn't pre-seeded. - const tmp = await tmpdir() - const state = path.join(tmp.path, "state") - await mkdir(state, { recursive: true }) - await Bun.write(path.join(state, "kv.json"), "{}") - // altimate_change end - - resetSetupComplete() - // altimate_change — fixes #1301: default preserved (every prior test relies on it), but a test - // can now mount without first-run active to prove migration telemetry fires regardless. - if (input.markFirstRun ?? true) markFirstRunActive() - const events: OnboardingTelemetryEvent[] = [] - const registrations: true[] = [] - const declines: true[] = [] - let replaceDialog = () => false - // altimate_change — fixes #1301 (Codex review round 2, D): populated inside `OpenConfirm` below - // (rendered inside `KVProvider`/`LocalProvider`), so the harness can assert the actual - // persisted decline state, not only whether a mock callback fired. - let declinedInKv = () => false - let declinedInModel = () => false - // altimate_change — PR #1302 review (CodeRabbit + cubic "Await the atomic writes before - // disposing the state directory"): populated inside `OpenConfirm` below. - let waitForPersistence: () => Promise = () => Promise.resolve() - const model = { - id: "altimate-base", - providerID: "altimate-free", - name: "Altimate Base", - family: "altimate", - status: "active", - capabilities: {}, - cost: { input: 0, output: 0 }, - limit: { context: 65_536, output: 4_096 }, - } - const provider = { id: "altimate-free", name: "Altimate", models: { "altimate-base": model }, env: [] } - // altimate_change — fixes #1301: the opencode-provider free model defaults to the retired Big - // Pickle id (unchanged for every existing test) but can be swapped to any other free Zen model. - const zenModel = input.zenModel ?? { id: "big-pickle", name: "Big Pickle", family: "glm" } - const bigPickle = { - ...model, - id: zenModel.id, - providerID: "opencode", - name: zenModel.name, - family: zenModel.family ?? "opencode", - } - const openCodeProvider = { id: "opencode", name: "Legacy Zen", models: { [zenModel.id]: bigPickle }, env: [] } - const inner = createFetch((url) => { - if (url.pathname === "/instance/dispose") return json({}) - if (url.pathname === "/config/providers") { - return json({ - providers: input.modelAvailable === false ? [openCodeProvider] : [provider, openCodeProvider], - default: {}, - }) - } - if (url.pathname === "/provider") { - return json({ - all: [provider, openCodeProvider], - default: {}, - connected: input.modelAvailable === false ? ["opencode"] : ["altimate-free", "opencode"], - }) - } - return undefined - }) - const source = createEventSource() - - function Harness() { - const renderer = useRenderer() - const keymap = createDefaultOpenTuiKeymap(renderer) - const resolvedConfig = createTuiResolvedConfig({ leader_timeout: 1_000 }) - const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig) - onCleanup(off) - - function OpenConfirm() { - const dialog = useDialog() - // altimate_change start — fixes #1301 (Codex review round 2, D): mirror app.tsx's REAL - // migration `onDecline` (kv.set + local.model.declineManagedBaseDefault()) instead of only - // recording that the callback fired, so tests can assert the actual persisted state a real - // launch would see — not just that a mock array grew. - const kv = useKV() - const local = useLocal() - declinedInKv = () => kv.get(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false) - declinedInModel = () => local.model.declinedManagedBaseDefault() - // altimate_change — PR #1302 review (CodeRabbit + cubic; Codex review round 2, P2): both - // real write queues, so `cleanup()` can await the actual persistence instead of a fixed - // delay before disposing the tmp state directory. Safe to `Promise.all` (rather than - // `allSettled`) here: `local.model.persisted()` now internally awaits `allSettled` over - // EVERY outstanding model write (not just the latest — a single reassigned promise - // previously dropped earlier in-flight writes from what this waited for), and `kv.flush()` - // returns kv.tsx's own queued write chain, which already swallows its own errors - // internally — neither can reject. - waitForPersistence = () => Promise.all([local.model.persisted(), kv.flush()]) - replaceDialog = () => dialog.replace(() => Session list replacement) - onMount(() => - dialog.replace(() => ( - { - declines.push(true) - kv.set(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, true) - local.model.declineManagedBaseDefault() - }} - /> - )), - ) - // altimate_change end - return null - } - - return ( - - {}}> - - - - - - - - { - registrations.push(true) - return typeof input.registration === "function" - ? input.registration() - : (input.registration ?? { ok: true }) - }} - > - - - - - { - events.push(event) - }} - > - - - - - - - - - - - - - - - - - - - ) - } - - const app = await testRender(() => , { kittyKeyboard: true }) - await app.renderOnce() - await Bun.sleep(50) - await app.renderOnce() - return { - app, - events, - disclosure: ALTIMATE_BASE_DISCLOSURE, - setupComplete: useSetupComplete(), - registrations: () => registrations, - declines: () => declines, - // altimate_change — fixes #1301 (Codex review round 2, D): actual persisted decline state. - declinedInKv: () => declinedInKv(), - declinedInModel: () => declinedInModel(), - replaceDialog: () => replaceDialog(), - async cleanup() { - app.renderer.destroy() - resetSetupComplete() - // altimate_change — PR #1302 review (CodeRabbit + cubic "Await the atomic writes before - // disposing the state directory"): `local.model`'s `save()` and `kv.tsx`'s `set()` each now - // expose their in-flight write (`persisted()`/`flush()` — see `waitForPersistence` above). - // A decline persisted just before this runs previously still had its write in flight when a - // fixed `Bun.sleep(20)` disposed the tmp dir out from under it (an EINVAL/ENOENT from - // `writeJsonAtomic`, surfacing as an unhandled rejection misattributed to whichever test - // happened to be running when it resolved). Actually awaiting the writes removes the guess. - await waitForPersistence().catch(() => {}) - await tmp[Symbol.asyncDispose]() - }, - } -} - -test.serial("Altimate Base shows the privacy disclosure before registration and defaults to No", async () => { - const confirm = await mountConfirm() - try { - const frame = confirm.app.captureCharFrame() - const flat = frame.replace(/\s+/g, " ") - expect(confirm.disclosure).toContain("Requests and responses may be logged and used") - // The persistent per-install-id linkage line is intentionally not in the gate (it lives in docs). - expect(confirm.disclosure).not.toContain("per-installation identifier") - expect(frame).toContain("Use Altimate Base?") - expect(flat).toContain("Requests and responses may be logged and used") - // Both options are always visible. - expect(frame).toContain("No — pick something else") - expect(frame).toContain("Yes — use Altimate Base") - // Assert WHICH option is default, not merely that the word appears. The previous version of - // this test checked only `toContain("(default)")` and the presence of the No label, so it - // passed both before and after the default was inverted — it asserted its own name away. - // The cursor glyph marks the selected row, and Return runs it. - expect(flat).toContain("› No — pick something else (default)") - expect(flat).not.toContain("› Yes — use Altimate Base") - expect(confirm.registrations()).toHaveLength(0) - expect(confirm.events).toEqual([{ name: "altimate_base_confirm_shown", origin: "welcome" }]) - } finally { - await confirm.cleanup() - } -}) - -test.serial("Return declines, because No is the default — it must never register", async () => { - const confirm = await mountConfirm() - try { - // NOTE: KeyInput is `string | keyof typeof KeyCodes`, so a lowercase "return" would be sent as - // the literal characters r,e,t,u,r,n. The Enter key is the uppercase KeyCodes name — nothing - // else in this suite exercises it, so this path was previously unverified in either direction. - confirm.app.mockInput.pressKey("RETURN") - await waitUntil(() => confirm.events.some((event) => event.name === "altimate_base_choice")) - expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "cancel", origin: "welcome" }) - // The property that matters: an unread Return cannot opt the installation into request logging. - expect(confirm.registrations()).toHaveLength(0) - } finally { - await confirm.cleanup() - } -}) - -test.serial( - // altimate_change — fixes #1301: migration telemetry is no longer suppressed — see the "even - // when first-run is not active" variant below for why that matters. - "the migration disclosure reuses consent, reports its own telemetry, and routes explicit No to the picker", - async () => { - const confirm = await mountConfirm({ origin: "migration" }) - try { - const frame = confirm.app.captureCharFrame() - expect(frame).toContain("No — pick something else") - expect(frame.replace(/\s+/g, " ")).toContain("Requests and responses may be logged and used") - expect(confirm.events).toEqual([{ name: "altimate_base_confirm_shown", origin: "migration" }]) - - confirm.app.mockInput.pressKey("n") - await waitUntil(() => confirm.declines().length === 1) - expect(confirm.registrations()).toHaveLength(0) - // altimate_change — fixes #1301 (Codex review round 2, D): assert the ACTUAL persisted - // state (kv + model.json, both through the real `local.model.declineManagedBaseDefault()`), - // not only that a mock callback was invoked. - expect(confirm.declinedInKv()).toBe(true) - expect(confirm.declinedInModel()).toBe(true) - expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "cancel", origin: "migration" }) - // altimate_change — "No — pick something else" must actually route somewhere: Big Pickle is - // retired, so declining the migration prompt lands the user in the curated picker instead of - // silently leaving the dialog cleared (the label used to promise a re-pick that never - // happened). - await waitUntil(() => confirm.events.some((event) => event.name === "model_picker_shown")) - expect(confirm.events).toContainEqual({ name: "model_picker_shown", trigger: "altimate_base_back" }) - await confirm.app.renderOnce() - expect(confirm.app.captureCharFrame()).toContain("Altimate LLM Gateway") - } finally { - await confirm.cleanup() - } - }, -) - -test.serial( - "migration telemetry fires even when first-run is not active, unlike welcome/model", - async () => { - // altimate_change — fixes #1301: migration is reachable on a returning (non-first-run) - // launch — the whole point of the fix — so its telemetry must not depend on - // `firstRunActive()` the way "welcome"/"model" origins' does. - const confirm = await mountConfirm({ origin: "migration", markFirstRun: false }) - try { - expect(confirm.events).toEqual([{ name: "altimate_base_confirm_shown", origin: "migration" }]) - } finally { - await confirm.cleanup() - } - }, -) - -test.serial( - "Escape on the migration disclosure persists the decline and opens the welcome picker, not a bare dismissal", - async () => { - // altimate_change — fixes #1301: DialogProvider's keymap binding closes the dialog BEFORE the - // component's own `useKeyboard` ever sees Escape/Ctrl-C, so this must route through the close - // guard — see `releaseCloseGuard` in altimate-onboarding.tsx. - const confirm = await mountConfirm({ origin: "migration" }) - try { - // `pressKey("escape")` (lowercase) types the literal LETTERS e-s-c-a-p-e — it is not the - // Escape key (see `KeyCodes.ESCAPE`/`resolveKeyInput` in @opentui/core's mock-keys helper). - // `pressEscape()` sends the actual key. - confirm.app.mockInput.pressEscape() - await waitUntil(() => confirm.declines().length === 1) - expect(confirm.registrations()).toHaveLength(0) - // altimate_change — fixes #1301 (Codex review round 2, D): the actual persisted state a - // real launch's `Provider.defaultModel()`/ACP would read, not only the mock callback. - expect(confirm.declinedInKv()).toBe(true) - expect(confirm.declinedInModel()).toBe(true) - await waitUntil(() => confirm.events.some((event) => event.name === "model_picker_shown")) - await confirm.app.renderOnce() - const frame = confirm.app.captureCharFrame() - expect(frame).toContain("Select a provider") - expect(frame).toContain("Altimate LLM Gateway") - } finally { - await confirm.cleanup() - } - }, -) - -test.serial( - "Ctrl+C on the migration disclosure closes it without deciding anything, unlike Escape", - async () => { - // altimate_change — PR review round 3: Ctrl+C is a "get me out" gesture (quitting the app), - // not "I decline Altimate Base specifically" the way Escape on THIS dialog is. Before - // `dialog.tsx` gave it its own "interrupt" reason, Ctrl+C was treated identically to Escape - // ("dismiss"), so quitting with Ctrl+C twice while this dialog was open queued `no()` (persist - // + picker takeover) on the FIRST Ctrl+C, recording a refusal the user never made. - const confirm = await mountConfirm({ origin: "migration" }) - try { - // The established way this suite sends a real Ctrl+C through the keymap (see the - // busy-state test below) — not `pressKey("c")` alone, which is just the letter "c". - confirm.app.mockInput.pressKey("c", { ctrl: true }) - await confirm.app.renderOnce() - // The dialog closes (it was the only entry on the stack) without being replaced by - // anything — no forced picker takeover, unlike Escape. - expect(confirm.app.captureCharFrame()).not.toContain("Use Altimate Base?") - expect(confirm.declines()).toHaveLength(0) - expect(confirm.registrations()).toHaveLength(0) - expect(confirm.events.some((event) => event.name === "altimate_base_choice")).toBe(false) - expect(confirm.events.some((event) => event.name === "model_picker_shown")).toBe(false) - // altimate_change — the actual persisted state, which is what a real headless/server - // launch's `Provider.defaultModel()`/ACP would read — not only the mock callback. - expect(confirm.declinedInKv()).toBe(false) - expect(confirm.declinedInModel()).toBe(false) - } finally { - await confirm.cleanup() - } - }, -) - -test.serial( - "the visible mouse esc label on the migration disclosure persists the decline and opens the picker, same as keyboard Escape", - async () => { - // altimate_change — fixes #1301 (Codex review round 2, P2): this visible label used to call a - // bare `dialog.clear()` for every origin, including migration — so clicking it silently - // skipped both the decline persistence AND the picker takeover that keyboard Escape produces, - // leaving a later headless/server launch free to pick Base again after a partial - // registration. It must now behave exactly like Escape for `origin === "migration"`. - const confirm = await mountConfirm({ origin: "migration" }) - try { - const frame = confirm.app.captureCharFrame() - expect(frame).toContain("esc") - // The "esc" label sits on the same row as the dialog title, near its right edge. - const escRow = frame.split("\n").findIndex((line) => line.includes("Use Altimate Base?")) - expect(escRow).toBeGreaterThanOrEqual(0) - const escColumn = frame.split("\n")[escRow].indexOf("esc") - // altimate_change — PR #1302 review (cubic P3): if the label ever moves off this row, - // `indexOf` returns -1 and the click silently misses — fail here with the real cause - // instead of a generic "timed out waiting for condition" from the assertion below. - expect(escColumn).toBeGreaterThanOrEqual(0) - await confirm.app.mockMouse.click(escColumn, escRow) - await waitUntil(() => confirm.declines().length === 1) - expect(confirm.registrations()).toHaveLength(0) - expect(confirm.declinedInKv()).toBe(true) - expect(confirm.declinedInModel()).toBe(true) - await waitUntil(() => confirm.events.some((event) => event.name === "model_picker_shown")) - await confirm.app.renderOnce() - expect(confirm.app.captureCharFrame()).toContain("Select a provider") - } finally { - await confirm.cleanup() - } - }, -) - -test.serial( - "a programmatic replace of the migration dialog succeeds and does not persist a decline", - async () => { - // altimate_change — fixes #1301 (Codex review round 2, P2): an unrelated feature (command - // palette, session list) replacing the dialog stack while the migration disclosure is open is - // not the user declining Altimate Base — it never dismissed THIS dialog, unlike keyboard - // Escape/Ctrl+C, the backdrop click (`dialog.tsx`'s `dismiss()`), or the visible mouse "esc" - // label, all of which now route through `no()` (see the tests above). Before the original - // fix, the close guard queued `no()` for every guarded close, including this one. - const confirm = await mountConfirm({ origin: "migration" }) - try { - expect(confirm.replaceDialog()).toBe(true) - await confirm.app.renderOnce() - expect(confirm.app.captureCharFrame()).toContain("Session list replacement") - expect(confirm.declines()).toHaveLength(0) - // altimate_change — fixes #1301 (Codex review round 2, D): the actual persisted state, - // which is what a real headless/server launch would read — not only the mock callback. - expect(confirm.declinedInKv()).toBe(false) - expect(confirm.declinedInModel()).toBe(false) - expect(confirm.registrations()).toHaveLength(0) - expect(confirm.events.some((event) => event.name === "model_picker_shown")).toBe(false) - } finally { - await confirm.cleanup() - } - }, -) - -test.serial( - "the migration copy names the current free model instead of always naming Big Pickle", - async () => { - // altimate_change — fixes #1301: migration now also covers implicit free public Zen - // defaults besides Big Pickle, so the copy must say which model is actually being moved. - // `modelAvailable: false` makes this swapped-in model the ONLY (hence current) provider - // entry, sidestepping any ambiguity in which provider the fallback picks first. - const confirm = await mountConfirm({ - origin: "migration", - modelAvailable: false, - zenModel: { id: "nemotron-3.5-lightning-free", name: "Nemotron 3.5 Lightning (Free)" }, - }) - try { - const flat = confirm.app.captureCharFrame().replace(/\s+/g, " ") - expect(flat).toContain( - "Your default model, Nemotron 3.5 Lightning (Free), is a public free model. Altimate Base is the free model Altimate hosts for data work.", - ) - expect(flat).not.toContain("Big Pickle has been retired.") - } finally { - await confirm.cleanup() - } - }, -) - -test.serial("declining Altimate Base makes no registration request, and Big Pickle is not offered as a new pick", async () => { - const confirm = await mountConfirm() - try { - confirm.app.mockInput.pressKey("n") - await waitUntil(() => confirm.events.some((event) => event.name === "altimate_base_choice")) - expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "cancel", origin: "welcome" }) - expect(confirm.registrations()).toHaveLength(0) - confirm.app.mockInput.pressKey("/") - await confirm.app.renderOnce() - // altimate_change — Big Pickle is retired as a NEW selectable option: the full catalog opened - // via search must not offer it, even though the fixture still wires up an "opencode" provider - // (used elsewhere to prove the migration path still recognizes a legacy selection). - expect(confirm.app.captureCharFrame()).not.toContain("Big Pickle") - expect(confirm.registrations()).toHaveLength(0) - } finally { - await confirm.cleanup() - } -}) - -test.serial("accepting registers once through the private host operation and completes setup", async () => { - const confirm = await mountConfirm() - try { - confirm.app.mockInput.pressKey("y") - await waitUntil(() => confirm.setupComplete()) - expect(confirm.registrations()).toHaveLength(1) - expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "accept", origin: "welcome" }) - expect(confirm.events).toContainEqual({ - name: "altimate_base_register_result", - result: "success", - origin: "welcome", - }) - expect(confirm.events.filter((event) => event.name === "altimate_base_choice")).toHaveLength(1) - } finally { - await confirm.cleanup() - } -}) - -test.serial("registration without a usable model remains incomplete and visibly recoverable", async () => { - const confirm = await mountConfirm({ modelAvailable: false }) - try { - confirm.app.mockInput.pressKey("y") - await waitUntil(() => confirm.events.some((event) => event.name === "altimate_base_register_result")) - await Bun.sleep(50) - await confirm.app.renderOnce() - expect(confirm.setupComplete()).toBe(false) - expect(confirm.app.captureCharFrame()).toContain("ready yet. Try again") - } finally { - await confirm.cleanup() - } -}) - -test.serial("rate-limited registration stays recoverable and reports a typed outcome", async () => { - const message = "Too many Altimate Base registrations from this network right now. Try again later." - const confirm = await mountConfirm({ - registration: { ok: false, result: "rate_limited", message }, - }) - try { - confirm.app.mockInput.pressKey("y") - await waitUntil(() => confirm.events.some((event) => event.name === "altimate_base_register_result")) - await confirm.app.renderOnce() - expect(confirm.setupComplete()).toBe(false) - expect(confirm.registrations()).toHaveLength(1) - expect(confirm.events).toContainEqual({ - name: "altimate_base_register_result", - result: "rate_limited", - origin: "welcome", - }) - expect(confirm.app.captureCharFrame()).toContain("Too many Altimate Base") - } finally { - await confirm.cleanup() - } -}) - -test.serial("dismissal keys and backdrop clicks are ignored while registration is in flight", async () => { - let finish!: (result: { ok: true }) => void - let started!: () => void - const began = new Promise((resolve) => { - started = resolve - }) - const pending = new Promise<{ ok: true }>((resolve) => { - finish = resolve - }) - const confirm = await mountConfirm({ - registration: async () => { - started() - return pending - }, - }) - try { - confirm.app.mockInput.pressKey("y") - await began - expect(confirm.replaceDialog()).toBe(false) - await confirm.app.renderOnce() - expect(confirm.app.captureCharFrame()).not.toContain("Session list replacement") - // altimate_change — fixes #1301: `pressKey("escape")` (lowercase) sends the literal letters - // e-s-c-a-p-e, not the Escape key (see the comment on the migration Escape test below); this - // assertion happened to hold either way since typing those letters while busy is also a - // no-op, but `pressEscape()` is what actually exercises the key this test is named for. - confirm.app.mockInput.pressEscape() - await confirm.app.renderOnce() - expect(confirm.app.captureCharFrame()).toContain("Setting up…") - confirm.app.mockInput.pressKey("c", { ctrl: true }) - await confirm.app.renderOnce() - expect(confirm.app.captureCharFrame()).toContain("Setting up…") - await confirm.app.mockMouse.click(0, 0) - await confirm.app.renderOnce() - expect(confirm.app.captureCharFrame()).toContain("Setting up…") - - finish({ ok: true }) - await waitUntil(() => confirm.setupComplete()) - } finally { - await confirm.cleanup() - } -}) diff --git a/packages/tui/test/context/altimate-base-consent.test.tsx b/packages/tui/test/context/altimate-base-consent.test.tsx deleted file mode 100644 index 044f151de4..0000000000 --- a/packages/tui/test/context/altimate-base-consent.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -// altimate_change start — proves the consent-gated Altimate Base registration operation cannot be -// reached through the PUBLIC SDK context (`@opencode-ai/tui/context/sdk`'s `useSDK()`), only -// through the dedicated `context/altimate-base-consent.tsx` module — which is not listed in -// package.json's `exports` map and so cannot be imported from outside this package. This closes -// the gap where any in-process consumer of `useSDK()`, including a plugin-rendered component, -// could call `sdk.altimateBaseRegistration()` directly and mint a Base install identifier without -// the disclosure dialog ever being shown. -import { testRender } from "@opentui/solid" -import { expect, test } from "bun:test" -import { SDKProvider, useSDK } from "../../src/context/sdk" -import { AltimateBaseConsentProvider, useAltimateBaseConsent } from "../../src/context/altimate-base-consent" -import { createFetch, eventSource } from "../fixture/tui-sdk" - -test("the registration operation is not reachable through the public SDK context", async () => { - const calls: true[] = [] - const register = async () => { - calls.push(true) - return { ok: true as const } - } - - let sdk: ReturnType | undefined - let consent: ReturnType | undefined - - function Probe() { - sdk = useSDK() - consent = useAltimateBaseConsent() - return null - } - - const app = await testRender( - () => ( - - - - - - ), - { kittyKeyboard: true }, - ) - try { - await app.renderOnce() - - // The public SDK context object carries no such property at all, forged or otherwise — a - // plugin that only imports `@opencode-ai/tui/context/sdk` has no way to reach registration. - expect(sdk).toBeDefined() - expect("altimateBaseRegistration" in (sdk as object)).toBe(false) - expect((sdk as Record)["altimateBaseRegistration"]).toBeUndefined() - - // The dedicated context is how the legitimate consent-accept flow reaches the same operation. - expect(consent).toBe(register) - expect(calls).toHaveLength(0) - await consent?.() - expect(calls).toHaveLength(1) - } finally { - app.renderer.destroy() - } -}) - -test("useAltimateBaseConsent is undefined when no host injected a registration operation", async () => { - let consent: ReturnType | undefined | "not-called" = "not-called" - - function Probe() { - consent = useAltimateBaseConsent() - return null - } - - const app = await testRender( - () => ( - - - - ), - { kittyKeyboard: true }, - ) - try { - await app.renderOnce() - expect(consent).toBeUndefined() - } finally { - app.renderer.destroy() - } -}) -// altimate_change end From 2d58fb9d1105b906177ae0bf4d7b254c71e83341 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 22 Sep 2026 20:14:39 -0700 Subject: [PATCH 04/27] test: cover the no-dialog Altimate Base picker flow and the disclosure notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two automated tests flagged as missing after the consent-dialog removal — the user-visible promises of this PR: - `test/component/select-altimate-base.test.ts`: unit-tests `selectAltimateBase()` directly (with hand-built fakes for its collaborators, since `selectModel()`'s underlying `local.model.set()` is agent-scoped and the package's component-mount fixtures don't set one up). Covers: a successful registration disposes the instance, refreshes provider state, selects `altimate-free/altimate-base`, and never opens a dialog; the same via the attached-TUI HTTP fallback (no host-injected `registerAltimateBase`); a registration failure shows an error toast and leaves the model/dialog untouched; and a registration that reports success but never actually surfaces the model in the refreshed provider list also fails closed with a toast, not a partial selection. - `test/component/altimate-base-disclosure-notice.test.tsx`: mounts the real provider stack with a single `altimate-free` provider (so `fallbackModel()` resolves to Base with nothing else to configure) and drives `useAltimateBaseDisclosureNotice()`'s kv-persisted "already shown" flag the way a real restart would (a pre-seeded `kv.json`). Confirms the notice shows exactly once — the first time Base becomes the active model — and does not reappear on a subsequent launch once the flag is set. Exported `ALTIMATE_BASE_DISCLOSURE_SHOWN_KEY` from `altimate-onboarding.tsx` (previously module-local) so the second test can seed it, the same way `ALTIMATE_BASE_MIGRATION_DECLINED_KEY` is already exported from `context/local.tsx` for the identical reason. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tui/src/component/altimate-onboarding.tsx | 6 +- .../altimate-base-disclosure-notice.test.tsx | 190 ++++++++++++++++++ .../component/select-altimate-base.test.ts | 173 ++++++++++++++++ 3 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 packages/tui/test/component/altimate-base-disclosure-notice.test.tsx create mode 100644 packages/tui/test/component/select-altimate-base.test.ts diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index ca9a1a7576..cf2d6120e1 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -458,7 +458,11 @@ async function registerAltimateBase(sdk: ReturnType): Promise boolean, timeout = 2_000) { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(5) + } +} + +const baseModel = { + id: "altimate-base", + providerID: "altimate-free", + name: "Altimate Base", + family: "altimate", + status: "active", + capabilities: {}, + cost: { input: 0, output: 0 }, + limit: { context: 131_072, output: 65_536 }, +} +const baseProvider = { id: "altimate-free", name: "Altimate", models: { "altimate-base": baseModel }, env: [] } + +async function mount(options: { preSeedShown: boolean }) { + const [ + { KVProvider }, + { LocalProvider, useLocal }, + { ArgsProvider }, + { ThemeProvider }, + { ToastProvider, useToast }, + { SDKProvider }, + { ProjectProvider }, + { SyncProvider }, + { RouteProvider }, + { ExitProvider }, + { TuiConfigProvider }, + ] = await Promise.all([ + import("../../src/context/kv"), + import("../../src/context/local"), + import("../../src/context/args"), + import("../../src/context/theme"), + import("../../src/ui/toast"), + import("../../src/context/sdk"), + import("../../src/context/project"), + import("../../src/context/sync"), + import("../../src/context/route"), + import("../../src/context/exit"), + import("../../src/config"), + ]) + const { createEffect } = await import("solid-js") + + const tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + if (options.preSeedShown) { + // Simulates a previous launch already having shown the notice — the state a real restart + // would find on disk. + await Bun.write(path.join(state, "kv.json"), JSON.stringify({ [ALTIMATE_BASE_DISCLOSURE_SHOWN_KEY]: true })) + } + + const inner = createFetch((url) => { + if (url.pathname === "/config/providers") return json({ providers: [baseProvider], default: {} }) + if (url.pathname === "/provider") return json({ all: [baseProvider], default: {}, connected: [] }) + if (url.pathname === "/agent") return json([]) + return undefined + }) + const source = createEventSource() + + let localAccessor: ReturnType | undefined + let toastAccessor: ReturnType | undefined + const shownMessages: string[] = [] + function Probe() { + localAccessor = useLocal() + toastAccessor = useToast() + useAltimateBaseDisclosureNotice() + createEffect(() => { + const message = toastAccessor?.currentToast?.message + if (message) shownMessages.push(message) + }) + return null + } + + const app = await testRender(() => ( + + {}}> + + + + + + + + + + + + + + + + + + + + + + + + )) + await app.renderOnce() + + return { + async waitForBaseActive() { + await waitUntil(() => localAccessor?.model.ready === true) + await waitUntil(() => localAccessor?.model.current()?.modelID === "altimate-base") + }, + shownMessages, + get currentToast() { + return toastAccessor?.currentToast + }, + async cleanup() { + app.renderer.destroy() + await tmp[Symbol.asyncDispose]() + }, + } +} + +/** Isolates kv.tsx's Flock lock (keyed off Global.Path.state) the same way + * context/cycle-stability.test.tsx does, so it never touches the real developer state dir. */ +async function withIsolatedStateHome(fn: () => Promise) { + const original = process.env.OPENCODE_TEST_STATE_HOME + const isolated = await tmpdir() + process.env.OPENCODE_TEST_STATE_HOME = isolated.path + try { + await fn() + } finally { + if (original === undefined) delete process.env.OPENCODE_TEST_STATE_HOME + else process.env.OPENCODE_TEST_STATE_HOME = original + await isolated[Symbol.asyncDispose]() + } +} + +test("shows once, the first time Base becomes the active model", async () => { + await withIsolatedStateHome(async () => { + const mounted = await mount({ preSeedShown: false }) + try { + await mounted.waitForBaseActive() + await waitUntil(() => mounted.currentToast?.message === ALTIMATE_BASE_DISCLOSURE) + expect(mounted.currentToast).toMatchObject({ variant: "info", message: ALTIMATE_BASE_DISCLOSURE }) + expect(mounted.shownMessages).toEqual([ALTIMATE_BASE_DISCLOSURE]) + } finally { + await mounted.cleanup() + } + }) +}) + +test("does not show again after a restart (kv flag already set)", async () => { + await withIsolatedStateHome(async () => { + const mounted = await mount({ preSeedShown: true }) + try { + await mounted.waitForBaseActive() + // Give the notice effect a real chance to fire before asserting its absence. + await Bun.sleep(150) + expect(mounted.currentToast).toBeNull() + expect(mounted.shownMessages).toEqual([]) + } finally { + await mounted.cleanup() + } + }) +}) +// altimate_change end diff --git a/packages/tui/test/component/select-altimate-base.test.ts b/packages/tui/test/component/select-altimate-base.test.ts new file mode 100644 index 0000000000..288c637be0 --- /dev/null +++ b/packages/tui/test/component/select-altimate-base.test.ts @@ -0,0 +1,173 @@ +// altimate_change start — coverage for the no-dialog Altimate Base picker flow (replaces +// DialogAltimateBaseConfirm). Exercises `selectAltimateBase()` directly with hand-built fakes for +// its collaborators, rather than mounting the full picker component tree: the pickers all funnel +// through this one shared function (dialog-provider.tsx, dialog-model.tsx, the welcome picker in +// this same file), so testing it here covers every call site's outcome without needing an `agent` +// in the render harness (selectModel()'s underlying local.model.set() is agent-scoped, which the +// component-mount fixtures used elsewhere in this package don't set up). +import { describe, expect, test } from "bun:test" +import type { useSDK } from "../../src/context/sdk" +import type { useSync } from "../../src/context/sync" +import type { useLocal } from "../../src/context/local" +import type { useToast } from "../../src/ui/toast" +import type { useDialog } from "../../src/ui/dialog" +import { selectAltimateBase } from "../../src/component/altimate-onboarding" + +function fakeCollaborators(options: { + registerAltimateBase?: () => Promise<{ ok: true } | { ok: false; result: "network" | "error"; message: string }> + fetchImpl?: typeof fetch + /** Whether the provider list gains the Base model once bootstrap() runs — the real flow's + * "the credential was minted, but the catalogue hasn't caught up yet" edge case sets this to + * false. */ + becomesAvailableAfterBootstrap?: boolean +}) { + const modelSetCalls: unknown[] = [] + let dialogClearCount = 0 + let dialogReplaceCount = 0 + const toastCalls: { variant: string; message: string }[] = [] + let disposed = false + let bootstrapped = false + const becomesAvailable = options.becomesAvailableAfterBootstrap ?? true + const providerState: { id: string; models: Record }[] = [] + + const sdk = { + client: { + instance: { + dispose: async () => { + disposed = true + }, + }, + }, + fetch: options.fetchImpl ?? (async () => new Response("should not be called", { status: 500 })), + url: "http://test", + registerAltimateBase: options.registerAltimateBase, + } as unknown as ReturnType + + const sync = { + bootstrap: async () => { + bootstrapped = true + if (becomesAvailable) { + providerState.push({ id: "altimate-free", models: { "altimate-base": { id: "altimate-base" } } }) + } + }, + data: { provider: providerState }, + } as unknown as ReturnType + + const local = { + model: { + set: (...args: unknown[]) => { + modelSetCalls.push(args) + }, + }, + } as unknown as ReturnType + + const toast = { + show: (toastOptions: { variant: string; message: string }) => { + toastCalls.push(toastOptions) + }, + } as unknown as ReturnType + + const dialog = { + clear: () => { + dialogClearCount++ + }, + replace: (..._args: unknown[]) => { + dialogReplaceCount++ + return true + }, + } as unknown as ReturnType + + return { + sdk, + sync, + local, + toast, + dialog, + modelSetCalls, + toastCalls, + get disposed() { + return disposed + }, + get bootstrapped() { + return bootstrapped + }, + get dialogClearCount() { + return dialogClearCount + }, + get dialogReplaceCount() { + return dialogReplaceCount + }, + } +} + +describe("selectAltimateBase", () => { + test("registers, refreshes provider state, and selects the model — no dialog is ever opened", async () => { + const fakes = fakeCollaborators({ registerAltimateBase: async () => ({ ok: true }) }) + + const result = await selectAltimateBase(fakes) + + expect(result).toBe(true) + expect(fakes.disposed).toBe(true) + expect(fakes.bootstrapped).toBe(true) + expect(fakes.modelSetCalls).toEqual([ + [{ providerID: "altimate-free", modelID: "altimate-base" }, { recent: true }], + ]) + expect(fakes.dialogClearCount).toBe(1) + // The whole point of this replacement: nothing ever opens a confirm/consent dialog. + expect(fakes.dialogReplaceCount).toBe(0) + expect(fakes.toastCalls).toHaveLength(0) + }) + + test("an attached TUI (no host-injected registerAltimateBase) falls back to the HTTP route", async () => { + const calls: string[] = [] + const fakes = fakeCollaborators({ + registerAltimateBase: undefined, + fetchImpl: (async (input: RequestInfo | URL) => { + calls.push(String(input)) + return Response.json({ ok: true }) + }) as typeof fetch, + }) + + const result = await selectAltimateBase(fakes) + + expect(result).toBe(true) + expect(calls).toEqual(["http://test/altimate/base/register"]) + expect(fakes.modelSetCalls).toHaveLength(1) + expect(fakes.dialogReplaceCount).toBe(0) + }) + + test("a registration failure shows the toast and leaves the model unchanged", async () => { + const fakes = fakeCollaborators({ + registerAltimateBase: async () => ({ ok: false, result: "network", message: "offline" }), + }) + + const result = await selectAltimateBase(fakes) + + expect(result).toBe(false) + expect(fakes.toastCalls).toEqual([{ variant: "error", message: "offline" }]) + // Nothing past the failed register() call ran: no instance disposal, no bootstrap, no selection. + expect(fakes.disposed).toBe(false) + expect(fakes.bootstrapped).toBe(false) + expect(fakes.modelSetCalls).toHaveLength(0) + expect(fakes.dialogClearCount).toBe(0) + expect(fakes.dialogReplaceCount).toBe(0) + }) + + test("a registration that reports ok but never actually surfaces the model shows an error and leaves the model unchanged", async () => { + // registerAltimateBase() can succeed (a credential was minted) while the provider list still + // hasn't caught up — bootstrap() runs, but the catalogue never gains the Base model. + const fakes = fakeCollaborators({ + registerAltimateBase: async () => ({ ok: true }), + becomesAvailableAfterBootstrap: false, + }) + + const result = await selectAltimateBase(fakes) + + expect(result).toBe(false) + expect(fakes.toastCalls).toHaveLength(1) + expect(fakes.toastCalls[0].variant).toBe("error") + expect(fakes.modelSetCalls).toHaveLength(0) + expect(fakes.dialogClearCount).toBe(0) + }) +}) +// altimate_change end From 695bbf60d95d235b0abc207552cf955a3804769a Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 22 Sep 2026 20:39:43 -0700 Subject: [PATCH 05/27] fix: Altimate Base fallback ordering and retry latch Three Codex review findings on the Altimate Base default-no-consent branch: - `fallbackModel()`'s implicit last-resort pick in `local.tsx` used a naive array-order `.find()` instead of mirroring `Provider.defaultModel()`'s ordering, so keyless public Zen could be picked over a registered Base, and Base could beat a provider the user actually connected depending on array position. Extracted `pickImplicitFallbackProvider()` as a pure, directly testable function that mirrors the server's ordering exactly, and added 3 unit tests covering: Base outranking Zen, a credentialed provider outranking Base, and unchanged behavior when Base isn't registered. - `dialog-provider.tsx` and `dialog-model.tsx` set the Altimate Base row's one-shot activation latch before the async `selectAltimateBase()` resolved, and never reset it on failure, permanently bricking the row for the rest of the dialog session after a single failed attempt. Reset the latch when `selectAltimateBase()` returns `false`, and added a full-render retry test confirming a failed selection can be retried and re-fires registration. - `altimate-onboarding.tsx`'s `chooseAltimateBase()` had the same bug, but on the first-run welcome picker: it returned `true` synchronously right after firing the unawaited `selectAltimateBase()` call, so `activateRow()` claimed its one-shot latch before the registration attempt was known to have failed. A failed Base registration then bricked Enter, `/` and mouse-up for the rest of the dialog session on the picker shown to users with no model at all. Reset the latch on failure the same way, keeping the double-input guard intact for the in-flight window, and added a retry test to `dialog-model-welcome.test.tsx` confirming the same row can be retried. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tui/src/component/altimate-onboarding.tsx | 8 +- packages/tui/src/component/dialog-model.tsx | 6 +- .../tui/src/component/dialog-provider.tsx | 6 +- packages/tui/src/context/local.tsx | 31 ++- .../cli/tui/dialog-model-welcome.test.tsx | 95 ++++++++- ...alog-provider-altimate-base-retry.test.tsx | 191 ++++++++++++++++++ packages/tui/test/context/local.test.ts | 40 +++- 7 files changed, 364 insertions(+), 13 deletions(-) create mode 100644 packages/tui/test/component/dialog-provider-altimate-base-retry.test.tsx diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index cf2d6120e1..59acf45047 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -206,7 +206,13 @@ export function DialogModelWelcome(props: { function chooseAltimateBase(): boolean { if (!providers().some((provider) => provider.value === "altimate-free")) return false - void selectAltimateBase({ sdk, sync, local, toast, dialog }) + // altimate_change — a failed selection must not permanently latch the row inert; only a + // SUCCESSFUL selection is meant to be one-shot (it closes the dialog). Returning `true` + // synchronously below keeps `activateRow`'s double-input guard active for the in-flight + // window; this resets it if the attempt turns out to have failed. + selectAltimateBase({ sdk, sync, local, toast, dialog }).then((selected) => { + if (!selected) activated = false + }) return true } diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 0b77406e47..e6a6f8904c 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -197,7 +197,11 @@ export function DialogModel(props: { via_search: props.viaSearch ?? false, }) } - void selectAltimateBase({ sdk, sync, local, toast, dialog }) // altimate_change — no dialog: register then select + // altimate_change — a failed selection must not permanently latch the row inert; + // only a SUCCESSFUL selection is meant to be one-shot (it closes the dialog). + selectAltimateBase({ sdk, sync, local, toast, dialog }).then((selected) => { + if (!selected) activated = false + }) return undefined }, } diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index 7c0f2a0a72..9642f275ee 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -201,7 +201,11 @@ export function createDialogProviderOptions() { via_search: false, }) } - void selectAltimateBase({ sdk, sync, local, toast, dialog }) + // altimate_change — a failed selection must not permanently latch the row inert; + // only a SUCCESSFUL selection is meant to be one-shot (it closes the dialog). + selectAltimateBase({ sdk, sync, local, toast, dialog }).then((selected) => { + if (!selected) altimateBaseActivated = false + }) return } // altimate_change end diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 48c6293e92..def955c7c9 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -129,6 +129,30 @@ export function isPublicZenProvider(provider: { }): boolean { return provider.id === "opencode" && provider.options?.["apiKey"] === "public" && !provider.key } + +/** + * `fallbackModel()`'s IMPLICIT last-resort pick (no persisted history behind it): the first + * allowed candidate that is neither Base nor keyless public Zen with Base available, else Base if + * it's available, else whatever the ordinary scan would have picked (e.g. public Zen, when Base + * isn't registered). Mirrors `Provider.defaultModel()`'s ordering exactly. + * + * A single `.find()` over the provider list in array order used to let Base beat a provider the + * user actually connected whenever Base happened to sort first, and let a keyless public-Zen + * candidate be picked outright (not skipped) even with Base available — both were array-position + * accidents, not a ranking. Extracted as a pure function (rather than inlined in the reactive + * memo) so the ordering itself is directly testable without mounting the whole Local context. + */ +export function pickImplicitFallbackProvider< + T extends { id: string; options?: Record; key?: string }, +>(providers: readonly T[], providerAllowed: (id: string) => boolean, baseAvailable: boolean): T | undefined { + const candidates = providers.filter( + (candidate) => candidate.id !== ALTIMATE_BASE_MODEL.providerID && providerAllowed(candidate.id), + ) + return ( + candidates.find((candidate) => !(baseAvailable && isPublicZenProvider(candidate))) ?? + (baseAvailable ? providers.find((candidate) => candidate.id === ALTIMATE_BASE_MODEL.providerID) : undefined) + ) +} // altimate_change end export function shouldOfferManagedBaseDefault( @@ -633,12 +657,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // Unlike `recent`, this is an IMPLICIT last-resort pick with no history behind it, so it // must honor the full allowlist — not just exclude Altimate Base — or it can land on a // connected provider the project never named either. Base itself is exempt from that - // allowlist check (see the comment above `baseAvailable`). + // allowlist check (see the comment above `baseAvailable`). Ordering is + // `pickImplicitFallbackProvider`'s job — see its declaration above for why. const configuredProviderIDs = Object.keys(sync.data.config.provider ?? {}) const providerAllowed = (id: string) => configuredProviderIDs.length === 0 || configuredProviderIDs.includes(id) - const provider = sync.data.provider.find( - (candidate) => candidate.id === ALTIMATE_BASE_MODEL.providerID || providerAllowed(candidate.id), - ) + const provider = pickImplicitFallbackProvider(sync.data.provider, providerAllowed, baseAvailable) // altimate_change end if (!provider) return undefined const defaultModel = sync.data.provider_default[provider.id] diff --git a/packages/tui/test/cli/tui/dialog-model-welcome.test.tsx b/packages/tui/test/cli/tui/dialog-model-welcome.test.tsx index cfa7a50842..b8e2a58998 100644 --- a/packages/tui/test/cli/tui/dialog-model-welcome.test.tsx +++ b/packages/tui/test/cli/tui/dialog-model-welcome.test.tsx @@ -13,7 +13,10 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { testRender, useRenderer } from "@opentui/solid" import { expect, test } from "bun:test" -import { onCleanup } from "solid-js" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { createEffect, onCleanup } from "solid-js" +import { tmpdir } from "../../fixture/fixture" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TestTuiContexts } from "../../fixture/tui-environment" import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk" @@ -35,7 +38,9 @@ const ALL_PROVIDER_IDS = ["altimate-backend", "anthropic", "openai", "google", " async function mountPicker( trigger?: PickerTrigger, availableProviders: string[] = ALL_PROVIDER_IDS, - { firstRun = true }: { firstRun?: boolean } = {}, + // altimate_change — `registerOutcomes` scripts the Altimate Base register endpoint (consumed + // one outcome per call, "ok" once exhausted) so the retry test below can force a failure. + { firstRun = true, registerOutcomes = [] as Array<"ok" | "error"> }: { firstRun?: boolean; registerOutcomes?: Array<"ok" | "error"> } = {}, ) { const [ { DialogProvider }, @@ -45,7 +50,7 @@ async function mountPicker( { KVProvider }, { ThemeProvider }, { TuiConfigProvider }, - { ToastProvider }, + { ToastProvider, useToast }, { SDKProvider }, { ProjectProvider }, { SyncProvider }, @@ -78,7 +83,25 @@ async function mountPicker( onboarding.resetSetupComplete() if (firstRun) onboarding.markFirstRunActive() + // altimate_change — repo rule: tests must not touch real global state. Without this, every + // test in this file shared the SAME default `/tmp/opencode/state` (TestTuiContexts's hardcoded + // fallback) for both `kv.tsx`'s `kv.json` file (`paths.state`) and the `Flock.withLock` it takes + // while reading/writing that file (keyed off `Global.Path.state`, overridden separately via + // `OPENCODE_TEST_STATE_HOME` — see context/cycle-stability.test.tsx's comment on the same + // isolation) — resource contention across concurrently-running test files/suites. + const tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME + process.env.OPENCODE_TEST_STATE_HOME = tmp.path + const events: OnboardingTelemetryEvent[] = [] + // altimate_change — see `registerOutcomes` above + let registerCallCount = 0 + const outcomes = [...registerOutcomes] + // altimate_change — the retry test below polls this instead of a fixed sleep to know when the + // failed selection's promise chain (register -> toast) has actually settled. + const toastMessages: string[] = [] const calls = createFetch((url) => { if (url.pathname === "/provider") { return Response.json({ @@ -87,10 +110,27 @@ async function mountPicker( connected: [], }) } + if (url.pathname === "/altimate/base/register") { + registerCallCount++ + const outcome = outcomes.shift() ?? "ok" + return Response.json( + outcome === "ok" ? { ok: true } : { ok: false, result: "network", message: "offline" }, + ) + } return undefined }) const source = createEventSource() + // altimate_change — see `toastMessages` above + function ToastProbe() { + const toast = useToast() + createEffect(() => { + const message = toast.currentToast?.message + if (message) toastMessages.push(message) + }) + return null + } + function Harness() { const renderer = useRenderer() const keymap = createDefaultOpenTuiKeymap(renderer) @@ -99,13 +139,14 @@ async function mountPicker( onCleanup(off) return ( - + {}}> + @@ -141,8 +182,20 @@ async function mountPicker( return { app, events, + // altimate_change — see `registerOutcomes` above + get registerCallCount() { + return registerCallCount + }, + // altimate_change — see `toastMessages` above + get toastShown() { + return toastMessages.length > 0 + }, async cleanup() { app.renderer.destroy() + // altimate_change — see the state-isolation comment above + if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME + else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome + await tmp[Symbol.asyncDispose]() }, } } @@ -229,6 +282,40 @@ test("outside a first run the picker records an impression but not a choice", as } }) +// altimate_change start — Codex review finding: `chooseAltimateBase()` returned `true` +// synchronously right after firing the (unawaited) `selectAltimateBase()` call, so `activateRow()` +// claimed the one-shot latch before the registration attempt was known to have failed. On the +// first-run welcome picker — the one shown to users with no model at all — a failed Base +// registration then bricked Enter, `/` and mouse-up for the rest of the dialog session. Confirms +// the fix: the same row can be retried after a failure, and the register attempt actually re-fires. +test("a failed Altimate Base selection on the welcome picker can be retried", async () => { + const picker = await mountPicker("first_run", [...ALL_PROVIDER_IDS, "altimate-free"], { + registerOutcomes: ["error", "ok"], + }) + try { + // Rows: gateway, anthropic, openai, google, Altimate Base, search — four Down presses lands on + // the Base row. + for (let i = 0; i < 4; i++) picker.app.mockInput.pressKey("ARROW_DOWN") + picker.app.mockInput.pressEnter() + await wait(() => picker.registerCallCount === 1) + // `chooseAltimateBase()` fires `selectAltimateBase()` without awaiting it, so the latch reset + // (on the failure branch) lands a beat after the register call itself resolves. Poll for the + // failure toast — the last thing `selectAltimateBase()` does before returning `false` — rather + // than a fixed sleep, so this doesn't race under load. + await wait(() => picker.toastShown) + + // Before the fix, the row's one-shot latch stayed set after the failed attempt above, so this + // second Enter would silently no-op — registerCallCount would stay at 1 forever. + picker.app.mockInput.pressEnter() + await wait(() => picker.registerCallCount === 2) + + expect(picker.registerCallCount).toBe(2) + } finally { + await picker.cleanup() + } +}) +// altimate_change end + test("a row for a provider the server filtered out does not brick the dialog", async () => { // The server filters providers via enabled_providers/disabled_providers while this picker renders // five hardcoded rows. Selecting a filtered-out row used to claim the double-submit latch before diff --git a/packages/tui/test/component/dialog-provider-altimate-base-retry.test.tsx b/packages/tui/test/component/dialog-provider-altimate-base-retry.test.tsx new file mode 100644 index 0000000000..cfbab9d4f6 --- /dev/null +++ b/packages/tui/test/component/dialog-provider-altimate-base-retry.test.tsx @@ -0,0 +1,191 @@ +/** @jsxImportSource @opentui/solid */ +// altimate_change start — Codex review finding: the Altimate Base row's one-shot activation latch +// stayed set forever after a failed selection, permanently bricking that row for the rest of the +// dialog session (the row would silently no-op on every later press). Confirms the fix: the SAME +// row can be selected again after a failure, and the register attempt actually re-fires. +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { testRender, useRenderer } from "@opentui/solid" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { onCleanup } from "solid-js" +import { tmpdir } from "../fixture/fixture" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" +import { createEventSource, createFetch, directory, json } from "../fixture/tui-sdk" + +async function waitUntil(predicate: () => boolean | Promise, timeout = 2_000) { + const started = Date.now() + while (!(await predicate())) { + if (Date.now() - started > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(5) + } +} + +async function mount(registerOutcomes: Array<"ok" | "error">) { + const [ + { createDialogProviderOptions }, + { KVProvider }, + { LocalProvider }, + { ArgsProvider }, + { ThemeProvider }, + { ToastProvider, useToast }, + { SDKProvider }, + { ProjectProvider }, + { SyncProvider }, + { RouteProvider }, + { ExitProvider }, + { TuiConfigProvider }, + { DialogProvider }, + { OpencodeKeymapProvider, registerOpencodeKeymap }, + ] = await Promise.all([ + import("../../src/component/dialog-provider"), + import("../../src/context/kv"), + import("../../src/context/local"), + import("../../src/context/args"), + import("../../src/context/theme"), + import("../../src/ui/toast"), + import("../../src/context/sdk"), + import("../../src/context/project"), + import("../../src/context/sync"), + import("../../src/context/route"), + import("../../src/context/exit"), + import("../../src/config"), + import("../../src/ui/dialog"), + import("../../src/keymap"), + ]) + + // altimate_change — repo rule: tests must not touch real global state. `kv.tsx`'s `kv.json` + // file is isolated via `paths.state` below, but the `Flock.withLock` it takes while + // reading/writing that file is keyed off `Global.Path.state` separately (see + // context/cycle-stability.test.tsx's comment on the same isolation) — without also overriding + // `OPENCODE_TEST_STATE_HOME`, this test's lock would still land in, and contend with, the real + // developer/CI-shared state dir every other unisolated test defaults to. + const tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME + process.env.OPENCODE_TEST_STATE_HOME = tmp.path + + let registerCallCount = 0 + const outcomes = [...registerOutcomes] + const inner = createFetch((url) => { + if (url.pathname === "/provider") { + return json({ + all: [{ id: "altimate-free", name: "Altimate", models: {}, env: [] }], + default: {}, + connected: [], + }) + } + if (url.pathname === "/altimate/base/register") { + registerCallCount++ + const outcome = outcomes.shift() ?? "ok" + return json(outcome === "ok" ? { ok: true } : { ok: false, result: "network", message: "offline" }) + } + return undefined + }) + const source = createEventSource() + + let optionsAccessor: (() => { value: string; onSelect?: () => unknown }[]) | undefined + let toastAccessor: (() => unknown) | undefined + function Probe() { + optionsAccessor = createDialogProviderOptions() + const toast = useToast() + toastAccessor = () => toast.currentToast + return null + } + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const resolvedConfig = createTuiResolvedConfig({ leader_timeout: 1000 }) + const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig) + onCleanup(off) + return ( + + + + + + ) + } + + const app = await testRender(() => ( + + {}}> + + + + + + + + + + + + + + + + + + + + + + + + )) + await app.renderOnce() + // The picker's provider list depends on `sync.bootstrap()`'s fetch round-trip; unlike the + // signals other tests in this package poll (written synchronously inside onMount/effects), + // this one only becomes visible to a fresh read after an explicit render pump. + await waitUntil(async () => { + await app.renderOnce() + return optionsAccessor !== undefined && optionsAccessor().some((option) => option.value === "altimate-free") + }) + + return { + getBaseRow: () => optionsAccessor!().find((option) => option.value === "altimate-free")!, + get registerCallCount() { + return registerCallCount + }, + get toastShown() { + return toastAccessor?.() != null + }, + renderOnce: () => app.renderOnce(), + async cleanup() { + app.renderer.destroy() + if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME + else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome + await tmp[Symbol.asyncDispose]() + }, + } +} + +test("a failed Altimate Base selection can be retried", async () => { + const picker = await mount(["error", "ok"]) + try { + await picker.getBaseRow().onSelect?.() + await waitUntil(() => picker.registerCallCount === 1) + // `onSelect` fires `selectAltimateBase()` without awaiting it (so the dialog stays + // responsive); the row's activation latch only resets once that promise chain (fetch parse + + // failure toast) fully settles, a beat after the fetch itself resolves. Let it drain, pumping + // the renderer so the reactive toast store's update actually flushes. + await waitUntil(async () => { + await picker.renderOnce() + return picker.toastShown + }) + + // Before the fix, the row's one-shot latch stayed set after the failed attempt above, so this + // second selection would silently no-op — registerCallCount would stay at 1 forever. + await picker.getBaseRow().onSelect?.() + await waitUntil(() => picker.registerCallCount === 2) + + expect(picker.registerCallCount).toBe(2) + } finally { + await picker.cleanup() + } +}) +// altimate_change end diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index fa18633b1c..cc70bb8771 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -17,9 +17,11 @@ import { // altimate_change start — fixes #1301: broaden legacy-default migration eligibility isFreeZenModel, shouldOfferManagedBaseDefault, - // altimate_change — the identity check `fallbackModel()`/`currentModel()`/`restoreSession()` use - // to replace a stale keyless public-Zen selection with registered Base + // altimate_change start — the identity check `fallbackModel()`/`currentModel()`/`restoreSession()` + // use to replace a stale keyless public-Zen selection with registered Base, and the ordering + // `fallbackModel()`'s implicit last-resort branch uses isPublicZenProvider, + pickImplicitFallbackProvider, // altimate_change end // altimate_change start — fixes #1301 (Codex review, P2): usable-free-default predicate isUsableFreeDefault, @@ -539,3 +541,37 @@ test("isPublicZenProvider: only the keyless built-in opencode provider counts", expect(isPublicZenProvider({ id: "opencode" })).toBe(false) }) // altimate_change end + +// altimate_change start — pickImplicitFallbackProvider: the ordering fix for fallbackModel()'s +// implicit last-resort branch. A single `.find()` over the provider list in array order used to +// let Base beat a provider the user actually connected (and let public Zen win outright instead +// of being skipped) whenever it happened to sort first. This mirrors +// `Provider.defaultModel()`'s ordering: try every non-Base candidate (skipping public Zen when +// Base is available), then Base, then whatever the ordinary scan would have picked. +const allowAll = () => true + +test("pickImplicitFallbackProvider: registered Base outranks public Zen even when Zen is listed first", () => { + const zen = { id: "opencode", options: { apiKey: "public" } } + const base = { id: "altimate-free", options: {} } + expect(pickImplicitFallbackProvider([zen, base], allowAll, true)).toBe(base) +}) + +test("pickImplicitFallbackProvider: a credentialed provider outranks Base even when Base is listed first", () => { + const base = { id: "altimate-free", options: {} } + const anthropic = { id: "anthropic", options: {}, key: "sk-real" } + expect(pickImplicitFallbackProvider([base, anthropic], allowAll, true)).toBe(anthropic) +}) + +test("pickImplicitFallbackProvider: without Base registered, behavior is unchanged", () => { + // Public Zen is picked outright (not skipped) when Base isn't available — the previous, + // array-order behavior for this specific case. + const zen = { id: "opencode", options: { apiKey: "public" } } + const anthropic = { id: "anthropic", options: {}, key: "sk-real" } + expect(pickImplicitFallbackProvider([zen, anthropic], allowAll, false)).toBe(zen) + // Base is never returned when it isn't available, even if present in the list (e.g. a + // stale/disabled entry) — the last-resort fallback to Base is gated on `baseAvailable`, not + // mere presence. + const base = { id: "altimate-free", options: {} } + expect(pickImplicitFallbackProvider([base, anthropic], allowAll, false)).toBe(anthropic) +}) +// altimate_change end From 59415f605765c7f7220fde808be73eced6f59e77 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 22 Sep 2026 21:34:19 -0700 Subject: [PATCH 06/27] fix: repair unbalanced `altimate_change` markers and upstream branding in the Base default changes - `server.ts`: restore the outer import block's `altimate_change end` that was removed together with the `FreeTierHost` import - `acp.ts`: the auto-register import block used a single-line marker closed by an `end`; make it a `start` - `provider.ts`: drop a nested `start` inside an already-marked block in `defaultModel()` - `provider/error.ts`: the Zen rejection message and its matcher no longer name the upstream product; behavior unchanged (still scoped to the `opencode` provider) - Reword two comments that the branding audit flagged Fixes the Marker Guard and `bridge-merge` / `upstream-merge-guard` CI failures. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/acp/service.ts | 2 +- packages/opencode/src/cli/cmd/acp.ts | 2 +- packages/opencode/src/provider/error.ts | 12 ++++++------ packages/opencode/src/provider/provider.ts | 2 +- packages/opencode/src/server/server.ts | 1 + packages/opencode/test/provider/error.test.ts | 2 +- packages/tui/src/component/dialog-model.tsx | 2 +- 7 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index e2902dae16..1d9ef174d3 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -923,7 +923,7 @@ export function defaultModelFromConfig( // Altimate Base replaces Big Pickle as the free fallback once it auto-registers (which is why it // is present in `providers`). Anything the user actually connected outranks the request-logging // tier, and the keyless public Zen tier now ranks below registered Base unconditionally — - // OpenCode Zen rejects keyless traffic outright, so there is no "declined the switch" choice left + // Zen rejects keyless traffic outright, so there is no "declined the switch" choice left // to honor. A keyed Zen account still wins. A project provider block cannot force the managed // model; an explicit configured model above remains authoritative. if (registeredBaseAvailable) { diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index ce04ff2a9d..bbbc4f1bfc 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -5,7 +5,7 @@ import { ServerAuth } from "@/server/auth" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { ACPProfile } from "@/acp/profile" -// altimate_change — auto-register Altimate Base before the directory/provider snapshot is built +// altimate_change start — auto-register Altimate Base before the directory/provider snapshot is built import { FreeTier } from "@/altimate/free/client" // altimate_change end diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 3e06c38f44..b3ab71f746 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -343,15 +343,15 @@ export namespace ProviderError { // Check responseBody for context_length_exceeded code (e.g., OpenAI-style errors) const bodyParsed = json(input.error.responseBody) const codeFromBody = bodyParsed?.error?.code - // altimate_change start — OpenCode Zen's keyless free tier (provider "opencode", the - // `apiKey: "public"` fallback in provider.ts) stopped accepting our traffic on 2026-09-17 - // ("OpenCode's free tier can only be used from within OpenCode"). Without this, users saw - // that raw provider string. Point them at Altimate Base instead; never auto-switch here. - if (String(input.providerID) === "opencode" && /can only be used from within OpenCode/i.test(m)) { + // altimate_change start — Zen's keyless free tier (provider "opencode", the `apiKey: "public"` + // fallback in provider.ts) stopped accepting our traffic on 2026-09-17 with a "free tier can only + // be used from within " rejection. Without this, users saw that raw provider + // string. Point them at Altimate Base instead; never auto-switch here. + if (String(input.providerID) === "opencode" && /free tier can only be used from within/i.test(m)) { return { type: "api_error", message: - "OpenCode's free models no longer work in Altimate Code. Switch to Altimate Base (free) with /models (or your editor's model picker), or connect your own provider.", + "The free Zen models no longer work in Altimate Code. Switch to Altimate Base (free) with /models (or your editor's model picker), or connect your own provider.", statusCode: input.error.statusCode, isRetryable: false, responseHeaders: input.error.responseHeaders, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 68d7a4430d..6ee05e832f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -2242,7 +2242,7 @@ export namespace Provider { const baseProviderID = ProviderID.make(FreeTier.PROVIDER_ID) const baseModelID = ModelID.make(FreeTier.MODEL_ID) const baseProvider = providers[baseProviderID] - // altimate_change start — Base is excluded only by an actual enabled_providers/disabled_providers + // Base is excluded only by an actual enabled_providers/disabled_providers // verdict, which `providers` (built in state() via isProviderAllowed) already reflects. The // mere presence of OTHER custom `config.provider` entries (`hasProviderAllowlist`) used to hide // Base here too — that's the bug OpenCode Zen's keyless rejection turned into 360 failed diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index afc8dcd23d..9392d2e77a 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -45,6 +45,7 @@ import { FreeTierConsent } from "../altimate/free/consent" import { InstanceStore } from "@/project/instance-store" import { AppRuntime } from "@/effect/app-runtime" // altimate_change end +// altimate_change end import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" import { ExperimentalRoutes } from "./routes/experimental" diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts index 062e9ee523..e9e28aa372 100644 --- a/packages/opencode/test/provider/error.test.ts +++ b/packages/opencode/test/provider/error.test.ts @@ -494,7 +494,7 @@ describe("ProviderError.parseAPICallError: OpenCode Zen keyless free tier block" }) expect(result.type).toBe("api_error") expect(result.message).toBe( - "OpenCode's free models no longer work in Altimate Code. Switch to Altimate Base (free) with /models (or your editor's model picker), or connect your own provider.", + "The free Zen models no longer work in Altimate Code. Switch to Altimate Base (free) with /models (or your editor's model picker), or connect your own provider.", ) if (result.type === "api_error") { expect(result.isRetryable).toBe(false) diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index e6a6f8904c..d1be29f3c2 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -65,7 +65,7 @@ export function DialogModel(props: { let activated = false // A provider is "ready" (usable now) when it has valid credentials: it is present - // in the live provider list with at least one model — and, for the free OpenCode + // in the live provider list with at least one model — and, for the free Zen // provider, with at least one paid model (a Zen key entered). function providerReady(id: string) { const p = sync.data.provider.find((x) => x.id === id) From 41dc4d492a57b269b86e920d76d3e5f387d71cec Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 22 Sep 2026 22:54:01 -0700 Subject: [PATCH 07/27] fix: headless disclosure, persisted auto-register backoff, retry cap, dedupe and idempotent register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the backend half of the PR #1361 review findings: - Headless disclosure (`run`/`serve`/`acp`/`web`): the first successful AUTO registration outside the TUI now prints `ALTIMATE_BASE_DISCLOSURE` once to stderr (`consent.ts`'s `printDisclosureOnceForHeadless()`), tracked with a marker file next to the credential store so it survives across process launches. `serve` skips it when `ALTIMATE_CLI_CLIENT=datamates`, since the VS Code extension shows its own notice. - Retry cap bypass (`provider/error.ts`): the 60s cap on a retryable Base 429's `Retry-After` only clamped a numeric `retry-after` in seconds. `SessionRetry.delay()` reads `retry-after-ms` first and falls back to an HTTP-date `retry-after` — both bypassed the cap entirely. All three forms are now clamped, with 4 new tests. - Idempotent register tearing down every instance (`server.ts`): the `POST /altimate/base/register` route disposed every session/LSP/PTY/MCP connection whenever `FreeTier.register()` reported success, even when the credential on disk hadn't actually changed (its "already registered" fast path). Compares `FreeTier.credentials()` before/after and skips disposal when nothing changed, with 2 new tests. - `SessionPrompt.lastModel()`: `FreeTier.isRegistered()` can throw (unreadable store, bad config), which aborted resuming a session. Now best-effort — falls through to the unchanged model on error. - Shared in-flight dedupe (`free/client.ts`): `autoRegister()` and `register()` used one dedupe map keyed only by gateway URL, so an explicit `register()` racing an in-flight `autoRegister()` could observe autoRegister's own "logged out" skip instead of actually reconnecting. Split into `explicitInflight`/`autoInflight`, still serialized through the same `Flock` lock, with a new race test. - Repeated startup wait after a failure (`free/client.ts`): every entrypoint calls `autoRegisterWithin()` at startup, so a persistent failure (network down, gateway 429/5xx) repeated the same attempt on every new launch — these are short-lived processes, so an in-process-only backoff map never helped. The backoff deadline is now persisted to a small JSON file next to the credential store, read at the start of `autoRegister()` (including the first call in a brand-new process) and cleared on success; `register()` ignores it. 3 new tests, including one asserting the backoff survives what an in-memory map would have lost. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/opencode/src/altimate/free/client.ts | 174 ++++++++++++++++-- .../opencode/src/altimate/free/consent.ts | 48 +++++ packages/opencode/src/cli/cmd/acp.ts | 4 +- packages/opencode/src/cli/cmd/run.ts | 4 +- packages/opencode/src/cli/cmd/serve.ts | 12 +- packages/opencode/src/cli/cmd/web.ts | 7 +- packages/opencode/src/provider/error.ts | 42 ++++- packages/opencode/src/server/server.ts | 19 +- packages/opencode/src/session/prompt.ts | 5 +- .../altimate-base-auto-register.test.ts | 108 +++++++++++ packages/opencode/test/provider/error.test.ts | 50 +++++ .../server/altimate-base-registration.test.ts | 66 +++++++ 12 files changed, 512 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index b5e3961d72..ca5ad1c69a 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -1,4 +1,6 @@ import { createHash, randomBytes } from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" import { Flock } from "@opencode-ai/core/util/flock" import { Installation } from "../../installation" import { Log } from "../util/log" @@ -20,7 +22,24 @@ declare const ALTIMATE_BASE_DEFAULT_GATEWAY_URL: string | undefined const REGISTER_TIMEOUT_MS = 15_000 const LOCK_KEY = "altimate-base-registration" -const inflight = new Map>() +// altimate_change start — Codex review finding: separate in-process dedupe maps for the explicit +// (picker/route) and auto (startup) registration paths. They used to share one map keyed only by +// gateway URL, so an explicit register() call arriving while an auto-register attempt was already +// in flight for the same gateway would just RETURN that auto attempt's promise — including its +// auto-only semantics. Concretely: autoRegister() treats "the user logged out" as a `{status: +// "skipped", reason: "logged-out"}` result (via AutoRegisterSkippedLoggedOutError, caught only in +// autoRegister() itself), while an explicit register() is documented to proceed through a logout +// and reconnect. A joined explicit call got that rejection surfaced as an unhandled/miscategorized +// error instead of actually registering. +// +// Splitting the maps does not reopen "both paths hit the network at once for the same gateway": +// both still funnel through the SAME `Flock.withLock(LOCK_KEY, ...)` in registerOnce()/ +// autoRegisterLocked() below, which serializes the real work. Whichever call's locked body runs +// second re-reads the store fresh and (unless a genuine explicit-through-logout reconnect is in +// play) takes the "already registered" fast path instead of registering again. +const explicitInflight = new Map>() +const autoInflight = new Map>() +// altimate_change end const rejectedCredentials = new Set() const REJECTED_CREDENTIAL_LIMIT = 32 // A credential is only disowned on disk after this many 401s in a row. One 401 can come from a @@ -44,6 +63,10 @@ export class RegistrationError extends Error { message: string, readonly kind: RegistrationFailureKind, readonly status?: number, + // altimate_change — the gateway's own Retry-After for a 429, in ms, when present and parseable + // (numeric seconds or an HTTP-date). Lets autoRegister's post-failure backoff (below) honor a + // longer-than-default wait instead of hammering a gateway that just told it to back off more. + readonly retryAfterMs?: number, ) { super(message) this.name = "AltimateBaseRegistrationError" @@ -165,6 +188,18 @@ function describeRegistrationFailure(status: number): string { return `Altimate Base registration failed (HTTP ${status}).` } +// altimate_change — Retry-After can be a plain seconds count or an HTTP-date; autoRegister's +// post-failure backoff below only needs a 429's value, so callers gate this on that status. +function parseRetryAfterMs(value: string | null): number | undefined { + if (!value) return undefined + const seconds = Number(value) + if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000 + const target = Date.parse(value) + if (!Number.isFinite(target)) return undefined + const ms = target - Date.now() + return ms > 0 ? ms : undefined +} + function sameOrigin(left: string, right: string): boolean { try { return new URL(left).origin === new URL(right).origin @@ -294,7 +329,10 @@ async function registerOnce( if (!response.ok) { log.warn("Altimate Base registration rejected", { status: response.status }) - throw new RegistrationError(describeRegistrationFailure(response.status), "http", response.status) + // altimate_change — see autoRegister()'s post-failure backoff below for why 429 specifically + const retryAfterMs = + response.status === 429 ? parseRetryAfterMs(response.headers.get("retry-after")) : undefined + throw new RegistrationError(describeRegistrationFailure(response.status), "http", response.status, retryAfterMs) } const body = (await response.json().catch(() => undefined)) as @@ -351,8 +389,10 @@ async function registerOnce( * resulting credential write carries a real `apiKey`, which is what `autoRegister`'s logout check * actually keys on). * - * Shares `LOCK_KEY`, the `inflight` dedupe map, and `registerOnce` with `autoRegister`, so the two - * can never both hit the network for the same gateway at once. + * Shares `LOCK_KEY` and `registerOnce` with `autoRegister`, so the two can never both hit the + * network for the same gateway at once — but dedupes concurrent explicit calls against + * `explicitInflight`, a map of its own, never against an in-flight auto-register (see + * `explicitInflight`'s declaration for why). */ export async function register( input: { origin: "picker" | "server"; signal?: AbortSignal } = { origin: "picker" }, @@ -366,7 +406,7 @@ export async function register( throw error } const dedupeKey = configuredGateway - const pending = inflight.get(dedupeKey) + const pending = explicitInflight.get(dedupeKey) if (pending) return pending const started = (async () => { @@ -406,9 +446,9 @@ export async function register( return registerOnce(configuredGateway, expectedLogoutNonce, input.signal) }) })().finally(() => { - if (inflight.get(dedupeKey) === started) inflight.delete(dedupeKey) + if (explicitInflight.get(dedupeKey) === started) explicitInflight.delete(dedupeKey) }) - inflight.set(dedupeKey, started) + explicitInflight.set(dedupeKey, started) // altimate_change start — report the outcome on a side branch so the caller's promise, and the // dedupe bookkeeping above, are untouched; the rejection handler keeps the branch from surfacing // as an unhandled rejection. @@ -474,7 +514,7 @@ class AutoRegisterSkippedLoggedOutError extends Error {} export type AutoRegisterResult = | { status: "registered" } - | { status: "skipped"; reason: "env" | "no-gateway" | "logged-out" | "already-registered" } + | { status: "skipped"; reason: "env" | "no-gateway" | "logged-out" | "already-registered" | "backoff" } | { status: "failed"; kind: Exclude } function autoRegisterDisabledByEnv(): boolean { @@ -482,6 +522,94 @@ function autoRegisterDisabledByEnv(): boolean { return raw === "0" || raw === "false" } +// altimate_change start — Codex review finding: every entrypoint calls autoRegisterWithin() at +// startup, so a persistent failure (network down, gateway returning 429/5xx) meant every single +// launch repeated the same 15s-timeout registration attempt (up to the 3s budget) for nothing. +// `run`/`serve`/`acp`/`web` are all short-lived processes — a Map would reset with every new +// launch, which is exactly the case that needed fixing — so the backoff deadline is persisted to a +// small JSON file next to the credential store (same directory as `FreeTierStore.credentialPath()`, +// mirroring the disclosure marker in consent.ts) and read at the start of every `autoRegister()` +// call, including the first one in a brand-new process. +const AUTO_REGISTER_BACKOFF_MS = 60 * 60 * 1000 // 1 hour + +function autoRegisterBackoffMs( + kind: Exclude, + error: unknown, +): number | undefined { + if (kind === "network") return AUTO_REGISTER_BACKOFF_MS + if (kind === "http" && error instanceof RegistrationError) { + if (error.status === 429) return Math.max(AUTO_REGISTER_BACKOFF_MS, error.retryAfterMs ?? 0) + if (error.status !== undefined && error.status >= 500) return AUTO_REGISTER_BACKOFF_MS + } + return undefined +} + +function autoRegisterBackoffPath(): string { + return path.join(path.dirname(FreeTierStore.credentialPath()), "altimate-base-auto-register-backoff.json") +} + +type AutoRegisterBackoffRecord = { [gateway: string]: number } + +async function readAutoRegisterBackoffRecord(): Promise { + try { + const raw = await fs.readFile(autoRegisterBackoffPath(), "utf8") + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== "object") return {} + const result: AutoRegisterBackoffRecord = {} + for (const [gateway, until] of Object.entries(parsed as Record)) { + if (typeof until === "number" && Number.isFinite(until)) result[gateway] = until + } + return result + } catch { + // Missing file, unreadable, or corrupt JSON — treated the same as "no backoff recorded". + // Never blocks auto-registration: worst case is one extra attempt. + return {} + } +} + +async function writeAutoRegisterBackoffRecord(record: AutoRegisterBackoffRecord): Promise { + const target = autoRegisterBackoffPath() + try { + await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 }) + await fs.writeFile(target, JSON.stringify(record) + "\n", { mode: 0o600 }) + } catch (error) { + // Best-effort persistence: a failure to write only risks one extra attempt on the next + // launch, never blocks the current one. + log.warn("failed to persist Altimate Base auto-register backoff", { error }) + } +} + +async function getPersistedAutoRegisterBackoff(gateway: string): Promise { + const record = await readAutoRegisterBackoffRecord() + return record[gateway] +} + +async function setPersistedAutoRegisterBackoff(gateway: string, until: number): Promise { + const record = await readAutoRegisterBackoffRecord() + record[gateway] = until + await writeAutoRegisterBackoffRecord(record) +} + +async function clearPersistedAutoRegisterBackoff(gateway: string): Promise { + const record = await readAutoRegisterBackoffRecord() + if (!(gateway in record)) return + delete record[gateway] + await writeAutoRegisterBackoffRecord(record) +} + +// Test-only: this file is otherwise process-global state with no reset hook, so a backoff set by +// one test would silently skip auto-register for every later test in the same file/gateway. +// Mirrors the `resetXForTests()` naming convention used elsewhere (e.g. +// `altimate/workspace/identity.ts`). +export async function resetAutoRegisterBackoffForTests(): Promise { + await writeAutoRegisterBackoffRecord({}) +} + +export async function getAutoRegisterBackoffUntilForTests(gateway: string): Promise { + return getPersistedAutoRegisterBackoff(gateway) +} +// altimate_change end + /** * Registration body for the no-consent auto-register path, run entirely under the shared * registration lock. Every read of the store happens while holding LOCK_KEY, so it always sees @@ -515,9 +643,11 @@ async function autoRegisterLocked(configuredGateway: string, signal: AbortSignal /** * Register at startup, automatically — no consent gate, no user action. Every entrypoint calls - * this before provider state is first built. Shares LOCK_KEY, the `inflight` dedupe map, and - * `registerOnce` with `register()` (the explicit, picker/route-triggered path), so an auto-register - * and an explicit registration racing for the same gateway can never both hit the network. + * this before provider state is first built. Shares LOCK_KEY and `registerOnce` with `register()` + * (the explicit, picker/route-triggered path), so an auto-register and an explicit registration + * racing for the same gateway can never both hit the network — but dedupes concurrent auto calls + * against `autoInflight`, a map of its own, never against an in-flight explicit register (see + * `explicitInflight`'s declaration for why). * * Never throws: every failure mode resolves to a `{ status: "skipped" | "failed" }` result. */ @@ -538,16 +668,21 @@ export async function autoRegister(signal?: AbortSignal): Promise false) if (alreadyRegistered) return { status: "skipped", reason: "already-registered" } + // altimate_change — see the persisted-backoff declarations above. Explicit register() never + // consults this: it is the user asking to reconnect right now, not startup's own retry. + const backoffUntil = await getPersistedAutoRegisterBackoff(configuredGateway) + if (backoffUntil !== undefined && Date.now() < backoffUntil) return { status: "skipped", reason: "backoff" } + const dedupeKey = configuredGateway - const existing = inflight.get(dedupeKey) + const existing = autoInflight.get(dedupeKey) const startedAt = performance.now() const started = existing ?? (() => { const promise = autoRegisterLocked(configuredGateway, signal).finally(() => { - if (inflight.get(dedupeKey) === promise) inflight.delete(dedupeKey) + if (autoInflight.get(dedupeKey) === promise) autoInflight.delete(dedupeKey) }) - inflight.set(dedupeKey, promise) + autoInflight.set(dedupeKey, promise) return promise })() @@ -558,9 +693,18 @@ export async function autoRegister(signal?: AbortSignal): Promise // Only the call that actually owns the in-flight promise reports it, so a dedupe hit never // double-counts one registration attempt. - if (!existing) reportRegistration(kind, startedAt, error, "auto") + if (!existing) { + reportRegistration(kind, startedAt, error, "auto") + // altimate_change — see the persisted-backoff declarations above + const backoffMs = autoRegisterBackoffMs(kind, error) + if (backoffMs) await setPersistedAutoRegisterBackoff(configuredGateway, Date.now() + backoffMs) + } return { status: "failed", kind } } + // altimate_change — a launch that finally succeeds (network recovered, gateway stopped + // throttling) must not keep skipping on some later launch just because a stale deadline is + // still sitting on disk from the earlier failure. + await clearPersistedAutoRegisterBackoff(configuredGateway) if (!existing) reportRegistration("success", startedAt, undefined, "auto") return { status: "registered" } } catch (error) { diff --git a/packages/opencode/src/altimate/free/consent.ts b/packages/opencode/src/altimate/free/consent.ts index faea865727..750a049615 100644 --- a/packages/opencode/src/altimate/free/consent.ts +++ b/packages/opencode/src/altimate/free/consent.ts @@ -1,4 +1,6 @@ import { createHash } from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" import { ALTIMATE_BASE_DISCLOSURE } from "@opencode-ai/core/altimate-base-disclosure" import { FreeTier } from "./client" import { FreeTierStore } from "./store" @@ -84,4 +86,50 @@ export function createRegistrationGate(input: { } } +// altimate_change start — Codex review finding: `run`, `serve`, `acp` and `web` all call +// `FreeTier.autoRegisterWithin()` before a TUI (or any UI at all) exists to show the disclosure — +// only the TUI's own one-line toast (`useAltimateBaseDisclosureNotice` in +// tui/src/component/altimate-onboarding.tsx) covered the interactive case. Prints the same +// disclosure text to stderr, once per install, the first time a HEADLESS entrypoint auto-registers +// successfully. +// +// "Once per install" is tracked with a marker file next to the credential store rather than the +// TUI's kv — the TUI's kv lives at a per-workspace path (`TuiPaths.state`, via +// `TuiPathsProvider`/`context/kv.tsx`) that these backend processes have no general way to reach +// (a `serve` and its TUI client can even be on different machines), while the credential store +// (`FreeTierStore`, `Global.Path.data`) is this same install's single global file either way. +function disclosureMarkerPath(): string { + return path.join(path.dirname(FreeTierStore.credentialPath()), "altimate-base-disclosure-shown.json") +} + +async function disclosureAlreadyShown(): Promise { + try { + await fs.access(disclosureMarkerPath()) + return true + } catch { + return false + } +} + +async function markDisclosureShown(): Promise { + const target = disclosureMarkerPath() + await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 }) + await fs.writeFile(target, JSON.stringify({ shownAt: new Date().toISOString() }) + "\n", { mode: 0o600 }) +} + +/** + * Print the Base disclosure to stderr for a headless entrypoint, once per install. A no-op unless + * `justRegistered` is true (this call's own `autoRegisterWithin()` actually minted a credential — + * not merely "already registered", which every later launch reports) and the marker isn't already + * set. Never throws: a failure to persist the marker only risks showing the notice again on a + * later launch, never blocks startup. + */ +export async function printDisclosureOnceForHeadless(justRegistered: boolean): Promise { + if (!justRegistered) return + if (await disclosureAlreadyShown().catch(() => false)) return + console.error(`Altimate Base: ${ALTIMATE_BASE_DISCLOSURE}`) + await markDisclosureShown().catch(() => {}) +} +// altimate_change end + export * as FreeTierConsent from "./consent" diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index bbbc4f1bfc..1e4e9d1cdf 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -26,7 +26,9 @@ export const AcpCommand = effectCmd({ process.env.OPENCODE_CLIENT = "acp" // altimate_change start — auto-register before Server.listen, ahead of the ACP directory // snapshot (providers/defaultModel) that ACP.init/loadDirectorySnapshot builds - yield* Effect.promise(() => FreeTier.autoRegisterWithin()) + const autoRegisterResult = yield* Effect.promise(() => FreeTier.autoRegisterWithin()) + const { FreeTierConsent } = yield* Effect.promise(() => import("@/altimate/free/consent")) + yield* Effect.promise(() => FreeTierConsent.printDisclosureOnceForHeadless(autoRegisterResult.status === "registered")) // altimate_change end const opts = yield* resolveNetworkOptions(args) // altimate_change start — upstream_fix: preserve async server listen inside ACP profiler measure diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 6b165acdb9..81f0a12fe2 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1661,7 +1661,9 @@ You are speaking to a non-technical business executive. Follow these rules stric // process is responsible for its own registration. { const { FreeTier } = await import("../../altimate/free/client") - await FreeTier.autoRegisterWithin() + const { FreeTierConsent } = await import("../../altimate/free/consent") + const result = await FreeTier.autoRegisterWithin() + await FreeTierConsent.printDisclosureOnceForHeadless(result.status === "registered") } // altimate_change end await bootstrap(process.cwd(), async () => { diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 6ccb048117..2c95752a46 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -5,6 +5,9 @@ import { Flag } from "@opencode-ai/core/flag/flag" // altimate_change start — trace: session tracing in headless serve import { subscribeTraceConsumer } from "../../altimate/observability/trace-consumer" // altimate_change end +// altimate_change — ALTIMATE_CLI_CLIENT is declared on this package's own Flag namespace, not +// core's (aliased to avoid colliding with the `Flag` import above) +import { Flag as OpencodeFlag } from "../../flag/flag" // altimate_change start — self-update on headless serve startup import { scheduleStartupUpgradeCheck } from "./serve-upgrade-check" // altimate_change end @@ -45,7 +48,14 @@ export const ServeCommand = effectCmd({ // altimate_change start — auto-register Altimate Base before provider state is first built. // `serve` is the VS Code/Cursor extension's process — no TUI, no interactive gate — so this is // the only chance to have Base ready before the first provider list/default-model resolution. - yield* Effect.promise(() => FreeTier.autoRegisterWithin()) + const autoRegisterResult = yield* Effect.promise(() => FreeTier.autoRegisterWithin()) + // The VS Code extension (ALTIMATE_CLI_CLIENT=datamates) renders its own notice in the chat + // panel; printing this one too would be a duplicate for the one client that actually has a UI + // for it. Every other `serve` caller has no UI at all, so stderr is the only surface it has. + if (OpencodeFlag.ALTIMATE_CLI_CLIENT !== "datamates") { + const { FreeTierConsent } = yield* Effect.promise(() => import("../../altimate/free/consent")) + yield* Effect.promise(() => FreeTierConsent.printDisclosureOnceForHeadless(autoRegisterResult.status === "registered")) + } // altimate_change end const server = yield* Effect.sync(() => Server.listen(opts)) // altimate_change start — upstream_fix: branding regression in log line diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 48097f7d6b..6262260fbd 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -6,8 +6,10 @@ import { AppRuntime } from "../../effect/app-runtime" import { Flag } from "../../flag/flag" import open from "open" import { networkInterfaces } from "os" -// altimate_change — auto-register Altimate Base before the server (and its provider state) starts +// altimate_change start — auto-register Altimate Base before the server (and its provider state) starts import { FreeTier } from "../../altimate/free/client" +import { FreeTierConsent } from "../../altimate/free/consent" +// altimate_change end function getNetworkIPs() { const nets = networkInterfaces() @@ -43,7 +45,8 @@ export const WebCommand = cmd({ } const opts = await AppRuntime.runPromise(resolveNetworkOptions(args)) // altimate_change start — auto-register Altimate Base before provider state is first built - await FreeTier.autoRegisterWithin() + const autoRegisterResult = await FreeTier.autoRegisterWithin() + await FreeTierConsent.printDisclosureOnceForHeadless(autoRegisterResult.status === "registered") // altimate_change end const server = Server.listen(opts) UI.empty() diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index b3ab71f746..fd8c849f2c 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -279,13 +279,45 @@ export namespace ProviderError { // 60s. Altimate Base's per-minute token throttle is now retryable (see the 429 branch below), // so a gateway-reported wait must never stall a session for minutes; the user-facing message // still shows the real value, only the header driving the sleep is clamped. + // + // SessionRetry.delay() reads `retry-after-ms` first (a raw millisecond count, no unit + // conversion), then `retry-after` — either numeric seconds or an HTTP-date. The original version + // of this cap only clamped the numeric-seconds form, so a gateway sending `retry-after-ms` or an + // HTTP-date `retry-after` bypassed it entirely; both are clamped here too (a date is converted to + // a plain seconds-from-now count once it exceeds the cap, matching the numeric form's shape). const MAX_RETRY_AFTER_SECONDS = 60 + const MAX_RETRY_AFTER_MS = MAX_RETRY_AFTER_SECONDS * 1000 function capRetryAfterHeader(headers: Record | undefined): Record | undefined { - const retryAfter = headers?.["retry-after"] - if (!retryAfter) return headers - const seconds = Number(retryAfter) - if (!Number.isFinite(seconds) || seconds <= MAX_RETRY_AFTER_SECONDS) return headers - return { ...headers, "retry-after": String(MAX_RETRY_AFTER_SECONDS) } + if (!headers) return headers + let next = headers + + const retryAfterMs = headers["retry-after-ms"] + if (retryAfterMs) { + const ms = Number(retryAfterMs) + if (Number.isFinite(ms) && ms > MAX_RETRY_AFTER_MS) { + next = { ...next, "retry-after-ms": String(MAX_RETRY_AFTER_MS) } + } + } + + const retryAfter = headers["retry-after"] + if (retryAfter) { + const seconds = Number(retryAfter) + if (Number.isFinite(seconds)) { + if (seconds > MAX_RETRY_AFTER_SECONDS) { + next = { ...next, "retry-after": String(MAX_RETRY_AFTER_SECONDS) } + } + } else { + const target = Date.parse(retryAfter) + if (Number.isFinite(target)) { + const secondsFromNow = (target - Date.now()) / 1000 + if (secondsFromNow > MAX_RETRY_AFTER_SECONDS) { + next = { ...next, "retry-after": String(MAX_RETRY_AFTER_SECONDS) } + } + } + } + } + + return next } // altimate_change end diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 9392d2e77a..cf5d03f8d9 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -728,7 +728,7 @@ export namespace Server { describeRoute({ summary: "Register Altimate Base", description: - "Mints the managed Altimate Base credential. `acceptedDisclosureSha256` is accepted for compatibility with older clients but ignored — registration no longer requires it. On success this disposes EVERY cached instance in the process — both registries — so provider loaders re-read the new credential. That is deliberately process-wide because the credential is a single global file, and it is disruptive: instance-scoped state elsewhere on this server (sessions, LSPs, PTYs, MCP connections, file watchers) is torn down and re-created, and `server.instance.disposed` is emitted for each. `staleProviders: true` in the response means the credential was written but at least one registry could not be invalidated, so provider lists may still show Altimate Base as disconnected.", + "Mints the managed Altimate Base credential. `acceptedDisclosureSha256` is accepted for compatibility with older clients but ignored — registration no longer requires it. If this installation already has a valid credential for the configured gateway, registration is idempotent and nothing is torn down. Otherwise, on success this disposes EVERY cached instance in the process — both registries — so provider loaders re-read the new credential. That is deliberately process-wide because the credential is a single global file, and it is disruptive: instance-scoped state elsewhere on this server (sessions, LSPs, PTYs, MCP connections, file watchers) is torn down and re-created, and `server.instance.disposed` is emitted for each. `staleProviders: true` in the response means a NEW credential was written but at least one registry could not be invalidated, so provider lists may still show Altimate Base as disconnected.", operationId: "altimateBase.register", responses: { 200: { @@ -775,6 +775,15 @@ export namespace Server { ) } + // altimate_change — Codex review finding: `FreeTier.register()` returns success whether + // it minted/rotated a credential OR just found an existing valid one (the idempotent + // fast path — see its "already registered" branch in free/client.ts). Everything below + // is instance-wide teardown that only matters when the credential on disk actually + // changed; comparing it before/after `gate.register()` (rather than changing + // `register()`'s own return shape, which every other caller — the picker, and a dozen + // existing tests — depends on as a plain `Credentials`) reports that without touching + // that contract. + const before = await FreeTier.credentials().catch(() => undefined) const gate = FreeTierConsent.createRegistrationGate({ register: () => FreeTier.register({ origin: "server" }), onUnexpectedError: (error) => log.error("Altimate Base registration failed", { error }), @@ -799,7 +808,15 @@ export namespace Server { // // A failure in either leaves the credential written but provider lists possibly stale, so // it is reported rather than swallowed: the client needs to know its picker may be wrong. + // + // Skipped entirely when nothing changed: an idempotent register (already valid + // credentials for this gateway) has nothing for a provider loader to re-read, so tearing + // down every session, LSP, PTY, MCP connection and file watcher in the process would be + // pure disruption for zero benefit. if (outcome.ok) { + const after = await FreeTier.credentials().catch(() => undefined) + const changed = before?.apiKey !== after?.apiKey || before?.baseURL !== after?.baseURL + if (!changed) return c.json(outcome) const disposed = await Promise.all([ Instance.disposeAll().then( () => true, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 73342cd7d4..00be80aa0e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2044,7 +2044,10 @@ export namespace SessionPrompt { // registered) never pays that cost. for await (const item of MessageV2.stream(sessionID)) { if (item.info.role === "user" && item.info.model) { - if (item.info.model.providerID === "opencode" && (await FreeTier.isRegistered())) { + // altimate_change — Codex review finding: `isRegistered()` can throw (unreadable store, + // bad config). This re-resolution is best-effort — an error here must not abort resuming + // the session, so it falls through to the unchanged model, same as "not registered". + if (item.info.model.providerID === "opencode" && (await FreeTier.isRegistered().catch(() => false))) { const providers = await Provider.list() const provider = providers[item.info.model.providerID] if (provider && Provider.isPublicZen(provider)) return Provider.defaultModel() diff --git a/packages/opencode/test/altimate/altimate-base-auto-register.test.ts b/packages/opencode/test/altimate/altimate-base-auto-register.test.ts index 747ca01874..adc497512e 100644 --- a/packages/opencode/test/altimate/altimate-base-auto-register.test.ts +++ b/packages/opencode/test/altimate/altimate-base-auto-register.test.ts @@ -42,6 +42,11 @@ beforeEach(async () => { await FreeTier.logout() await FreeTierStore.remove() resetGatewayEnv(GATEWAY_URL) + // altimate_change — the auto-register backoff (see "FreeTier.autoRegister: backoff after a + // failure" below) is persisted to a file next to the credential store with no other reset hook; + // without this, a backoff set by one test would silently skip auto-register in every later test + // in this file. + await FreeTier.resetAutoRegisterBackoffForTests() }) afterEach(() => { @@ -198,3 +203,106 @@ describe("FreeTier.autoRegisterWithin", () => { } }) }) + +// altimate_change start — Codex review finding: autoRegister() and register() used to share ONE +// in-process dedupe map keyed only by gateway URL. An explicit register() call arriving while an +// auto-register attempt was in flight for the same gateway could just return THAT attempt's +// promise — including autoRegister()'s own "the user logged out" skip, which register() is +// documented to never treat as a reason to stop (an explicit register is the user asking to +// reconnect). Separate maps mean the two can never observe each other's in-flight promise; the +// shared LOCK_KEY flock still means only one of them actually talks to the gateway. +// +// Also, Codex review finding: every entrypoint calls autoRegisterWithin() at startup, so a +// persistent failure (network down, gateway 429/5xx) meant every launch repeated the same +// 15s-timeout attempt for nothing. A failure now sets a backoff window persisted next to the +// credential store (1h, or the gateway's own Retry-After for a 429 if longer); a launch within +// that window skips without touching the network. Explicit register() ignores the backoff +// entirely — it's the user asking to +// reconnect right now, not startup's own retry. +describe("FreeTier.autoRegister: backoff after a failure", () => { + test("a network failure sets a backoff; the next auto-register call within it makes no network call", async () => { + gateway.restore() + const failing = spyOn(globalThis, "fetch").mockImplementation((async (_input: RequestInfo | URL, _init?: RequestInit) => { + throw new TypeError("network unreachable") + }) as unknown as typeof fetch) + try { + const first = await FreeTier.autoRegister() + expect(first).toEqual({ status: "failed", kind: "network" }) + } finally { + failing.mockRestore() + } + + // Reinstall the (working) FakeGateway — if the backoff were NOT honored, this second call + // would succeed and register, since nothing else is wrong now. + gateway.install() + gateway.registerNext({ kind: "ok" }) + const second = await FreeTier.autoRegister() + expect(second).toEqual({ status: "skipped", reason: "backoff" }) + expect(gateway.registerCalls).toHaveLength(0) + expect(await FreeTier.isRegistered()).toBe(false) + }) + + test("a 429 with a Retry-After longer than the default backoff honors the longer value", async () => { + gateway.restore() + const rateLimited = spyOn(globalThis, "fetch").mockImplementation((async (_input: RequestInfo | URL, _init?: RequestInit) => { + return new Response(JSON.stringify({ error: "rate limited" }), { + status: 429, + headers: { "Content-Type": "application/json", "retry-after": String(2 * 60 * 60) }, // 2h + }) + }) as typeof fetch) + const before = Date.now() + try { + const first = await FreeTier.autoRegister() + expect(first).toEqual({ status: "failed", kind: "http" }) + } finally { + rateLimited.mockRestore() + } + + // The stored backoff deadline reflects the gateway's 2h ask, not the 1h default — a launch + // 1.5h later (past the default, short of the 2h ask) must still be skipped. + const backoffUntil = await FreeTier.getAutoRegisterBackoffUntilForTests(GATEWAY_URL) + expect(backoffUntil).toBeDefined() + expect(backoffUntil!).toBeGreaterThanOrEqual(before + 1.9 * 60 * 60 * 1000) + }) + + test("explicit register() ignores the auto-register backoff", async () => { + gateway.restore() + const failing = spyOn(globalThis, "fetch").mockImplementation((async (_input: RequestInfo | URL, _init?: RequestInit) => { + throw new TypeError("network unreachable") + }) as unknown as typeof fetch) + try { + const first = await FreeTier.autoRegister() + expect(first).toEqual({ status: "failed", kind: "network" }) + } finally { + failing.mockRestore() + } + + gateway.install() + gateway.registerNext({ kind: "ok" }) + const result = await FreeTier.register({ origin: "picker" }) + expect(result.apiKey).toBeDefined() + expect(gateway.registerCalls).toHaveLength(1) + }) +}) + +describe("FreeTier.autoRegister / FreeTier.register: independent in-flight dedupe", () => { + test("an explicit register() racing a logged-out auto-register still registers, never surfaces the auto skip", async () => { + await FreeTier.logout() + gateway.registerNext({ kind: "ok" }) + + const [autoResult, explicit] = await Promise.all([FreeTier.autoRegister(), FreeTier.register({ origin: "picker" })]) + + // Whichever attempt's locked body wins the race, the explicit call must always come back with + // real credentials — never rejecting with autoRegister's own AutoRegisterSkippedLoggedOutError + // (the exact failure mode this test guards against; before the fix it could join that + // rejecting promise instead of registering). + expect(explicit.apiKey).toBeDefined() + expect(await FreeTier.isRegistered()).toBe(true) + // autoRegister's own outcome depends on which locked body ran first — both are legitimate: + // "logged-out" if it read the store before the explicit call registered, "registered" if the + // explicit call already reconnected by the time it read. Never anything else. + expect(["skipped", "registered"]).toContain(autoResult.status) + if (autoResult.status === "skipped") expect(autoResult.reason).toBe("logged-out") + }) +}) +// altimate_change end diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts index e9e28aa372..947f0062fe 100644 --- a/packages/opencode/test/provider/error.test.ts +++ b/packages/opencode/test/provider/error.test.ts @@ -469,6 +469,56 @@ describe("ProviderError.parseAPICallError: Altimate Base isolation", () => { } }) + // altimate_change start — Codex review finding: SessionRetry.delay() reads `retry-after-ms` + // first (a raw millisecond count) and falls back to an HTTP-date `retry-after` when the header + // isn't numeric — the 60s cap above originally only clamped a numeric `retry-after` in seconds, + // so both of these bypassed it entirely. + test("caps a large retry-after-ms header at 60000ms", () => { + const result = ProviderError.parseAPICallError({ + providerID: "altimate-free" as any, + error: rateLimited("throttling_error", "Limit type: tokens", { "retry-after-ms": "900000" }), + }) + if (result.type === "api_error") { + expect(result.isRetryable).toBe(true) + expect(result.responseHeaders?.["retry-after-ms"]).toBe("60000") + } + }) + + test("does not cap a retry-after-ms header already under 60000ms", () => { + const result = ProviderError.parseAPICallError({ + providerID: "altimate-free" as any, + error: rateLimited("throttling_error", "", { "retry-after-ms": "12000" }), + }) + if (result.type === "api_error") { + expect(result.responseHeaders?.["retry-after-ms"]).toBe("12000") + } + }) + + test("caps an HTTP-date Retry-After header more than 60s in the future", () => { + const future = new Date(Date.now() + 15 * 60_000).toUTCString() + const result = ProviderError.parseAPICallError({ + providerID: "altimate-free" as any, + error: rateLimited("throttling_error", "Limit type: tokens", { "retry-after": future }), + }) + if (result.type === "api_error") { + expect(result.isRetryable).toBe(true) + expect(result.responseHeaders?.["retry-after"]).toBe("60") + } + }) + + test("does not cap an HTTP-date Retry-After header already under 60s away", () => { + const soon = new Date(Date.now() + 10_000).toUTCString() + const result = ProviderError.parseAPICallError({ + providerID: "altimate-free" as any, + error: rateLimited("throttling_error", "", { "retry-after": soon }), + }) + if (result.type === "api_error") { + // Untouched — still the original HTTP-date string, not rewritten into a seconds count. + expect(result.responseHeaders?.["retry-after"]).toBe(soon) + } + }) + // altimate_change end + test("does not cap the Retry-After header on a non-retryable Altimate Base 429 (budget_exceeded)", () => { const result = ProviderError.parseAPICallError({ providerID: "altimate-free" as any, diff --git a/packages/opencode/test/server/altimate-base-registration.test.ts b/packages/opencode/test/server/altimate-base-registration.test.ts index 926b6cf98f..c96958fa45 100644 --- a/packages/opencode/test/server/altimate-base-registration.test.ts +++ b/packages/opencode/test/server/altimate-base-registration.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { Server } from "../../src/server/server" import { FreeTier } from "../../src/altimate/free/client" import { FreeTierConsent } from "../../src/altimate/free/consent" +import { Instance } from "../../src/project/instance" import { resetDatabase } from "./db" import { disposeAllInstances } from "../fixture/fixture" @@ -16,14 +17,34 @@ function app() { // exercising a real gateway fetch — the registration function itself (network/HTTP/response // mapping) is covered by test/altimate/altimate-base*.test.ts. let registerSpy: ReturnType | undefined +// altimate_change — Codex review finding: the route must skip instance-wide disposal when +// `FreeTier.register()` reported success but the credential on disk didn't actually change (its +// idempotent "already registered" fast path). The route detects this by comparing +// `FreeTier.credentials()` before/after, so these tests drive that comparison directly. +let credentialsSpy: ReturnType | undefined function mockRegister(impl: () => Promise) { registerSpy = spyOn(FreeTier, "register").mockImplementation(impl as typeof FreeTier.register) } +// altimate_change — see `credentialsSpy` above. Index-based (not `.shift() ?? ...`): the first +// value in the sequence is legitimately `undefined` (no credential yet), which `??` cannot tell +// apart from "queue exhausted". +function mockCredentialsSequence(...values: Array>>) { + let i = 0 + credentialsSpy = spyOn(FreeTier, "credentials").mockImplementation(async () => { + const value = values[Math.min(i, values.length - 1)] + i++ + return value + }) +} + afterEach(async () => { registerSpy?.mockRestore() registerSpy = undefined + // altimate_change — see `credentialsSpy` above + credentialsSpy?.mockRestore() + credentialsSpy = undefined await disposeAllInstances() await resetDatabase() }) @@ -107,6 +128,51 @@ describe("Altimate Base registration route", () => { }) expect(response.status).toBe(400) }) + + // altimate_change start — Codex review finding: `FreeTier.register()` reports success whether + // it minted/rotated a credential or just returned an existing valid one unchanged (its + // idempotent fast path). Disposing every session/LSP/PTY/MCP connection in the process on the + // idempotent path is pure disruption for zero benefit, since no provider loader has anything new + // to re-read. + test("disposes every instance when registration actually mints a new credential", async () => { + mockCredentialsSequence(undefined, { apiKey: "sk-new", baseURL: "https://gateway.test", installSecret: "s" }) + mockRegister(async () => ({ apiKey: "sk-new", baseURL: "https://gateway.test", installSecret: "s" })) + const disposeAll = spyOn(Instance, "disposeAll") + try { + const response = await app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ ok: true }) + expect(disposeAll).toHaveBeenCalledTimes(1) + } finally { + disposeAll.mockRestore() + } + }) + + test("skips instance disposal when registration is idempotent (credential unchanged)", async () => { + const existing = { apiKey: "sk-existing", baseURL: "https://gateway.test", installSecret: "s" } + mockCredentialsSequence(existing, existing) + mockRegister(async () => existing) + const disposeAll = spyOn(Instance, "disposeAll") + try { + const response = await app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ ok: true }) + // No staleProviders and no disposal: nothing changed, so there's nothing for a provider + // loader to re-read, and no reason to tear down live sessions/LSPs/PTYs/MCP connections. + expect(disposeAll).not.toHaveBeenCalled() + } finally { + disposeAll.mockRestore() + } + }) + // altimate_change end }) describe("disclosure hash", () => { From 2ef2c8a438ce2505d9fd9518210a23a64e625e1e Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 22 Sep 2026 22:54:22 -0700 Subject: [PATCH 08/27] fix: attached-TUI auth headers, explicit model authority, dismissed-picker guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the TUI half of the PR #1361 review findings: - Attached TUI registration drops auth (`altimate-onboarding.tsx`): the HTTP fallback for `registerAltimateBase()` (used when there's no host-injected worker RPC, i.e. `opencode attach`) called `sdk.fetch` directly with no auth headers, so it 401ed against a password-protected server. `sdk.tsx` now exposes the same `headers` `createOpencodeClient` already bakes into every typed SDK call; the fallback merges them in. New test asserts the Authorization header is actually sent. - Explicit model must stay authoritative (`local.tsx`'s `currentModel()`): the stale-public-Zen -> registered-Base substitution applied uniformly to whatever `fallbackModel()`/a persisted pick/an agent's own `model` resolved to — so an explicit `--model opencode/x` (or config `model`, or an agent's configured `model`) pointing at the now-broken keyless Zen tier got silently rewritten to Base instead of staying put. Split `fallbackModel()`'s explicit args/config checks into their own `explicitFallbackModel()` memo so `currentModel()` can route only the two truly implicit sources (a persisted per-agent pick, and the implicit recents/allowlist fallback) through the substitution. `Provider.defaultModel()`, ACP's `defaultModelFromConfig()`, and `SessionPrompt.lastModel()` were already correct (their explicit sources short-circuit before any substitution logic runs) — verified, not changed. New full-mount test (`explicit-model-authoritative.test.tsx`) exercises `currentModel()` itself with Base actually registered, confirming `--model opencode/x` stays put and — as a control — that the same catalogue substitutes Base when nothing explicit overrides it. - Dismissed picker (`selectAltimateBase()`): registration and bootstrap are both async; if the originating picker was dismissed or replaced while either was in flight, the function still went on to select the model and call `dialog.clear()` — closing whatever the user has open now, not the picker that started this. Snapshots the top-of-stack dialog by reference at entry and re-checks it after every await, bailing out silently the moment it no longer matches. 2 new tests (dismissed during registration, replaced during bootstrap). - Test isolation (`select-altimate-base.test.ts`): `selectAltimateBase()` calls `markSetupComplete()` on every successful path, flipping the module-global `setupComplete` signal that Bun's test runner shares across every file in the run (already handled in `local.test.ts` around its own `markSetupComplete()` calls, but missing here). Added the same `resetSetupComplete()` in `afterEach`. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tui/src/component/altimate-onboarding.tsx | 27 ++- packages/tui/src/context/local.tsx | 79 ++++--- packages/tui/src/context/sdk.tsx | 6 + .../component/select-altimate-base.test.ts | 110 +++++++++- .../explicit-model-authoritative.test.tsx | 207 ++++++++++++++++++ 5 files changed, 390 insertions(+), 39 deletions(-) create mode 100644 packages/tui/test/context/explicit-model-authoritative.test.tsx diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index 59acf45047..02ec92001f 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -440,7 +440,9 @@ const REGISTER_FAILURE_MESSAGE = "Could not set up Altimate Base. Try again, or * context/sdk.tsx) when available. An attached TUI has no in-process worker to call * (cli/cmd/attach.ts never provides it), so it falls back to the server's own * `POST /altimate/base/register` route over the same transport (`sdk.fetch`/`sdk.url`) everything - * else uses. + * else uses — including `sdk.headers`, the same Basic-auth headers `createOpencodeClient` bakes + * into every typed SDK call, so this raw fetch doesn't 401 against a password-protected attached + * server the way a bare `sdk.fetch` call would. */ async function registerAltimateBase(sdk: ReturnType): Promise { try { @@ -449,9 +451,11 @@ async function registerAltimateBase(sdk: ReturnType): Promise undefined)) as @@ -494,6 +498,12 @@ export function useAltimateBaseDisclosureNotice() { * the provider dialog): register if needed, refresh provider state, confirm the model actually * came up, then select it. An error at any step is shown via toast and the selection is left * alone — never a partial/failed switch. + * + * Also guards against the originating picker going away mid-flight (see `stillOpen()` below): + * registration and bootstrap are both async, and if the user dismissed the picker or opened + * something else in the meantime, neither the model switch nor `dialog.clear()` should run — + * `clear()` would otherwise close whatever the user has open NOW, not the picker that started + * this. */ export async function selectAltimateBase(input: { sdk: ReturnType @@ -502,14 +512,27 @@ export async function selectAltimateBase(input: { toast: ReturnType dialog: ReturnType }): Promise { + // altimate_change start — Codex review finding: snapshot the top-of-stack item BY REFERENCE at + // entry; `stillOpen()` re-checks it after every await below. `dialog.replace()`/`clear()` always + // install a brand-new stack (and a dismissal empties it), so any of those happening in between — + // whether the user backed out or a different feature took the dialog stack over — makes this + // reference comparison false, and every call site below bails out silently: no toast (there is + // nothing left for it to be about), no model switch, no `clear()`. + const originatingDialog = input.dialog.stack.at(-1) + const stillOpen = () => input.dialog.stack.at(-1) === originatingDialog + // altimate_change end + const outcome = await registerAltimateBase(input.sdk) + if (!stillOpen()) return false if (!outcome.ok) { input.toast.show({ variant: "error", message: outcome.message }) return false } await input.sdk.client.instance.dispose().catch(() => {}) + if (!stillOpen()) return false await input.sync.bootstrap().catch(() => {}) + if (!stillOpen()) return false const available = input.sync.data.provider.some( (provider) => provider.id === "altimate-free" && Boolean(provider.models?.["altimate-base"]), ) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index def955c7c9..20b7d48bf8 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -365,14 +365,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return !!provider?.models[model.modelID] } - function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) { - for (const modelFn of modelFns) { - const model = modelFn() - if (!model) continue - if (isModelValid(model)) return model - } - } - function createAgent() { const agents = createMemo(() => sync.data.agent.filter((agent) => agent.mode !== "subagent" && !agent.hidden)) const visibleAgents = createMemo(() => sync.data.agent.filter((agent) => !agent.hidden)) @@ -598,26 +590,29 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } // altimate_change end - const fallbackModel = createMemo(() => { + // altimate_change start — Codex review finding: `currentModel()` below used to apply the + // stale-Zen -> Base substitution uniformly to whatever `fallbackModel()` returned, but + // `fallbackModel()` returns an EXPLICIT `--model`/config `model` pick verbatim when either is + // set (below) — so an explicit ask for the now-broken public Zen tier was silently rewritten + // to Base instead of surfacing as broken. Mirrors just those two explicit checks (not + // `fallbackModel()`'s recents/allowlist implicit tail) so `currentModel()` can tell them apart + // without duplicating `fallbackModel()`'s own logic inline. + const explicitFallbackModel = createMemo(() => { if (args.model) { const { providerID, modelID } = parseModel(args.model) - if (isModelValid({ providerID, modelID })) { - return { - providerID, - modelID, - } - } + if (isModelValid({ providerID, modelID })) return { providerID, modelID } } - if (sync.data.config.model) { const { providerID, modelID } = parseModel(sync.data.config.model) - if (isModelValid({ providerID, modelID })) { - return { - providerID, - modelID, - } - } + if (isModelValid({ providerID, modelID })) return { providerID, modelID } } + return undefined + }) + // altimate_change end + + const fallbackModel = createMemo(() => { + const explicit = explicitFallbackModel() // altimate_change — see its declaration above + if (explicit) return explicit // altimate_change start — Base is excluded only by an actual enabled_providers/disabled_providers // verdict, which `sync.data.provider` (server-built) already reflects. The mere presence of @@ -678,21 +673,35 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // be a stale keyless public-Zen pick; replace it with registered Base the same way // `fallbackModel()`'s recents loop does, rather than replaying a model OpenCode Zen now // rejects outright. A credentialed/paid selection is untouched. + // + // Codex review finding: that substitution must only ever reach an IMPLICIT pick — the + // persisted per-agent selection below, or `fallbackModel()`'s own implicit recents/allowlist + // tail — never something explicitly asked for: an agent's own configured `model` field, or + // `--model`/config `model` (see `explicitFallbackModel` above `fallbackModel()`). Each + // candidate is checked in the SAME priority order this memo used before; only the two + // implicit branches route through `substituteStaleZen`. + function substituteStaleZen(model: { providerID: string; modelID: string } | undefined) { + if (!model) return model + const provider = sync.data.provider.find((candidate) => candidate.id === model.providerID) + if (provider && isPublicZenProvider(provider) && isModelValid(ALTIMATE_BASE_MODEL)) { + return { ...ALTIMATE_BASE_MODEL } + } + return model + } + const currentModel = createMemo(() => { const a = agent.current() - const resolved = - getFirstValidModel( - () => a && modelStore.model[a.name], - () => a && a.model, - fallbackModel, - ) ?? undefined - if (resolved) { - const provider = sync.data.provider.find((candidate) => candidate.id === resolved.providerID) - if (provider && isPublicZenProvider(provider) && isModelValid(ALTIMATE_BASE_MODEL)) { - return { ...ALTIMATE_BASE_MODEL } - } - } - return resolved + + const persistedAgentPick = a ? modelStore.model[a.name] : undefined + if (persistedAgentPick && isModelValid(persistedAgentPick)) return substituteStaleZen(persistedAgentPick) + + const agentConfiguredModel = a?.model + if (agentConfiguredModel && isModelValid(agentConfiguredModel)) return agentConfiguredModel + + const explicit = explicitFallbackModel() + if (explicit) return explicit + + return substituteStaleZen(fallbackModel()) }) // altimate_change end diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index d2fab995e7..88ffa4d264 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -208,6 +208,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ event: emitter, fetch: props.fetch ?? fetch, url: props.url, + // altimate_change — the auth headers `createOpencodeClient` above bakes into every typed SDK + // call (Basic auth for an attached, password-protected server — see cli/cmd/attach.ts). A raw + // `sdk.fetch` call bypasses the client entirely, so a caller hitting an untyped route + // directly (component/altimate-onboarding.tsx's `registerAltimateBase` HTTP fallback) needs + // these to attach them itself, or it 401s against a password-protected server. + headers: props.headers, registerAltimateBase: props.registerAltimateBase, // altimate_change — see the declaration above } }, diff --git a/packages/tui/test/component/select-altimate-base.test.ts b/packages/tui/test/component/select-altimate-base.test.ts index 288c637be0..f76f6d1a8e 100644 --- a/packages/tui/test/component/select-altimate-base.test.ts +++ b/packages/tui/test/component/select-altimate-base.test.ts @@ -5,13 +5,25 @@ // this same file), so testing it here covers every call site's outcome without needing an `agent` // in the render harness (selectModel()'s underlying local.model.set() is agent-scoped, which the // component-mount fixtures used elsewhere in this package don't set up). -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" import type { useSDK } from "../../src/context/sdk" import type { useSync } from "../../src/context/sync" import type { useLocal } from "../../src/context/local" import type { useToast } from "../../src/ui/toast" import type { useDialog } from "../../src/ui/dialog" -import { selectAltimateBase } from "../../src/component/altimate-onboarding" +import { selectAltimateBase, resetSetupComplete } from "../../src/component/altimate-onboarding" + +// altimate_change start — Codex/CodeRabbit review finding: `selectAltimateBase()` calls +// `markSetupComplete()` on every successful path, which flips the module-global `setupComplete` +// solid-js signal declared in altimate-onboarding.tsx. That signal is shared with +// `test/context/local.test.ts` (imported there too, and reset around every `markSetupComplete()` +// call it makes) and read by every other suite through `useReady()`/`useSetupComplete()` — Bun's +// test runner shares one module registry across every test file in the run, so a test here left +// it `true` for whichever suite runs next. Restoring it here mirrors local.test.ts's own pattern. +afterEach(() => { + resetSetupComplete() +}) +// altimate_change end function fakeCollaborators(options: { registerAltimateBase?: () => Promise<{ ok: true } | { ok: false; result: "network" | "error"; message: string }> @@ -20,6 +32,9 @@ function fakeCollaborators(options: { * "the credential was minted, but the catalogue hasn't caught up yet" edge case sets this to * false. */ becomesAvailableAfterBootstrap?: boolean + // altimate_change — the Basic-auth headers an attached, password-protected server needs (see + // cli/cmd/attach.ts + context/sdk.tsx's `headers`). + headers?: RequestInit["headers"] }) { const modelSetCalls: unknown[] = [] let dialogClearCount = 0 @@ -30,6 +45,14 @@ function fakeCollaborators(options: { const becomesAvailable = options.becomesAvailableAfterBootstrap ?? true const providerState: { id: string; models: Record }[] = [] + // altimate_change start — Codex review finding: `selectAltimateBase()`'s liveness guard compares + // `dialog.stack.at(-1)` by reference before and after each await. The fake stack starts as a + // single "the originating picker" item; `simulateDismiss()`/`simulateReplace()` let a test swap + // it out mid-flight, exactly as a real Escape/backdrop dismissal or an unrelated feature taking + // over the dialog stack would. + let stack: { readonly id: string }[] = [{ id: "originating-picker" }] + // altimate_change end + const sdk = { client: { instance: { @@ -40,6 +63,7 @@ function fakeCollaborators(options: { }, fetch: options.fetchImpl ?? (async () => new Response("should not be called", { status: 500 })), url: "http://test", + headers: options.headers, // altimate_change — see the option's declaration above registerAltimateBase: options.registerAltimateBase, } as unknown as ReturnType @@ -70,11 +94,17 @@ function fakeCollaborators(options: { const dialog = { clear: () => { dialogClearCount++ + stack = [] // altimate_change — mirrors the real clearAll()'s empty stack }, replace: (..._args: unknown[]) => { dialogReplaceCount++ + stack = [{ id: "replaced" }] // altimate_change — mirrors the real replace()'s new sole item return true }, + get stack() { + // altimate_change — see `stack`'s declaration above + return stack + }, } as unknown as ReturnType return { @@ -97,6 +127,14 @@ function fakeCollaborators(options: { get dialogReplaceCount() { return dialogReplaceCount }, + // altimate_change start — see `stack`'s declaration above + simulateDismiss() { + stack = [] + }, + simulateReplace() { + stack = [{ id: "something-else" }] + }, + // altimate_change end } } @@ -136,6 +174,29 @@ describe("selectAltimateBase", () => { expect(fakes.dialogReplaceCount).toBe(0) }) + // altimate_change start — Codex review finding: the HTTP fallback used a bare `sdk.fetch` call + // with no auth headers, so it 401ed against an attached, password-protected server (`opencode + // attach` — cli/cmd/attach.ts) even though `sdk.headers` (the same Basic-auth headers + // `createOpencodeClient` bakes into every typed SDK call) was one merge away. + test("an attached TUI's HTTP fallback sends the SDK's auth headers", async () => { + let seenHeaders: Headers | undefined + const fakes = fakeCollaborators({ + registerAltimateBase: undefined, + headers: { Authorization: "Basic dGVzdDpwYXNz" }, + fetchImpl: (async (_input: RequestInfo | URL, init?: RequestInit) => { + seenHeaders = new Headers(init?.headers) + return Response.json({ ok: true }) + }) as typeof fetch, + }) + + const result = await selectAltimateBase(fakes) + + expect(result).toBe(true) + expect(seenHeaders?.get("Authorization")).toBe("Basic dGVzdDpwYXNz") + expect(seenHeaders?.get("Content-Type")).toBe("application/json") + }) + // altimate_change end + test("a registration failure shows the toast and leaves the model unchanged", async () => { const fakes = fakeCollaborators({ registerAltimateBase: async () => ({ ok: false, result: "network", message: "offline" }), @@ -169,5 +230,50 @@ describe("selectAltimateBase", () => { expect(fakes.modelSetCalls).toHaveLength(0) expect(fakes.dialogClearCount).toBe(0) }) + + // altimate_change start — Codex review finding: registration and bootstrap are both async; + // if the originating picker went away (dismissed, or replaced by something else) while either + // was in flight, `selectAltimateBase()` must not select the model or call `dialog.clear()` — + // that `clear()` would close whatever the user has open NOW, not the picker that started this. + test("does not select the model or clear the dialog if the picker was dismissed while registration was in flight", async () => { + const fakes = fakeCollaborators({ + registerAltimateBase: async () => { + // The user pressed Escape while this await was pending. + fakes.simulateDismiss() + return { ok: true } + }, + }) + + const result = await selectAltimateBase(fakes) + + expect(result).toBe(false) + expect(fakes.modelSetCalls).toHaveLength(0) + expect(fakes.dialogClearCount).toBe(0) + expect(fakes.disposed).toBe(false) + expect(fakes.bootstrapped).toBe(false) + expect(fakes.toastCalls).toHaveLength(0) + }) + + test("does not select the model or clear the dialog if something else replaced the dialog during bootstrap", async () => { + const fakes = fakeCollaborators({ + registerAltimateBase: async () => ({ ok: true }), + }) + const realBootstrap = fakes.sync.bootstrap + fakes.sync.bootstrap = async () => { + const result = await realBootstrap() + // A different feature (e.g. the command palette) took over the dialog stack while this + // await was pending. + fakes.simulateReplace() + return result + } + + const result = await selectAltimateBase(fakes) + + expect(result).toBe(false) + expect(fakes.modelSetCalls).toHaveLength(0) + expect(fakes.dialogClearCount).toBe(0) + expect(fakes.toastCalls).toHaveLength(0) + }) + // altimate_change end }) // altimate_change end diff --git a/packages/tui/test/context/explicit-model-authoritative.test.tsx b/packages/tui/test/context/explicit-model-authoritative.test.tsx new file mode 100644 index 0000000000..d96e9c28b9 --- /dev/null +++ b/packages/tui/test/context/explicit-model-authoritative.test.tsx @@ -0,0 +1,207 @@ +/** @jsxImportSource @opentui/solid */ +// altimate_change start — Codex review finding: `currentModel()` applies the stale-public-Zen -> +// registered-Base substitution to whatever `fallbackModel()` (or a persisted/agent-config pick) +// resolves to. `fallbackModel()` returns an explicit `--model`/config `model` pick VERBATIM, ahead +// of any Zen check — but `currentModel()` used to re-apply the substitution on TOP of that result +// regardless of where it came from, so an explicit `--model opencode/x` pointing at the (now +// broken) keyless public Zen tier got silently rewritten to Altimate Base instead of staying +// `opencode/x` (or surfacing as broken). This mounts the real provider tree (not just the pure +// helpers `local.test.ts` covers) so the fix is verified against `currentModel()` itself, with +// Altimate Base actually registered and available — the exact condition that used to trigger the +// wrongful substitution. +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { tmpdir } from "../fixture/fixture" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" +import { createEventSource, createFetch, directory, json } from "../fixture/tui-sdk" + +async function waitUntil(predicate: () => boolean, timeout = 2_000) { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(5) + } +} + +function makeModel(id: string, providerID: string) { + return { + id, + providerID, + name: id, + family: providerID, + status: "active", + capabilities: {}, + cost: { input: 0, output: 0 }, + limit: { context: 65_536, output: 4_096 }, + } +} + +// The keyless public Zen provider — matches `isPublicZenProvider()`'s exact identity check +// (local.tsx): id "opencode", `options.apiKey === "public"`, no real `key`. +const ZEN_PROVIDER = { + id: "opencode", + name: "Legacy Zen", + options: { apiKey: "public" }, + models: { "zen-model": makeModel("zen-model", "opencode") }, + env: [], +} + +// Registered Altimate Base — present and available, the exact condition that makes +// `currentModel()`'s substitution kick in for an IMPLICIT pick. +const BASE_PROVIDER = { + id: "altimate-free", + name: "Altimate Base", + models: { "altimate-base": makeModel("altimate-base", "altimate-free") }, + env: [], +} + +const AGENT = { + name: "build", + mode: "primary" as const, + hidden: false, + permission: {}, + options: {}, +} + +async function mount(args: { model?: string }) { + const [ + { KVProvider }, + { LocalProvider, useLocal }, + { ArgsProvider }, + { ThemeProvider }, + { ToastProvider }, + { SDKProvider }, + { ProjectProvider }, + { SyncProvider }, + { RouteProvider }, + { ExitProvider }, + { TuiConfigProvider }, + ] = await Promise.all([ + import("../../src/context/kv"), + import("../../src/context/local"), + import("../../src/context/args"), + import("../../src/context/theme"), + import("../../src/ui/toast"), + import("../../src/context/sdk"), + import("../../src/context/project"), + import("../../src/context/sync"), + import("../../src/context/route"), + import("../../src/context/exit"), + import("../../src/config"), + ]) + + const tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + await Bun.write(path.join(state, "kv.json"), "{}") + // No recents at all: the only way `currentModel()` could resolve anything here is through + // `args.model` (explicit) or the implicit allowlist fallback — isolates the explicit path. + await Bun.write(path.join(state, "model.json"), JSON.stringify({ recent: [] })) + + const inner = createFetch((url) => { + if (url.pathname === "/instance/dispose") return json({}) + if (url.pathname === "/config/providers") + return json({ providers: [ZEN_PROVIDER, BASE_PROVIDER], default: {} }) + if (url.pathname === "/provider") + return json({ all: [ZEN_PROVIDER, BASE_PROVIDER], default: {}, connected: ["opencode", "altimate-free"] }) + if (url.pathname === "/agent") return json([AGENT]) + if (url.pathname === "/project/proj_test/directories") return json([]) + return undefined + }) + const source = createEventSource() + + let localAccessor: ReturnType | undefined + function Capture() { + localAccessor = useLocal() + return null + } + + const app = await testRender(() => ( + + {}}> + + + + + + + + + + + + + + + + + + + + + + + + )) + await app.renderOnce() + await waitUntil(() => localAccessor !== undefined && localAccessor.model.ready) + const local = localAccessor! + + return { + local, + async cleanup() { + app.renderer.destroy() + await local.model.persisted().catch(() => {}) + await tmp[Symbol.asyncDispose]() + }, + } +} + +test("an explicit --model pointing at the public Zen tier stays put even though registered Base is available", async () => { + const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME + const isolatedState = await tmpdir() + process.env.OPENCODE_TEST_STATE_HOME = isolatedState.path + + const mounted = await mount({ model: "opencode/zen-model" }) + try { + // The bug this guards against: this used to resolve to `{ providerID: "altimate-free", + // modelID: "altimate-base" }` instead, even though the user explicitly asked for + // `opencode/zen-model` via `--model`. + await waitUntil(() => local_model_is(mounted, "opencode", "zen-model")) + expect(mounted.local.model.current()).toEqual({ providerID: "opencode", modelID: "zen-model" }) + } finally { + await mounted.cleanup() + if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME + else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome + await isolatedState[Symbol.asyncDispose]() + } +}) + +test("with no explicit --model, the same catalogue resolves the implicit fallback to registered Base (control)", async () => { + const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME + const isolatedState = await tmpdir() + process.env.OPENCODE_TEST_STATE_HOME = isolatedState.path + + const mounted = await mount({}) + try { + // Confirms the harness's Base-registered/no-recents setup actually exercises the + // substitution path when nothing explicit overrides it — i.e. that the first test above is + // not passing merely because Base was never reachable at all. + await waitUntil(() => local_model_is(mounted, "altimate-free", "altimate-base")) + expect(mounted.local.model.current()).toEqual({ providerID: "altimate-free", modelID: "altimate-base" }) + } finally { + await mounted.cleanup() + if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME + else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome + await isolatedState[Symbol.asyncDispose]() + } +}) + +function local_model_is(mounted: Awaited>, providerID: string, modelID: string) { + const current = mounted.local.model.current() + return current?.providerID === providerID && current?.modelID === modelID +} +// altimate_change end From 367201a4dfe65ac2530ec390ef5880a1a3a540b0 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 22 Sep 2026 22:54:34 -0700 Subject: [PATCH 09/27] docs: state the new Altimate Base auto-registration behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every doc still promised the retired flow — "registration only after an explicit confirmation that defaults to No" — after auto-registration with no consent dialog shipped. Updates the security FAQ, quickstart, provider docs, README, and network reference to state the new behavior (a fresh install with no model of its own registers Altimate Base automatically, the disclosure is shown once) and the actual opt-outs (`ALTIMATE_BASE_AUTO_REGISTER=0`, `altimate providers logout altimate-base`, `enabled_providers`/`disabled_providers`) — noting that only the env var stops the background registration call itself. `docs/docs/configure/providers.md` also drops the stale claim that `declinedManagedBaseDefault` still keeps public Zen ahead of registered Base for a migrating Big Pickle user — Zen's keyless tier rejects unauthenticated traffic outright now, so there's no working choice left to honor; the flag is read for backward compatibility only. `docs/docs/reference/telemetry.md`: corrected `altimate_base_registration`'s "after consent" wording, and marked `altimate_base_confirm_shown` / `altimate_base_choice` / the `altimate_base_back` picker trigger as legacy — still defined in the event schema, but no longer emitted now that the consent dialog they recorded is gone. Logging/retention wording is unchanged verbatim from `ALTIMATE_BASE_DISCLOSURE` (verified against `test/altimate/altimate-base-disclosure-claims.test.ts`, which checks the docs note stays a superset of the in-app notice). Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 ++- docs/docs/configure/providers.md | 40 ++++++++++++++++--------- docs/docs/getting-started/quickstart.md | 2 +- docs/docs/reference/network.md | 2 +- docs/docs/reference/security-faq.md | 17 +++++++---- docs/docs/reference/telemetry.md | 8 ++--- 6 files changed, 46 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 452a2bb577..fd0a2fb03e 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,9 @@ altimate # Launch the TUI Altimate Base is the free, no-signup option. It is rate limited, and its requests and responses are logged and may be used to improve Altimate products and services; do not send secrets or -confidential code. The setup dialog shows this disclosure and defaults to **No** before registering. +confidential code. If you don't pick another provider, a fresh install registers it automatically +— no dialog to accept — and prints this notice once. Opt out with `ALTIMATE_BASE_AUTO_REGISTER=0`, +`altimate providers logout altimate-base`, or `disabled_providers` in config. Or set an environment variable directly: ```bash diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index e068114f4f..ffbe43a5f0 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -62,23 +62,35 @@ If you need stronger guarantees — no training on your data, metadata-only rete [Altimate LLM Gateway](https://help.altimate.ai/datamates/user-guide/components/llm-gateway/) instead. -Choose **Altimate Base** from the first-run picker or `/connect`. A disclosure is shown before any -registration request; **No** is selected by default. After registration, the model is available as +Choose **Altimate Base** from the first-run picker or `/connect` — or do nothing at all: a fresh +install with no model of its own registers it automatically, before your first prompt, so it works +the same way headlessly (`run`, `serve`, `acp`, `web`). There is no confirmation dialog to accept. +The disclosure above is printed once per install the first time this happens — a toast in the TUI, +or a one-line notice to stderr for a headless entrypoint (skipped when `ALTIMATE_CLI_CLIENT=datamates`, +since the VS Code extension shows its own notice). After registration, the model is available as `altimate-free/altimate-base` and becomes the free fallback when no paid Altimate Gateway or explicit model is selected. Big Pickle is retired as a new selection — it no longer appears in the picker or the full model catalog for users choosing a model for the first time. Users already on -Big Pickle are still detected on launch and offered Altimate Base through the same consent gate. -If you decline the default switch, `declinedManagedBaseDefault: true` in the state directory's `model.json` keeps public Zen ahead of registered Base for headless and ACP defaults, with Base used only as a last resort; accepting migration or explicitly selecting Base clears the flag. - -Registration is per machine, not per host. Once any host on a machine has registered Altimate -Base (the TUI's consent gate, or the HTTP registration route used by IDE integrations), every -other host on that machine treats Base as the default free model without showing its own -prompt: the TUI migrates an implicit free default silently, and headless `altimate run`, -`altimate serve`, and ACP sessions resolve to Base ahead of the keyless public Zen tier. The -disclosure is therefore shown once per machine, by whichever host registers. Declining as -described above applies to all hosts on the machine too. Administrators auditing a fleet can -check `model.json` for `declinedManagedBaseDefault` and the registered `altimate-free` provider -entry in `auth.json`. +Big Pickle are migrated to Altimate Base the same automatic way once it registers, not through a +separate confirmation: `declinedManagedBaseDefault` is still read from `model.json` for backward +compatibility, but no longer changes the outcome — the keyless public Zen tier rejects +unauthenticated traffic outright, so there is no longer a working "stay on public Zen" choice to +honor. + +To opt out: set `ALTIMATE_BASE_AUTO_REGISTER=0` before this install first registers Base, run +`altimate providers logout altimate-base` afterward, or keep it out of your own choices with +`enabled_providers` / `disabled_providers`. The env var is the only one of these that stops the +background registration call itself; the other two only control whether Base can be *selected* as +your model on this machine — logging out also un-registers it (it will auto-register again on the +next launch unless the env var is also set). + +Registration is per machine, not per host: once any host on a machine has registered Altimate +Base (auto-registration on any entrypoint, or the HTTP registration route used by IDE +integrations), every other host on that machine treats Base as the default free model too — the +TUI migrates an implicit free default silently, and headless `altimate run`, `altimate serve`, and +ACP sessions resolve to Base ahead of the keyless public Zen tier. Logging out on any host applies +to all hosts on the machine, since the credential is a single shared file. Administrators auditing +a fleet can check for the registered `altimate-free` provider entry in `auth.json`. Official release binaries embed the current gateway endpoint at build time. Operators and local development can override it without changing code: diff --git a/docs/docs/getting-started/quickstart.md b/docs/docs/getting-started/quickstart.md index 8067420bbc..b0f8520d3d 100644 --- a/docs/docs/getting-started/quickstart.md +++ b/docs/docs/getting-started/quickstart.md @@ -25,7 +25,7 @@ On a fresh install, a welcome panel appears with a curated 6-provider picker: - **Altimate LLM Gateway** *(recommended)* — 10M tokens free, no API keys. Routes to the best model per task across Sonnet, Opus, GPT-5, and more. Sign-in opens a browser tab; complete Google or email signup and you're back in the TUI. If your terminal can't open a browser (SSH / tmux / WSL), the CLI prints the URL — paste it into a browser on your desktop. - **Anthropic** / **OpenAI** / **Google** — paste an API key or OAuth in. -- **Altimate Base** — a hosted open model, free and rate limited, with no signup or API key. Requests and responses may be logged and used to improve Altimate's products, so do not send secrets or confidential code. Registration happens only after an explicit confirmation that defaults to **No**. +- **Altimate Base** — a hosted open model, free and rate limited, with no signup or API key. Requests and responses may be logged and used to improve Altimate's products, so do not send secrets or confidential code. If you don't pick another provider, a fresh install registers it automatically — there is no confirmation dialog — and prints this notice once. Opt out with `ALTIMATE_BASE_AUTO_REGISTER=0`, `altimate providers logout altimate-base`, or `disabled_providers` (see [providers](../configure/providers.md#altimate-base)). - **Search all providers…** — full picker if you need Bedrock, Databricks AI Gateway, Cloudflare AI Gateway, Snowflake Cortex, DigitalOcean Inference, etc. Or set an environment variable and skip the picker: diff --git a/docs/docs/reference/network.md b/docs/docs/reference/network.md index 4f1bc30588..028d901aea 100644 --- a/docs/docs/reference/network.md +++ b/docs/docs/reference/network.md @@ -41,7 +41,7 @@ altimate needs outbound HTTPS access to: | Destination | Purpose | |-------------|---------| | Your LLM provider API | Model inference (Anthropic, OpenAI, etc.) | -| Official Altimate Base gateway (embedded in release), or the host set by `ALTIMATE_BASE_GATEWAY_URL` | Altimate Base registration and inference when you explicitly enable Altimate Base | +| Official Altimate Base gateway (embedded in release), or the host set by `ALTIMATE_BASE_GATEWAY_URL` | Altimate Base registration (automatic on a fresh install with no model of its own, unless `ALTIMATE_BASE_AUTO_REGISTER=0`) and inference | | `registry.npmjs.org` | Package updates | | `models.dev` | Model catalog (can be disabled) | | Your warehouse endpoints | Database connections | diff --git a/docs/docs/reference/security-faq.md b/docs/docs/reference/security-faq.md index 9611255454..204293cc60 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -13,11 +13,16 @@ Answers to the most common security questions about running Altimate Code in you Altimate Code sends prompts and context to the LLM provider you configure (Anthropic, OpenAI, Azure OpenAI, AWS Bedrock, etc.). **You choose the provider.** No data is sent anywhere else except optional [telemetry](#what-telemetry-is-collected), which contains no code, queries, or credentials. -Altimate Base is an optional hosted provider. Its confirmation dialog explains that requests and -responses are logged and may be used to improve Altimate products and services; do not send -secrets or confidential code. The dialog defaults to **No**, and no registration request is made -unless you explicitly accept. This request logging is part of the Altimate Base service and is -separate from anonymous product telemetry. +Altimate Base is Altimate's own hosted free model. A fresh install with no model of its own +registers it automatically — there is no confirmation dialog to accept. Requests and responses are +logged and may be used to improve Altimate products and services, including the model; secrets are +automatically masked before storage, but don't rely on it — avoid sending secrets or confidential +code. This notice is printed once — a toast in the TUI, or a one-line stderr notice the first time +a headless entrypoint (`run`, `serve`, `acp`, `web`) registers it — and is part of the Altimate +Base service, separate from anonymous product telemetry. To opt out: set +`ALTIMATE_BASE_AUTO_REGISTER=0` before Base ever registers, run `altimate providers logout +altimate-base` afterward, or exclude it from your own model choices with `enabled_providers` / +`disabled_providers` (see [providers](../configure/providers.md#altimate-base)). **What identifies you to Altimate Base.** Registration sends a SHA-256 hash of a locally generated installation secret — the secret itself never leaves your machine. That hash is stable, so logged @@ -110,7 +115,7 @@ You can also configure per-agent permissions. For example, restrict the `analyst | Destination | Purpose | |-------------|---------| | Your configured LLM provider | Model inference | -| Altimate Base gateway | Registration and inference only after you explicitly enable Altimate Base | +| Altimate Base gateway | Registration (automatic on a fresh install with no model of its own) and inference | | Your warehouse endpoints | Database queries | | `registry.npmjs.org` | Package updates | | `models.dev` | Model catalog (can be disabled) | diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index 5f1dad6e69..cd46bb4f48 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -45,7 +45,7 @@ We collect the following categories of events: | `first_launch` | Fired once on the first CLI run after an install or upgrade, triggered by a marker file the installer wrote — the installers themselves send nothing and contact no telemetry endpoint. Contains the installed version, `is_upgrade`, and `install_method` (`curl`, `powershell`, `npm`, `vscode-extension`, `local` for `install --binary`, or `unknown` for markers written before the field existed). `vscode-extension` starts appearing only once an extension build containing the marker write ships, so a zero share for it means the extension has not rolled out yet rather than no extension installs. No PII. **Reading `is_upgrade`:** it means "this machine had run altimate-code before", probed as whether `~/.altimate/machine-id` already existed — *not* "a binary was already present". A reinstall onto a machine that ever ran the CLI reports `is_upgrade: true`, and `altimate uninstall` leaves `machine-id` in place, so a metric excluding upgrades counts installs **per previously-unseen machine** and undercounts reinstalls onto known ones. (`is_upgrade` is a boolean in the event schema; it arrives in Application Insights `customDimensions` as a string, so KQL filters read `tostring(customDimensions.is_upgrade) != "true"`.) Delivery is at-most-once: the marker is deleted before the event flushes, so a process that dies first loses that install rather than re-firing it every launch. Local `--binary` installs report `version: "local"`. | | `startup_ready` | Fired once per process when the top-level command can serve its first request or frame (`serve` listening, TUI transport resolved, `run` about to send its first prompt). Contains the command name, `duration_ms` since process start, and `fresh_install` (true when this process also emitted a non-upgrade `first_launch`). No PII. | | `event_loop_stall` | A 250 ms monitor tick fired more than 1 s late, meaning the event loop was blocked for that long (for example an in-process dependency install). Contains the command name, `thread` (`main` or `worker`), `blocked_ms`, and `since_start_ms`. Capped at 20 per thread (the main thread and the TUI server worker each keep their own counter). No PII. | -| `altimate_base_registration` | Timing and outcome of each Altimate Base registration after consent (concurrent calls share one result, and a still-valid cached credential reports `success` without a network round trip): `result` (`success`, `network`, `http`, `response`, `cancelled`, `configuration`, or `error`), `duration_ms`, and the HTTP `status` when the result is `http`. Distinct from `altimate_base_register_result`, which records the onboarding-flow outcome. No gateway URL, response body, credential, or error text is included. | +| `altimate_base_registration` | Timing and outcome of each Altimate Base registration, whether triggered automatically at startup or by an explicit picker selection (concurrent calls share one result, and a still-valid cached credential reports `success` without a network round trip): `result` (`success`, `network`, `http`, `response`, `cancelled`, `configuration`, or `error`), `duration_ms`, and the HTTP `status` when the result is `http`. Distinct from `altimate_base_register_result`, which records the onboarding-flow outcome. No gateway URL, response body, credential, or error text is included. | | `task_outcome_signal` | Behavioral quality signal at session end — accepted, error, abandoned, or cancelled. Includes tool count, step count, duration, and last tool category. No user content. | | `task_classified` | Intent classification of the first user message using keyword matching — category (e.g. `debug_dbt`, `write_sql`, `optimize_query`), confidence score, and detected warehouse type. No user text is sent — only the classified category. | | `tool_chain_outcome` | Aggregated tool execution sequence at session end — ordered tool names (capped at 50), error count, recovery count, final outcome, duration, and cost. No tool arguments or outputs. | @@ -55,10 +55,10 @@ We collect the following categories of events: | `validator_check` | A completion-gate validator ran on session end — validator name, `ok` boolean, step, retry count, `enforced` flag (false in shadow mode), and structured `details` (model counts, elapsed time, concurrency limit — no SQL or model content). Only emitted when `ALTIMATE_VALIDATORS_ENABLED=1` or `ALTIMATE_VALIDATORS_SHADOW=1`. See [Validators](../data-engineering/validators.md). | | `validator_retries_exhausted` | A session terminated with unresolved validator failures after exhausting the synthetic-retry budget — names of the failing validators (no failure body content). | | `onboarding_started` | The first-run setup gate opened (fresh launch with no usable model). | -| `model_picker_shown` | The provider picker was displayed. `trigger` distinguishes the first run from `/connect`, from declining Altimate Base, and from the prompt gate. | +| `model_picker_shown` | The provider picker was displayed. `trigger` distinguishes the first run from `/connect` and from the prompt gate (`altimate_base_back` is defined in the schema but no longer emitted — it recorded backing out of the old consent dialog, which no longer exists). | | `provider_selected` | A provider row was chosen — `altimate_gateway`, `altimate_base`, `anthropic`, `openai`, `google`, `search_all`, or `other` for anything outside the curated five. `provider_id` carries the raw id only for publicly-known providers, so a provider you named yourself in config is reported as `other` with no name attached. `via_search` marks a pick made inside the full catalogue after choosing "Search all providers…". **Choosing search emits this event twice for one user** — once as `search_all`, then again with the provider actually chosen — so count distinct users or filter on `via_search`, not raw event count. Recorded at the moment of choice, so a sign-in that is then cancelled still counts. | -| `altimate_base_confirm_shown` / `altimate_base_choice` | The Altimate Base disclosure was shown, and what the user decided (`accept`/`cancel`). `origin` is `welcome`, `model`, or `migration` (returning free-default users offered Altimate Base on launch); required for the disclosure event and optional for the choice event. | -| `altimate_base_register_result` | The consented registration outcome: `success`, `rate_limited`, `unavailable`, `network`, or `error`. Optional `origin` is `welcome`, `model`, or `migration` (returning free-default users offered Altimate Base on launch). No credential or gateway response body is included. | +| `altimate_base_confirm_shown` / `altimate_base_choice` | Legacy — defined in the event schema but no longer emitted. These recorded the old consent dialog (shown, then `accept`/`cancel`), removed once Altimate Base moved to auto-registration with no confirmation step. | +| `altimate_base_register_result` | The registration outcome from a picker-driven selection (the welcome picker, the full catalogue, or the provider dialog): `success`, `rate_limited`, `unavailable`, `network`, or `error`. Optional `origin` is `welcome`, `model`, or `migration` (returning free-default users offered Altimate Base). Auto-registration at startup is reported separately, by `altimate_base_registration` above. No credential or gateway response body is included. | | `gateway_device_code_issued` | The Altimate Gateway authorize URL was built and the browser open attempted. **Name note:** the flow is a browser loopback OAuth — there is no device code. The name follows the original event spec. | | `gateway_auth_completed` / `gateway_auth_failed` | Gateway sign-in outcome. `reason` is `timeout`, `denied`, or `error` — never the underlying message, which can contain the instance name. An unrecognised callback state does not reject the pending attempt, so a CSRF mismatch surfaces as `timeout`. | | `instance_connected` | Credentials received and saved. `time_to_connect_ms` runs from the start of the authorize call, so it includes the browser launch. No instance or tenant name is sent. | From 7aa747408158fc6b305259ddfbf2efacaaa5bbd5 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 22 Sep 2026 22:58:40 -0700 Subject: [PATCH 10/27] fix: mark two lines the strict marker guard flagged as unmarked new code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--base origin/main --strict` diffs against HEAD (not the working tree), so this only surfaced after the previous two commits landed: - `sdk.tsx`: the new `headers: props.headers,` line had its explanation on the line above instead of a same-line marker, which the line-based checker doesn't credit — moved to a start/end block ending on that line, matching the sibling `registerAltimateBase` line's own trailing-marker style. - `local.tsx`: `const fallbackModel = createMemo(() => {` is unchanged content, but inserting `explicitFallbackModel()` above it shifted its diff position enough that git shows it as a delete+add against origin/main rather than pure context — flagged the same way. Added a marker comment. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/tui/src/context/local.tsx | 5 +++-- packages/tui/src/context/sdk.tsx | 13 +++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 20b7d48bf8..ed8dce5acd 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -610,8 +610,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) // altimate_change end - const fallbackModel = createMemo(() => { - const explicit = explicitFallbackModel() // altimate_change — see its declaration above + const fallbackModel = createMemo(() => { // altimate_change + // altimate_change — reads explicitFallbackModel() first now; declared above. + const explicit = explicitFallbackModel() if (explicit) return explicit // altimate_change start — Base is excluded only by an actual enabled_providers/disabled_providers diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 88ffa4d264..9ca54dd886 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -208,12 +208,13 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ event: emitter, fetch: props.fetch ?? fetch, url: props.url, - // altimate_change — the auth headers `createOpencodeClient` above bakes into every typed SDK - // call (Basic auth for an attached, password-protected server — see cli/cmd/attach.ts). A raw - // `sdk.fetch` call bypasses the client entirely, so a caller hitting an untyped route - // directly (component/altimate-onboarding.tsx's `registerAltimateBase` HTTP fallback) needs - // these to attach them itself, or it 401s against a password-protected server. - headers: props.headers, + // altimate_change start — the auth headers `createOpencodeClient` above bakes into every + // typed SDK call (Basic auth for an attached, password-protected server — see + // cli/cmd/attach.ts). A raw `sdk.fetch` call bypasses the client entirely, so a caller + // hitting an untyped route directly (component/altimate-onboarding.tsx's + // `registerAltimateBase` HTTP fallback) needs these to attach them itself, or it 401s + // against a password-protected server. + headers: props.headers, // altimate_change end registerAltimateBase: props.registerAltimateBase, // altimate_change — see the declaration above } }, From daa773cb0abea69158f4278576f1f7ea055b96ef Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 22 Sep 2026 22:59:25 -0700 Subject: [PATCH 11/27] fix: mark remaining shifted line the strict marker guard flagged Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/tui/src/context/local.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index ed8dce5acd..4fc4fb5b26 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -611,9 +611,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // altimate_change end const fallbackModel = createMemo(() => { // altimate_change - // altimate_change — reads explicitFallbackModel() first now; declared above. - const explicit = explicitFallbackModel() - if (explicit) return explicit + const explicit = explicitFallbackModel() // altimate_change — declared above + if (explicit) return explicit // altimate_change // altimate_change start — Base is excluded only by an actual enabled_providers/disabled_providers // verdict, which `sync.data.provider` (server-built) already reflects. The mere presence of From 6f9285304aa6cda1546bae21301007e2242120a0 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 14:29:27 +0800 Subject: [PATCH 12/27] fix: close the late-registration gaps and tidy review findings - Headless disclosure: `printDisclosureOnceForHeadless` now prints whenever Base is registered and the once-per-install marker is unclaimed, not only when this launch's own registration finished. A registration that outlasted the startup wait finished in the background and every later launch reported "already registered", so the notice was never shown. The marker is claimed with an exclusive create before printing, so concurrent launches print once. - Register route: skip the process-wide reload only when the credential is unchanged AND Base is already in this server's provider list. After a background auto-registration (or a credential refreshed in place) the file is unchanged but the cached provider state predates it. - Move `isPublicZen` out of the `Provider` namespace into `provider/public-zen.ts` (packages/opencode/AGENTS.md "Module shape"). - Restore `altimate_base_register_result` for picker registrations during first-run onboarding (welcome picker `origin: "welcome"`, catalogue and provider dialog `origin: "model"`), as the removed confirm component did. - Cap the persisted 429 auto-register backoff at 24 hours, and serialise the backoff record's read-modify-write under its own file lock. - Docs: registration happens on every unregistered install, whether or not the user has a model of their own; logout keeps automatic registration off until Base is picked again; fleet audits look for `altimate-base.json`; the `ALTIMATE_CLI_CLIENT=datamates` skip applies to `serve`; the registration telemetry row covers the server route. - Tests: route reload when Base is not loaded, backoff cap, headless disclosure after a background registration, picker result callback; the explicit-model test restores its env even if mounting fails. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- docs/docs/configure/providers.md | 22 +++++----- docs/docs/getting-started/quickstart.md | 2 +- docs/docs/reference/network.md | 2 +- docs/docs/reference/security-faq.md | 10 +++-- docs/docs/reference/telemetry.md | 2 +- packages/opencode/src/acp/service.ts | 9 +++-- packages/opencode/src/altimate/free/client.ts | 28 +++++++++---- .../opencode/src/altimate/free/consent.ts | 40 +++++++++++-------- packages/opencode/src/provider/provider.ts | 13 ++---- packages/opencode/src/provider/public-zen.ts | 16 ++++++++ packages/opencode/src/server/server.ts | 8 +++- packages/opencode/src/session/prompt.ts | 7 +++- .../altimate-base-auto-register.test.ts | 20 ++++++++++ .../altimate-base-headless-disclosure.test.ts | 40 +++++++++++++++++++ .../server/altimate-base-registration.test.ts | 32 +++++++++++++++ .../tui/src/component/altimate-onboarding.tsx | 15 ++++++- packages/tui/src/component/dialog-model.tsx | 12 +++++- .../tui/src/component/dialog-provider.tsx | 12 +++++- .../component/select-altimate-base.test.ts | 11 +++++ .../explicit-model-authoritative.test.tsx | 18 +++++---- 20 files changed, 252 insertions(+), 67 deletions(-) create mode 100644 packages/opencode/src/provider/public-zen.ts create mode 100644 packages/opencode/test/altimate/altimate-base-headless-disclosure.test.ts diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index ffbe43a5f0..fdeb7f253f 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -62,12 +62,14 @@ If you need stronger guarantees — no training on your data, metadata-only rete [Altimate LLM Gateway](https://help.altimate.ai/datamates/user-guide/components/llm-gateway/) instead. -Choose **Altimate Base** from the first-run picker or `/connect` — or do nothing at all: a fresh -install with no model of its own registers it automatically, before your first prompt, so it works -the same way headlessly (`run`, `serve`, `acp`, `web`). There is no confirmation dialog to accept. -The disclosure above is printed once per install the first time this happens — a toast in the TUI, -or a one-line notice to stderr for a headless entrypoint (skipped when `ALTIMATE_CLI_CLIENT=datamates`, -since the VS Code extension shows its own notice). After registration, the model is available as +Choose **Altimate Base** from the first-run picker or `/connect` — or do nothing at all: every +install that is not yet registered registers it automatically at startup, before your first prompt, +so it works the same way headlessly (`run`, `serve`, `acp`, `web`). This happens whether or not you +also have a model of your own; a registered Base only becomes your default when nothing you +configured is usable. There is no confirmation dialog to accept. +The disclosure above is printed once per install the first time Base is registered — a toast in the TUI, +or a one-line notice to stderr for a headless entrypoint (`serve` skips it when +`ALTIMATE_CLI_CLIENT=datamates`, since the VS Code extension shows its own notice). After registration, the model is available as `altimate-free/altimate-base` and becomes the free fallback when no paid Altimate Gateway or explicit model is selected. Big Pickle is retired as a new selection — it no longer appears in the picker or the full model catalog for users choosing a model for the first time. Users already on @@ -81,8 +83,9 @@ To opt out: set `ALTIMATE_BASE_AUTO_REGISTER=0` before this install first regist `altimate providers logout altimate-base` afterward, or keep it out of your own choices with `enabled_providers` / `disabled_providers`. The env var is the only one of these that stops the background registration call itself; the other two only control whether Base can be *selected* as -your model on this machine — logging out also un-registers it (it will auto-register again on the -next launch unless the env var is also set). +your model on this machine. Logging out un-registers it and also stops automatic registration on +this machine: later launches skip it until you pick Altimate Base again in the picker (or an IDE +calls the registration route), which reconnects it. Registration is per machine, not per host: once any host on a machine has registered Altimate Base (auto-registration on any entrypoint, or the HTTP registration route used by IDE @@ -90,7 +93,8 @@ integrations), every other host on that machine treats Base as the default free TUI migrates an implicit free default silently, and headless `altimate run`, `altimate serve`, and ACP sessions resolve to Base ahead of the keyless public Zen tier. Logging out on any host applies to all hosts on the machine, since the credential is a single shared file. Administrators auditing -a fleet can check for the registered `altimate-free` provider entry in `auth.json`. +a fleet can check for the Altimate Base credential file, `altimate-base.json`, in the data directory +(it is stored separately from the shared provider-auth file). Official release binaries embed the current gateway endpoint at build time. Operators and local development can override it without changing code: diff --git a/docs/docs/getting-started/quickstart.md b/docs/docs/getting-started/quickstart.md index b0f8520d3d..64f90ae1f8 100644 --- a/docs/docs/getting-started/quickstart.md +++ b/docs/docs/getting-started/quickstart.md @@ -25,7 +25,7 @@ On a fresh install, a welcome panel appears with a curated 6-provider picker: - **Altimate LLM Gateway** *(recommended)* — 10M tokens free, no API keys. Routes to the best model per task across Sonnet, Opus, GPT-5, and more. Sign-in opens a browser tab; complete Google or email signup and you're back in the TUI. If your terminal can't open a browser (SSH / tmux / WSL), the CLI prints the URL — paste it into a browser on your desktop. - **Anthropic** / **OpenAI** / **Google** — paste an API key or OAuth in. -- **Altimate Base** — a hosted open model, free and rate limited, with no signup or API key. Requests and responses may be logged and used to improve Altimate's products, so do not send secrets or confidential code. If you don't pick another provider, a fresh install registers it automatically — there is no confirmation dialog — and prints this notice once. Opt out with `ALTIMATE_BASE_AUTO_REGISTER=0`, `altimate providers logout altimate-base`, or `disabled_providers` (see [providers](../configure/providers.md#altimate-base)). +- **Altimate Base** — a hosted open model, free and rate limited, with no signup or API key. Requests and responses may be logged and used to improve Altimate's products, so do not send secrets or confidential code. Every install registers it automatically at startup, whichever provider you pick — there is no confirmation dialog — and shows this notice once; it only becomes your model when nothing else you configured is usable. Opt out with `ALTIMATE_BASE_AUTO_REGISTER=0`, `altimate providers logout altimate-base`, or `disabled_providers` (see [providers](../configure/providers.md#altimate-base)). - **Search all providers…** — full picker if you need Bedrock, Databricks AI Gateway, Cloudflare AI Gateway, Snowflake Cortex, DigitalOcean Inference, etc. Or set an environment variable and skip the picker: diff --git a/docs/docs/reference/network.md b/docs/docs/reference/network.md index 028d901aea..1d324bc650 100644 --- a/docs/docs/reference/network.md +++ b/docs/docs/reference/network.md @@ -41,7 +41,7 @@ altimate needs outbound HTTPS access to: | Destination | Purpose | |-------------|---------| | Your LLM provider API | Model inference (Anthropic, OpenAI, etc.) | -| Official Altimate Base gateway (embedded in release), or the host set by `ALTIMATE_BASE_GATEWAY_URL` | Altimate Base registration (automatic on a fresh install with no model of its own, unless `ALTIMATE_BASE_AUTO_REGISTER=0`) and inference | +| Official Altimate Base gateway (embedded in release), or the host set by `ALTIMATE_BASE_GATEWAY_URL` | Altimate Base registration (automatic at startup on any install not yet registered, unless `ALTIMATE_BASE_AUTO_REGISTER=0` or after logging out of Base) and inference | | `registry.npmjs.org` | Package updates | | `models.dev` | Model catalog (can be disabled) | | Your warehouse endpoints | Database connections | diff --git a/docs/docs/reference/security-faq.md b/docs/docs/reference/security-faq.md index 204293cc60..387b66811e 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -13,12 +13,14 @@ Answers to the most common security questions about running Altimate Code in you Altimate Code sends prompts and context to the LLM provider you configure (Anthropic, OpenAI, Azure OpenAI, AWS Bedrock, etc.). **You choose the provider.** No data is sent anywhere else except optional [telemetry](#what-telemetry-is-collected), which contains no code, queries, or credentials. -Altimate Base is Altimate's own hosted free model. A fresh install with no model of its own -registers it automatically — there is no confirmation dialog to accept. Requests and responses are +Altimate Base is Altimate's own hosted free model. Every install that is not yet registered +registers it automatically at startup, whether or not you also have a model of your own — there is +no confirmation dialog to accept. It only becomes your default model when nothing you configured is +usable. Requests and responses are logged and may be used to improve Altimate products and services, including the model; secrets are automatically masked before storage, but don't rely on it — avoid sending secrets or confidential code. This notice is printed once — a toast in the TUI, or a one-line stderr notice the first time -a headless entrypoint (`run`, `serve`, `acp`, `web`) registers it — and is part of the Altimate +a headless entrypoint (`run`, `serve`, `acp`, `web`) runs with Base registered — and is part of the Altimate Base service, separate from anonymous product telemetry. To opt out: set `ALTIMATE_BASE_AUTO_REGISTER=0` before Base ever registers, run `altimate providers logout altimate-base` afterward, or exclude it from your own model choices with `enabled_providers` / @@ -115,7 +117,7 @@ You can also configure per-agent permissions. For example, restrict the `analyst | Destination | Purpose | |-------------|---------| | Your configured LLM provider | Model inference | -| Altimate Base gateway | Registration (automatic on a fresh install with no model of its own) and inference | +| Altimate Base gateway | Registration (automatic at startup on any install not yet registered) and inference | | Your warehouse endpoints | Database queries | | `registry.npmjs.org` | Package updates | | `models.dev` | Model catalog (can be disabled) | diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index cd46bb4f48..8f4f9d8803 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -45,7 +45,7 @@ We collect the following categories of events: | `first_launch` | Fired once on the first CLI run after an install or upgrade, triggered by a marker file the installer wrote — the installers themselves send nothing and contact no telemetry endpoint. Contains the installed version, `is_upgrade`, and `install_method` (`curl`, `powershell`, `npm`, `vscode-extension`, `local` for `install --binary`, or `unknown` for markers written before the field existed). `vscode-extension` starts appearing only once an extension build containing the marker write ships, so a zero share for it means the extension has not rolled out yet rather than no extension installs. No PII. **Reading `is_upgrade`:** it means "this machine had run altimate-code before", probed as whether `~/.altimate/machine-id` already existed — *not* "a binary was already present". A reinstall onto a machine that ever ran the CLI reports `is_upgrade: true`, and `altimate uninstall` leaves `machine-id` in place, so a metric excluding upgrades counts installs **per previously-unseen machine** and undercounts reinstalls onto known ones. (`is_upgrade` is a boolean in the event schema; it arrives in Application Insights `customDimensions` as a string, so KQL filters read `tostring(customDimensions.is_upgrade) != "true"`.) Delivery is at-most-once: the marker is deleted before the event flushes, so a process that dies first loses that install rather than re-firing it every launch. Local `--binary` installs report `version: "local"`. | | `startup_ready` | Fired once per process when the top-level command can serve its first request or frame (`serve` listening, TUI transport resolved, `run` about to send its first prompt). Contains the command name, `duration_ms` since process start, and `fresh_install` (true when this process also emitted a non-upgrade `first_launch`). No PII. | | `event_loop_stall` | A 250 ms monitor tick fired more than 1 s late, meaning the event loop was blocked for that long (for example an in-process dependency install). Contains the command name, `thread` (`main` or `worker`), `blocked_ms`, and `since_start_ms`. Capped at 20 per thread (the main thread and the TUI server worker each keep their own counter). No PII. | -| `altimate_base_registration` | Timing and outcome of each Altimate Base registration, whether triggered automatically at startup or by an explicit picker selection (concurrent calls share one result, and a still-valid cached credential reports `success` without a network round trip): `result` (`success`, `network`, `http`, `response`, `cancelled`, `configuration`, or `error`), `duration_ms`, and the HTTP `status` when the result is `http`. Distinct from `altimate_base_register_result`, which records the onboarding-flow outcome. No gateway URL, response body, credential, or error text is included. | +| `altimate_base_registration` | Timing and outcome of each Altimate Base registration, whether triggered automatically at startup, by an explicit picker selection, or through the `POST /altimate/base/register` route an IDE calls (`origin: "server"`) (concurrent calls share one result, and a still-valid cached credential reports `success` without a network round trip): `result` (`success`, `network`, `http`, `response`, `cancelled`, `configuration`, or `error`), `duration_ms`, and the HTTP `status` when the result is `http`. Distinct from `altimate_base_register_result`, which records the onboarding-flow outcome. No gateway URL, response body, credential, or error text is included. | | `task_outcome_signal` | Behavioral quality signal at session end — accepted, error, abandoned, or cancelled. Includes tool count, step count, duration, and last tool category. No user content. | | `task_classified` | Intent classification of the first user message using keyword matching — category (e.g. `debug_dbt`, `write_sql`, `optimize_query`), confidence score, and detected warehouse type. No user text is sent — only the classified category. | | `tool_chain_outcome` | Aggregated tool execution sequence at session end — ordered tool names (capped at 50), error count, recovery count, final outcome, duration, and cost. No tool arguments or outputs. | diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 1d9ef174d3..720e993647 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -44,6 +44,9 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" import type { Command } from "@/command" +// altimate_change start — keyless public Zen predicate (flat module) +import { isPublicZen } from "@/provider/public-zen" +// altimate_change end export const AuthMethodID = "opencode-login" @@ -878,7 +881,7 @@ export function defaultModelFromConfig( // altimate_change — a stale recent pick of the now-broken keyless public Zen tier is replaced // by registered Base rather than replayed; it is guaranteed to fail otherwise. A // credentialed/paid selection is never overridden. - if (registeredBaseAvailable && Provider.isPublicZen(provider)) continue + if (registeredBaseAvailable && isPublicZen(provider)) continue return { providerID, modelID } } @@ -903,7 +906,7 @@ export function defaultModelFromConfig( if (id === "altimate-free") return false if (hasProviderAllowlist && !Object.prototype.hasOwnProperty.call(providerFilter, id)) return false const info = providers[ProviderV2.ID.make(id)] - if (registeredBaseAvailable && info && Provider.isPublicZen(info)) return false + if (registeredBaseAvailable && info && isPublicZen(info)) return false return true } const opencodeProvider = providerAllowed("opencode") ? providers[ProviderV2.ID.make("opencode")] : undefined @@ -976,7 +979,7 @@ function isStalePublicZenSnapshotModel(snapshot: Directory.Snapshot, model: Dire const registeredBaseAvailable = Boolean(baseProvider?.models[ModelV2.ID.make("altimate-base")]) if (!registeredBaseAvailable) return false const provider = snapshot.providers[model.providerID] - return Boolean(provider && Provider.isPublicZen(provider)) + return Boolean(provider && isPublicZen(provider)) } // altimate_change end diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index ca5ad1c69a..1fe3abc145 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -531,6 +531,8 @@ function autoRegisterDisabledByEnv(): boolean { // mirroring the disclosure marker in consent.ts) and read at the start of every `autoRegister()` // call, including the first one in a brand-new process. const AUTO_REGISTER_BACKOFF_MS = 60 * 60 * 1000 // 1 hour +// A gateway or proxy sending an enormous Retry-After must not disable auto-registration for days. +const AUTO_REGISTER_BACKOFF_MAX_MS = 24 * 60 * 60 * 1000 // 24 hours function autoRegisterBackoffMs( kind: Exclude, @@ -538,7 +540,8 @@ function autoRegisterBackoffMs( ): number | undefined { if (kind === "network") return AUTO_REGISTER_BACKOFF_MS if (kind === "http" && error instanceof RegistrationError) { - if (error.status === 429) return Math.max(AUTO_REGISTER_BACKOFF_MS, error.retryAfterMs ?? 0) + if (error.status === 429) + return Math.min(AUTO_REGISTER_BACKOFF_MAX_MS, Math.max(AUTO_REGISTER_BACKOFF_MS, error.retryAfterMs ?? 0)) if (error.status !== undefined && error.status >= 500) return AUTO_REGISTER_BACKOFF_MS } return undefined @@ -584,17 +587,26 @@ async function getPersistedAutoRegisterBackoff(gateway: string): Promise { - const record = await readAutoRegisterBackoffRecord() - record[gateway] = until - await writeAutoRegisterBackoffRecord(record) + await Flock.withLock(BACKOFF_LOCK_KEY, async () => { + const record = await readAutoRegisterBackoffRecord() + record[gateway] = until + await writeAutoRegisterBackoffRecord(record) + }).catch((error) => log.warn("failed to update Altimate Base auto-register backoff", { error })) } async function clearPersistedAutoRegisterBackoff(gateway: string): Promise { - const record = await readAutoRegisterBackoffRecord() - if (!(gateway in record)) return - delete record[gateway] - await writeAutoRegisterBackoffRecord(record) + await Flock.withLock(BACKOFF_LOCK_KEY, async () => { + const record = await readAutoRegisterBackoffRecord() + if (!(gateway in record)) return + delete record[gateway] + await writeAutoRegisterBackoffRecord(record) + }).catch((error) => log.warn("failed to clear Altimate Base auto-register backoff", { error })) } // Test-only: this file is otherwise process-global state with no reset hook, so a backoff set by diff --git a/packages/opencode/src/altimate/free/consent.ts b/packages/opencode/src/altimate/free/consent.ts index 750a049615..86c44e017d 100644 --- a/packages/opencode/src/altimate/free/consent.ts +++ b/packages/opencode/src/altimate/free/consent.ts @@ -102,20 +102,7 @@ function disclosureMarkerPath(): string { return path.join(path.dirname(FreeTierStore.credentialPath()), "altimate-base-disclosure-shown.json") } -async function disclosureAlreadyShown(): Promise { - try { - await fs.access(disclosureMarkerPath()) - return true - } catch { - return false - } -} -async function markDisclosureShown(): Promise { - const target = disclosureMarkerPath() - await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 }) - await fs.writeFile(target, JSON.stringify({ shownAt: new Date().toISOString() }) + "\n", { mode: 0o600 }) -} /** * Print the Base disclosure to stderr for a headless entrypoint, once per install. A no-op unless @@ -125,10 +112,31 @@ async function markDisclosureShown(): Promise { * later launch, never blocks startup. */ export async function printDisclosureOnceForHeadless(justRegistered: boolean): Promise { - if (!justRegistered) return - if (await disclosureAlreadyShown().catch(() => false)) return + // A registration that outlasted the startup wait finishes in the background, and every later + // launch reports "already registered", so this launch's own result cannot be the only trigger: + // print whenever Base is registered and the once-per-install marker is not yet set. + const registered = justRegistered || (await FreeTier.isRegistered().catch(() => false)) + if (!registered) return + // Claim the marker before printing: an exclusive create lets exactly one of several concurrent + // headless launches win, so the notice cannot print twice. If the claim fails for any other + // reason (unwritable directory), print anyway — a repeat is better than a missed notice. + const claimed = await claimDisclosureMarker() + if (claimed === "taken") return console.error(`Altimate Base: ${ALTIMATE_BASE_DISCLOSURE}`) - await markDisclosureShown().catch(() => {}) +} + +async function claimDisclosureMarker(): Promise<"claimed" | "taken" | "unavailable"> { + const target = disclosureMarkerPath() + try { + await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 }) + await fs.writeFile(target, JSON.stringify({ shownAt: new Date().toISOString() }) + "\n", { + mode: 0o600, + flag: "wx", + }) + return "claimed" + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EEXIST" ? "taken" : "unavailable" + } } // altimate_change end diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 6ee05e832f..3115c1164a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -28,6 +28,9 @@ import { iife } from "@/util/iife" import { Global } from "../global" import path from "path" import { Filesystem } from "../util/filesystem" +// altimate_change start — keyless public Zen predicate (flat module) +import { isPublicZen } from "./public-zen" +// altimate_change end import { AltimateApi } from "../altimate/api/client" // altimate_change start — managed Altimate Base provider and credential boundary import { FreeTier } from "../altimate/free/client" @@ -2193,16 +2196,6 @@ export namespace Provider { ) } - // altimate_change start — shared "is this the keyless public Zen tier?" predicate. - // OpenCode Zen's free tier now rejects keyless traffic outright (2026-09-17), so every place that - // used to weigh public Zen against registered Base must agree on what "public Zen" means. A - // provider counts only when it is the built-in `opencode` provider AND was auto-configured with - // the `"public"` placeholder key AND the user never supplied a real one (`provider.key` is set - // only by an authenticated key, never by the placeholder). - export function isPublicZen(provider: Pick): boolean { - return provider.id === "opencode" && provider.options["apiKey"] === "public" && !provider.key - } - // altimate_change end // altimate_change start — normalize persisted model references and default-switch consent function isModelReference(model: unknown): model is { providerID: ProviderID; modelID: ModelID } { diff --git a/packages/opencode/src/provider/public-zen.ts b/packages/opencode/src/provider/public-zen.ts new file mode 100644 index 0000000000..ec165ec2f9 --- /dev/null +++ b/packages/opencode/src/provider/public-zen.ts @@ -0,0 +1,16 @@ +// altimate_change start — shared "is this the keyless public Zen tier?" predicate, kept as a flat +// module (not a `Provider` namespace member; see packages/opencode/AGENTS.md "Module shape"). +// +// OpenCode Zen's free tier rejects keyless traffic outright (2026-09-17), so every place that +// weighs public Zen against registered Altimate Base must agree on what "public Zen" means. A +// provider counts only when it is the built-in `opencode` provider AND was auto-configured with the +// `"public"` placeholder key AND the user never supplied a real one (`key` is set only by an +// authenticated key, never by the placeholder). +export function isPublicZen(provider: { + id: string + options: Record + key?: string +}): boolean { + return provider.id === "opencode" && provider.options["apiKey"] === "public" && !provider.key +} +// altimate_change end diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index cf5d03f8d9..19386f07f4 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -816,7 +816,13 @@ export namespace Server { if (outcome.ok) { const after = await FreeTier.credentials().catch(() => undefined) const changed = before?.apiKey !== after?.apiKey || before?.baseURL !== after?.baseURL - if (!changed) return c.json(outcome) + // An unchanged file is not enough to skip: a startup registration that finished in the + // background, or an expired/rejected credential that was refreshed in place, leaves this + // server's cached provider state without Base. Skip only when Base is actually loaded. + const baseLoaded = await Provider.list() + .then((providers) => FreeTier.PROVIDER_ID in providers) + .catch(() => false) + if (!changed && baseLoaded) return c.json(outcome) const disposed = await Promise.all([ Instance.disposeAll().then( () => true, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 00be80aa0e..d2d2095fef 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -101,6 +101,9 @@ import { stampRegistryToolSource, describeMcpTool } from "../altimate/tool-sourc // altimate_change end import { Telemetry } from "@/telemetry" // altimate_change — session telemetry import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" // altimate_change — onboarding funnel +// altimate_change start — keyless public Zen predicate (flat module) +import { isPublicZen } from "@/provider/public-zen" +// altimate_change end // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -2038,7 +2041,7 @@ export namespace SessionPrompt { // the default instead so the session picks up Base. A credentialed/paid selection (or any // non-Zen provider) is returned unchanged. // - // Only `Provider.isPublicZen()` can ever be true for the `opencode` provider id, and + // Only `isPublicZen()` can ever be true for the `opencode` provider id, and // `Provider.list()` below is expensive (it can hit the models.dev catalog) — check both cheap, // sync-ish preconditions first so the common case (any other provider, or Base not // registered) never pays that cost. @@ -2050,7 +2053,7 @@ export namespace SessionPrompt { if (item.info.model.providerID === "opencode" && (await FreeTier.isRegistered().catch(() => false))) { const providers = await Provider.list() const provider = providers[item.info.model.providerID] - if (provider && Provider.isPublicZen(provider)) return Provider.defaultModel() + if (provider && isPublicZen(provider)) return Provider.defaultModel() } return item.info.model } diff --git a/packages/opencode/test/altimate/altimate-base-auto-register.test.ts b/packages/opencode/test/altimate/altimate-base-auto-register.test.ts index adc497512e..b29b1dee2c 100644 --- a/packages/opencode/test/altimate/altimate-base-auto-register.test.ts +++ b/packages/opencode/test/altimate/altimate-base-auto-register.test.ts @@ -265,6 +265,26 @@ describe("FreeTier.autoRegister: backoff after a failure", () => { expect(backoffUntil!).toBeGreaterThanOrEqual(before + 1.9 * 60 * 60 * 1000) }) + test("a 429 with an enormous Retry-After is capped at 24 hours", async () => { + gateway.restore() + const rateLimited = spyOn(globalThis, "fetch").mockImplementation((async (_input: RequestInfo | URL, _init?: RequestInit) => { + return new Response(JSON.stringify({ error: "rate limited" }), { + status: 429, + headers: { "Content-Type": "application/json", "retry-after": String(30 * 24 * 60 * 60) }, // 30 days + }) + }) as typeof fetch) + const before = Date.now() + try { + await FreeTier.autoRegister() + } finally { + rateLimited.mockRestore() + } + const backoffUntil = await FreeTier.getAutoRegisterBackoffUntilForTests(GATEWAY_URL) + expect(backoffUntil).toBeDefined() + expect(backoffUntil!).toBeLessThanOrEqual(Date.now() + 24 * 60 * 60 * 1000) + expect(backoffUntil!).toBeGreaterThanOrEqual(before + 23.9 * 60 * 60 * 1000) + }) + test("explicit register() ignores the auto-register backoff", async () => { gateway.restore() const failing = spyOn(globalThis, "fetch").mockImplementation((async (_input: RequestInfo | URL, _init?: RequestInit) => { diff --git a/packages/opencode/test/altimate/altimate-base-headless-disclosure.test.ts b/packages/opencode/test/altimate/altimate-base-headless-disclosure.test.ts new file mode 100644 index 0000000000..a3cafe920a --- /dev/null +++ b/packages/opencode/test/altimate/altimate-base-headless-disclosure.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { FreeTier } from "../../src/altimate/free/client" +import { FreeTierConsent } from "../../src/altimate/free/consent" +import { FreeTierStore } from "../../src/altimate/free/store" + +// The headless notice (`run`, `serve`, `acp`, `web`) must reach a user whose registration finished +// in the background after the startup wait: that launch reported "pending" and every later launch +// reports "already registered", so neither passes `justRegistered`. +const marker = () => path.join(path.dirname(FreeTierStore.credentialPath()), "altimate-base-disclosure-shown.json") + +describe("FreeTierConsent.printDisclosureOnceForHeadless", () => { + let stderr: ReturnType + let registered: ReturnType + + beforeEach(async () => { + await fs.rm(marker(), { force: true }) + stderr = spyOn(console, "error").mockImplementation(() => {}) + }) + afterEach(async () => { + stderr.mockRestore() + registered?.mockRestore() + await fs.rm(marker(), { force: true }) + }) + + test("prints once when Base was registered by an earlier, backgrounded attempt", async () => { + registered = spyOn(FreeTier, "isRegistered").mockResolvedValue(true) + await FreeTierConsent.printDisclosureOnceForHeadless(false) + await FreeTierConsent.printDisclosureOnceForHeadless(false) + expect(stderr).toHaveBeenCalledTimes(1) + expect(String(stderr.mock.calls[0]?.[0])).toStartWith("Altimate Base: ") + }) + + test("prints nothing while Base is not registered", async () => { + registered = spyOn(FreeTier, "isRegistered").mockResolvedValue(false) + await FreeTierConsent.printDisclosureOnceForHeadless(false) + expect(stderr).not.toHaveBeenCalled() + }) +}) diff --git a/packages/opencode/test/server/altimate-base-registration.test.ts b/packages/opencode/test/server/altimate-base-registration.test.ts index c96958fa45..d32a7f4632 100644 --- a/packages/opencode/test/server/altimate-base-registration.test.ts +++ b/packages/opencode/test/server/altimate-base-registration.test.ts @@ -3,6 +3,7 @@ import { Server } from "../../src/server/server" import { FreeTier } from "../../src/altimate/free/client" import { FreeTierConsent } from "../../src/altimate/free/consent" import { Instance } from "../../src/project/instance" +import { Provider } from "../../src/provider/provider" import { resetDatabase } from "./db" import { disposeAllInstances } from "../fixture/fixture" @@ -156,6 +157,10 @@ describe("Altimate Base registration route", () => { const existing = { apiKey: "sk-existing", baseURL: "https://gateway.test", installSecret: "s" } mockCredentialsSequence(existing, existing) mockRegister(async () => existing) + // Base is already loaded on this server, so there is genuinely nothing to re-read. + const list = spyOn(Provider, "list").mockResolvedValue({ + [FreeTier.PROVIDER_ID]: {}, + } as unknown as Awaited>) const disposeAll = spyOn(Instance, "disposeAll") try { const response = await app().request("/altimate/base/register", { @@ -170,6 +175,33 @@ describe("Altimate Base registration route", () => { expect(disposeAll).not.toHaveBeenCalled() } finally { disposeAll.mockRestore() + list.mockRestore() + } + }) + + test("reloads when the credential is unchanged but this server has not loaded Base yet", async () => { + // A startup auto-registration that finished in the background (or a credential refreshed in + // place) leaves the file unchanged across this request while the cached provider state still + // predates it; skipping would leave Base disconnected until a restart. + const existing = { apiKey: "sk-existing", baseURL: "https://gateway.test", installSecret: "s" } + mockCredentialsSequence(existing, existing) + mockRegister(async () => existing) + const list = spyOn(Provider, "list").mockResolvedValue( + {} as unknown as Awaited>, + ) + const disposeAll = spyOn(Instance, "disposeAll") + try { + const response = await app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ ok: true }) + expect(disposeAll).toHaveBeenCalledTimes(1) + } finally { + disposeAll.mockRestore() + list.mockRestore() } }) // altimate_change end diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index 02ec92001f..5c4963991d 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -210,7 +210,16 @@ export function DialogModelWelcome(props: { // SUCCESSFUL selection is meant to be one-shot (it closes the dialog). Returning `true` // synchronously below keeps `activateRow`'s double-input guard active for the in-flight // window; this resets it if the attempt turns out to have failed. - selectAltimateBase({ sdk, sync, local, toast, dialog }).then((selected) => { + selectAltimateBase({ + sdk, + sync, + local, + toast, + dialog, + onRegisterResult: (result) => { + if (firstRunActive()) trackOnboarding({ name: "altimate_base_register_result", result, origin: "welcome" }) + }, + }).then((selected) => { if (!selected) activated = false }) return true @@ -511,6 +520,8 @@ export async function selectAltimateBase(input: { local: ReturnType toast: ReturnType dialog: ReturnType + /** Reports the registration outcome, for the onboarding funnel's `altimate_base_register_result`. */ + onRegisterResult?: (result: "success" | "rate_limited" | "unavailable" | "network" | "error") => void }): Promise { // altimate_change start — Codex review finding: snapshot the top-of-stack item BY REFERENCE at // entry; `stillOpen()` re-checks it after every await below. `dialog.replace()`/`clear()` always @@ -523,6 +534,8 @@ export async function selectAltimateBase(input: { // altimate_change end const outcome = await registerAltimateBase(input.sdk) + // Reported even if the picker went away: the registration itself happened. + input.onRegisterResult?.(outcome.ok ? "success" : outcome.result) if (!stillOpen()) return false if (!outcome.ok) { input.toast.show({ variant: "error", message: outcome.message }) diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index d1be29f3c2..546b1c0543 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -199,7 +199,17 @@ export function DialogModel(props: { } // altimate_change — a failed selection must not permanently latch the row inert; // only a SUCCESSFUL selection is meant to be one-shot (it closes the dialog). - selectAltimateBase({ sdk, sync, local, toast, dialog }).then((selected) => { + selectAltimateBase({ + sdk, + sync, + local, + toast, + dialog, + onRegisterResult: (result) => { + if (firstRunActive()) + trackOnboarding({ name: "altimate_base_register_result", result, origin: "model" }) + }, + }).then((selected) => { if (!selected) activated = false }) return undefined diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index 9642f275ee..4546ff20fd 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -203,7 +203,17 @@ export function createDialogProviderOptions() { } // altimate_change — a failed selection must not permanently latch the row inert; // only a SUCCESSFUL selection is meant to be one-shot (it closes the dialog). - selectAltimateBase({ sdk, sync, local, toast, dialog }).then((selected) => { + selectAltimateBase({ + sdk, + sync, + local, + toast, + dialog, + onRegisterResult: (result) => { + if (firstRunActive()) + trackOnboarding({ name: "altimate_base_register_result", result, origin: "model" }) + }, + }).then((selected) => { if (!selected) altimateBaseActivated = false }) return diff --git a/packages/tui/test/component/select-altimate-base.test.ts b/packages/tui/test/component/select-altimate-base.test.ts index f76f6d1a8e..d15d812d92 100644 --- a/packages/tui/test/component/select-altimate-base.test.ts +++ b/packages/tui/test/component/select-altimate-base.test.ts @@ -197,6 +197,17 @@ describe("selectAltimateBase", () => { }) // altimate_change end + test("reports the registration outcome for the onboarding funnel", async () => { + const results: string[] = [] + const ok = fakeCollaborators({ registerAltimateBase: async () => ({ ok: true }) }) + await selectAltimateBase({ ...ok, onRegisterResult: (r) => results.push(r) }) + const failed = fakeCollaborators({ + registerAltimateBase: async () => ({ ok: false, result: "network", message: "offline" }), + }) + await selectAltimateBase({ ...failed, onRegisterResult: (r) => results.push(r) }) + expect(results).toEqual(["success", "network"]) + }) + test("a registration failure shows the toast and leaves the model unchanged", async () => { const fakes = fakeCollaborators({ registerAltimateBase: async () => ({ ok: false, result: "network", message: "offline" }), diff --git a/packages/tui/test/context/explicit-model-authoritative.test.tsx b/packages/tui/test/context/explicit-model-authoritative.test.tsx index d96e9c28b9..aea317d8d9 100644 --- a/packages/tui/test/context/explicit-model-authoritative.test.tsx +++ b/packages/tui/test/context/explicit-model-authoritative.test.tsx @@ -165,15 +165,16 @@ test("an explicit --model pointing at the public Zen tier stays put even though const isolatedState = await tmpdir() process.env.OPENCODE_TEST_STATE_HOME = isolatedState.path - const mounted = await mount({ model: "opencode/zen-model" }) + let mounted: Awaited> | undefined try { + mounted = await mount({ model: "opencode/zen-model" }) // The bug this guards against: this used to resolve to `{ providerID: "altimate-free", // modelID: "altimate-base" }` instead, even though the user explicitly asked for // `opencode/zen-model` via `--model`. - await waitUntil(() => local_model_is(mounted, "opencode", "zen-model")) - expect(mounted.local.model.current()).toEqual({ providerID: "opencode", modelID: "zen-model" }) + await waitUntil(() => local_model_is(mounted!, "opencode", "zen-model")) + expect(mounted!.local.model.current()).toEqual({ providerID: "opencode", modelID: "zen-model" }) } finally { - await mounted.cleanup() + await mounted?.cleanup() if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome await isolatedState[Symbol.asyncDispose]() @@ -185,15 +186,16 @@ test("with no explicit --model, the same catalogue resolves the implicit fallbac const isolatedState = await tmpdir() process.env.OPENCODE_TEST_STATE_HOME = isolatedState.path - const mounted = await mount({}) + let mounted: Awaited> | undefined try { + mounted = await mount({}) // Confirms the harness's Base-registered/no-recents setup actually exercises the // substitution path when nothing explicit overrides it — i.e. that the first test above is // not passing merely because Base was never reachable at all. - await waitUntil(() => local_model_is(mounted, "altimate-free", "altimate-base")) - expect(mounted.local.model.current()).toEqual({ providerID: "altimate-free", modelID: "altimate-base" }) + await waitUntil(() => local_model_is(mounted!, "altimate-free", "altimate-base")) + expect(mounted!.local.model.current()).toEqual({ providerID: "altimate-free", modelID: "altimate-base" }) } finally { - await mounted.cleanup() + await mounted?.cleanup() if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome await isolatedState[Symbol.asyncDispose]() From 9f6b95b7a726de6640cdcee85dd6a22cd66e290d Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 15:02:40 +0800 Subject: [PATCH 13/27] fix: reload providers per process, not per workspace, and qualify the Base docs - The register route skipped its reload whenever the requesting workspace already listed Base. Caches in other directories, or in the `/api` registry, could still predate a background registration. It now skips only once this process has reloaded for that exact credential. - The headless disclosure test isolates its home directory like the other Base suites. - Docs: the three-second startup wait and background completion, the retry backoff, the auto-register skip conditions, what registration sends, and the TUI notice appearing when Base first becomes active. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- docs/docs/configure/providers.md | 12 +++-- docs/docs/getting-started/quickstart.md | 2 +- docs/docs/reference/network.md | 2 +- docs/docs/reference/security-faq.md | 11 +++-- packages/opencode/src/server/server.ts | 19 +++++--- .../altimate-base-headless-disclosure.test.ts | 3 ++ .../server/altimate-base-registration.test.ts | 46 +++++++++---------- 7 files changed, 55 insertions(+), 40 deletions(-) diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index fdeb7f253f..11d9a7b6c4 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -63,12 +63,16 @@ If you need stronger guarantees — no training on your data, metadata-only rete instead. Choose **Altimate Base** from the first-run picker or `/connect` — or do nothing at all: every -install that is not yet registered registers it automatically at startup, before your first prompt, -so it works the same way headlessly (`run`, `serve`, `acp`, `web`). This happens whether or not you +install that is not yet registered registers it automatically at startup, so it works the same way +headlessly (`run`, `serve`, `acp`, `web`). Startup waits up to three seconds for this; a slower +registration finishes in the background and applies from the next launch (a `serve` client can +apply it sooner through the register route). After a failed attempt, startup skips registration +for a retry backoff of one hour (longer if the gateway asks, up to 24 hours). This happens whether or not you also have a model of your own; a registered Base only becomes your default when nothing you configured is usable. There is no confirmation dialog to accept. -The disclosure above is printed once per install the first time Base is registered — a toast in the TUI, -or a one-line notice to stderr for a headless entrypoint (`serve` skips it when +The disclosure above is shown once per install: in the TUI as a toast the first time Base becomes +the active model, and for a headless entrypoint as a one-line notice to stderr the first time it runs +with Base registered (`serve` skips it when `ALTIMATE_CLI_CLIENT=datamates`, since the VS Code extension shows its own notice). After registration, the model is available as `altimate-free/altimate-base` and becomes the free fallback when no paid Altimate Gateway or explicit model is selected. Big Pickle is retired as a new selection — it no longer appears in the diff --git a/docs/docs/getting-started/quickstart.md b/docs/docs/getting-started/quickstart.md index 64f90ae1f8..2d2ec7ad1a 100644 --- a/docs/docs/getting-started/quickstart.md +++ b/docs/docs/getting-started/quickstart.md @@ -25,7 +25,7 @@ On a fresh install, a welcome panel appears with a curated 6-provider picker: - **Altimate LLM Gateway** *(recommended)* — 10M tokens free, no API keys. Routes to the best model per task across Sonnet, Opus, GPT-5, and more. Sign-in opens a browser tab; complete Google or email signup and you're back in the TUI. If your terminal can't open a browser (SSH / tmux / WSL), the CLI prints the URL — paste it into a browser on your desktop. - **Anthropic** / **OpenAI** / **Google** — paste an API key or OAuth in. -- **Altimate Base** — a hosted open model, free and rate limited, with no signup or API key. Requests and responses may be logged and used to improve Altimate's products, so do not send secrets or confidential code. Every install registers it automatically at startup, whichever provider you pick — there is no confirmation dialog — and shows this notice once; it only becomes your model when nothing else you configured is usable. Opt out with `ALTIMATE_BASE_AUTO_REGISTER=0`, `altimate providers logout altimate-base`, or `disabled_providers` (see [providers](../configure/providers.md#altimate-base)). +- **Altimate Base** — a hosted open model, free and rate limited, with no signup or API key. Requests and responses may be logged and used to improve Altimate's products, so do not send secrets or confidential code. Every install registers it automatically at startup, whichever provider you pick — there is no confirmation dialog — and this notice is shown once, when Base is first used; it only becomes your model when nothing else you configured is usable. Opt out with `ALTIMATE_BASE_AUTO_REGISTER=0`, `altimate providers logout altimate-base`, or `disabled_providers` (see [providers](../configure/providers.md#altimate-base)). - **Search all providers…** — full picker if you need Bedrock, Databricks AI Gateway, Cloudflare AI Gateway, Snowflake Cortex, DigitalOcean Inference, etc. Or set an environment variable and skip the picker: diff --git a/docs/docs/reference/network.md b/docs/docs/reference/network.md index 1d324bc650..c40798c753 100644 --- a/docs/docs/reference/network.md +++ b/docs/docs/reference/network.md @@ -41,7 +41,7 @@ altimate needs outbound HTTPS access to: | Destination | Purpose | |-------------|---------| | Your LLM provider API | Model inference (Anthropic, OpenAI, etc.) | -| Official Altimate Base gateway (embedded in release), or the host set by `ALTIMATE_BASE_GATEWAY_URL` | Altimate Base registration (automatic at startup on any install not yet registered, unless `ALTIMATE_BASE_AUTO_REGISTER=0` or after logging out of Base) and inference | +| Official Altimate Base gateway (embedded in release), or the host set by `ALTIMATE_BASE_GATEWAY_URL` | Altimate Base registration (automatic at startup on any install not yet registered, unless `ALTIMATE_BASE_AUTO_REGISTER=0`, after logging out of Base, or during the 1–24 h retry backoff after a failed attempt) and inference | | `registry.npmjs.org` | Package updates | | `models.dev` | Model catalog (can be disabled) | | Your warehouse endpoints | Database connections | diff --git a/docs/docs/reference/security-faq.md b/docs/docs/reference/security-faq.md index 387b66811e..083ff06a3e 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -11,15 +11,18 @@ Answers to the most common security questions about running Altimate Code in you ## Does Altimate Code send my data to external services? -Altimate Code sends prompts and context to the LLM provider you configure (Anthropic, OpenAI, Azure OpenAI, AWS Bedrock, etc.). **You choose the provider.** No data is sent anywhere else except optional [telemetry](#what-telemetry-is-collected), which contains no code, queries, or credentials. +Altimate Code sends prompts and context to the LLM provider you configure (Anthropic, OpenAI, Azure OpenAI, AWS Bedrock, etc.). **You choose the provider.** Beyond that provider, Altimate Code contacts the Altimate Base gateway to register this install (see below), and sends optional [telemetry](#what-telemetry-is-collected), which contains no code, queries, or credentials. -Altimate Base is Altimate's own hosted free model. Every install that is not yet registered -registers it automatically at startup, whether or not you also have a model of your own — there is +Altimate Base is Altimate's own hosted free model. By default, every install that is not yet +registered registers it automatically at startup, whether or not you also have a model of your own. +Registration sends only a hash of a random per-install secret and the CLI version, not your prompts or code. It is skipped when +`ALTIMATE_BASE_AUTO_REGISTER=0` is set, after you log out of Base, when no gateway is configured, and +during the retry backoff that follows a failed attempt. There is no confirmation dialog to accept. It only becomes your default model when nothing you configured is usable. Requests and responses are logged and may be used to improve Altimate products and services, including the model; secrets are automatically masked before storage, but don't rely on it — avoid sending secrets or confidential -code. This notice is printed once — a toast in the TUI, or a one-line stderr notice the first time +code. This notice is shown once — a toast in the TUI the first time Base becomes the active model, or a one-line stderr notice the first time a headless entrypoint (`run`, `serve`, `acp`, `web`) runs with Base registered — and is part of the Altimate Base service, separate from anonymous product telemetry. To opt out: set `ALTIMATE_BASE_AUTO_REGISTER=0` before Base ever registers, run `altimate providers logout diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 19386f07f4..773a945c96 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -71,6 +71,12 @@ globalThis.AI_SDK_LOG_WARNINGS = false export namespace Server { const log = Log.create({ service: "server" }) + // altimate_change start — the Base credential every provider cache in this process is known to + // reflect: set after the register route has disposed both registries for it. Unset until then, + // because a cache built before a background registration finished cannot be told apart from one + // built after it, in any directory or either registry. + let appliedBaseCredential: string | undefined + // altimate_change end export const Default = lazy(() => createApp({})) // altimate_change start — upstream_fix: preserve upstream v1.17.9 /api HttpApi routes. @@ -817,12 +823,12 @@ export namespace Server { const after = await FreeTier.credentials().catch(() => undefined) const changed = before?.apiKey !== after?.apiKey || before?.baseURL !== after?.baseURL // An unchanged file is not enough to skip: a startup registration that finished in the - // background, or an expired/rejected credential that was refreshed in place, leaves this - // server's cached provider state without Base. Skip only when Base is actually loaded. - const baseLoaded = await Provider.list() - .then((providers) => FreeTier.PROVIDER_ID in providers) - .catch(() => false) - if (!changed && baseLoaded) return c.json(outcome) + // background leaves caches built before it without Base, in any directory and in either + // registry. Skip only when this process has already reloaded for this exact credential. + const fingerprint = after ? `${after.baseURL}\n${after.apiKey}` : undefined + if (!changed && fingerprint !== undefined && fingerprint === appliedBaseCredential) { + return c.json(outcome) + } const disposed = await Promise.all([ Instance.disposeAll().then( () => true, @@ -839,6 +845,7 @@ export namespace Server { }, ), ]).then((results) => results.every(Boolean)) + if (disposed) appliedBaseCredential = fingerprint return c.json(disposed ? outcome : { ...outcome, staleProviders: true as const }) } return c.json(outcome) diff --git a/packages/opencode/test/altimate/altimate-base-headless-disclosure.test.ts b/packages/opencode/test/altimate/altimate-base-headless-disclosure.test.ts index a3cafe920a..0404a9acfe 100644 --- a/packages/opencode/test/altimate/altimate-base-headless-disclosure.test.ts +++ b/packages/opencode/test/altimate/altimate-base-headless-disclosure.test.ts @@ -4,6 +4,9 @@ import path from "node:path" import { FreeTier } from "../../src/altimate/free/client" import { FreeTierConsent } from "../../src/altimate/free/consent" import { FreeTierStore } from "../../src/altimate/free/store" +import { isolateAltimateBaseHome } from "./_fixtures/altimate-base-harness" + +isolateAltimateBaseHome("altimate-base-headless-disclosure") // The headless notice (`run`, `serve`, `acp`, `web`) must reach a user whose registration finished // in the background after the startup wait: that launch reported "pending" and every later launch diff --git a/packages/opencode/test/server/altimate-base-registration.test.ts b/packages/opencode/test/server/altimate-base-registration.test.ts index d32a7f4632..6f32602409 100644 --- a/packages/opencode/test/server/altimate-base-registration.test.ts +++ b/packages/opencode/test/server/altimate-base-registration.test.ts @@ -153,42 +153,40 @@ describe("Altimate Base registration route", () => { } }) - test("skips instance disposal when registration is idempotent (credential unchanged)", async () => { + test("skips instance disposal once this process has reloaded for the unchanged credential", async () => { const existing = { apiKey: "sk-existing", baseURL: "https://gateway.test", installSecret: "s" } - mockCredentialsSequence(existing, existing) + mockCredentialsSequence(existing) mockRegister(async () => existing) - // Base is already loaded on this server, so there is genuinely nothing to re-read. - const list = spyOn(Provider, "list").mockResolvedValue({ - [FreeTier.PROVIDER_ID]: {}, - } as unknown as Awaited>) const disposeAll = spyOn(Instance, "disposeAll") - try { - const response = await app().request("/altimate/base/register", { + const post = () => + app().request("/altimate/base/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}), }) - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ ok: true }) - // No staleProviders and no disposal: nothing changed, so there's nothing for a provider - // loader to re-read, and no reason to tear down live sessions/LSPs/PTYs/MCP connections. - expect(disposeAll).not.toHaveBeenCalled() + try { + // First call: unchanged on disk, but nothing yet shows this process's caches include it. + expect(await (await post()).json()).toMatchObject({ ok: true }) + expect(disposeAll).toHaveBeenCalledTimes(1) + // Second call: already reloaded for this credential, so there is nothing left to re-read, + // and no reason to tear down live sessions/LSPs/PTYs/MCP connections again. + expect(await (await post()).json()).toMatchObject({ ok: true }) + expect(disposeAll).toHaveBeenCalledTimes(1) } finally { disposeAll.mockRestore() - list.mockRestore() } }) - test("reloads when the credential is unchanged but this server has not loaded Base yet", async () => { - // A startup auto-registration that finished in the background (or a credential refreshed in - // place) leaves the file unchanged across this request while the cached provider state still - // predates it; skipping would leave Base disconnected until a restart. - const existing = { apiKey: "sk-existing", baseURL: "https://gateway.test", installSecret: "s" } - mockCredentialsSequence(existing, existing) - mockRegister(async () => existing) - const list = spyOn(Provider, "list").mockResolvedValue( - {} as unknown as Awaited>, - ) + test("reloads for a credential registered in the background, even if one directory already sees Base", async () => { + // A startup auto-registration that finished after the server started leaves the file unchanged + // across this request, while caches built earlier (another directory, or the /api registry) + // still predate it. Whether this request's own directory lists Base says nothing about those. + const late = { apiKey: "sk-late", baseURL: "https://gateway.test", installSecret: "s" } + mockCredentialsSequence(late) + mockRegister(async () => late) + const list = spyOn(Provider, "list").mockResolvedValue({ + [FreeTier.PROVIDER_ID]: {}, + } as unknown as Awaited>) const disposeAll = spyOn(Instance, "disposeAll") try { const response = await app().request("/altimate/base/register", { From b103818242f7122fa721110ab3d0b720c864b636 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 15:15:25 +0800 Subject: [PATCH 14/27] fix: treat a renewed Base credential as changed, and scope the backoff docs - The register route's credential identity now includes the expiry. An expired credential loads as absent, so a renewal that reissues the same key and URL still reloads the provider caches. - Docs: the retry backoff follows only network errors, rate limits and gateway server errors. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- docs/docs/configure/providers.md | 4 +-- docs/docs/reference/network.md | 2 +- docs/docs/reference/security-faq.md | 2 +- packages/opencode/src/server/server.ts | 8 ++++-- .../server/altimate-base-registration.test.ts | 28 +++++++++++++++++++ 5 files changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index 11d9a7b6c4..0ce2467355 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -66,8 +66,8 @@ Choose **Altimate Base** from the first-run picker or `/connect` — or do nothi install that is not yet registered registers it automatically at startup, so it works the same way headlessly (`run`, `serve`, `acp`, `web`). Startup waits up to three seconds for this; a slower registration finishes in the background and applies from the next launch (a `serve` client can -apply it sooner through the register route). After a failed attempt, startup skips registration -for a retry backoff of one hour (longer if the gateway asks, up to 24 hours). This happens whether or not you +apply it sooner through the register route). After a network error, rate limit or gateway server error, startup skips +registration for a retry backoff of one hour (longer if the gateway asks, up to 24 hours). This happens whether or not you also have a model of your own; a registered Base only becomes your default when nothing you configured is usable. There is no confirmation dialog to accept. The disclosure above is shown once per install: in the TUI as a toast the first time Base becomes diff --git a/docs/docs/reference/network.md b/docs/docs/reference/network.md index c40798c753..4340d971b7 100644 --- a/docs/docs/reference/network.md +++ b/docs/docs/reference/network.md @@ -41,7 +41,7 @@ altimate needs outbound HTTPS access to: | Destination | Purpose | |-------------|---------| | Your LLM provider API | Model inference (Anthropic, OpenAI, etc.) | -| Official Altimate Base gateway (embedded in release), or the host set by `ALTIMATE_BASE_GATEWAY_URL` | Altimate Base registration (automatic at startup on any install not yet registered, unless `ALTIMATE_BASE_AUTO_REGISTER=0`, after logging out of Base, or during the 1–24 h retry backoff after a failed attempt) and inference | +| Official Altimate Base gateway (embedded in release), or the host set by `ALTIMATE_BASE_GATEWAY_URL` | Altimate Base registration (automatic at startup on any install not yet registered, unless `ALTIMATE_BASE_AUTO_REGISTER=0`, after logging out of Base, or during the 1–24 h retry backoff after a network error, rate limit or gateway server error) and inference | | `registry.npmjs.org` | Package updates | | `models.dev` | Model catalog (can be disabled) | | Your warehouse endpoints | Database connections | diff --git a/docs/docs/reference/security-faq.md b/docs/docs/reference/security-faq.md index 083ff06a3e..720e460b22 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -17,7 +17,7 @@ Altimate Base is Altimate's own hosted free model. By default, every install tha registered registers it automatically at startup, whether or not you also have a model of your own. Registration sends only a hash of a random per-install secret and the CLI version, not your prompts or code. It is skipped when `ALTIMATE_BASE_AUTO_REGISTER=0` is set, after you log out of Base, when no gateway is configured, and -during the retry backoff that follows a failed attempt. There is +during the retry backoff that follows a network error, rate limit or gateway server error. There is no confirmation dialog to accept. It only becomes your default model when nothing you configured is usable. Requests and responses are logged and may be used to improve Altimate products and services, including the model; secrets are diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 773a945c96..a096009c77 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -821,11 +821,15 @@ export namespace Server { // pure disruption for zero benefit. if (outcome.ok) { const after = await FreeTier.credentials().catch(() => undefined) - const changed = before?.apiKey !== after?.apiKey || before?.baseURL !== after?.baseURL + // Expiry is part of the identity: an expired credential loads as absent, so renewing it + // with the same key and URL still changes what a provider loader sees. + const identity = (value: typeof after) => + value ? `${value.baseURL}\n${value.apiKey}\n${value.expiresAt ?? ""}` : undefined + const fingerprint = identity(after) + const changed = identity(before) !== fingerprint // An unchanged file is not enough to skip: a startup registration that finished in the // background leaves caches built before it without Base, in any directory and in either // registry. Skip only when this process has already reloaded for this exact credential. - const fingerprint = after ? `${after.baseURL}\n${after.apiKey}` : undefined if (!changed && fingerprint !== undefined && fingerprint === appliedBaseCredential) { return c.json(outcome) } diff --git a/packages/opencode/test/server/altimate-base-registration.test.ts b/packages/opencode/test/server/altimate-base-registration.test.ts index 6f32602409..1e10fcbade 100644 --- a/packages/opencode/test/server/altimate-base-registration.test.ts +++ b/packages/opencode/test/server/altimate-base-registration.test.ts @@ -177,6 +177,34 @@ describe("Altimate Base registration route", () => { } }) + test("reloads when an applied credential is renewed with the same key and URL", async () => { + // An expired credential loads as absent, so directories opened after it expired cached no Base. + // Renewing it can reissue the same key and URL with only a new expiry; that still has to reload. + const expired = { apiKey: "sk-renew", baseURL: "https://gateway.test", installSecret: "s", expiresAt: "2026-01-01T00:00:00Z" } + const renewed = { ...expired, expiresAt: "2099-01-01T00:00:00Z" } + const disposeAll = spyOn(Instance, "disposeAll") + const post = () => + app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + try { + mockCredentialsSequence(expired) + mockRegister(async () => expired) + await post() + expect(disposeAll).toHaveBeenCalledTimes(1) + credentialsSpy?.mockRestore() + registerSpy?.mockRestore() + mockCredentialsSequence(expired, renewed) + mockRegister(async () => renewed) + expect(await (await post()).json()).toMatchObject({ ok: true }) + expect(disposeAll).toHaveBeenCalledTimes(2) + } finally { + disposeAll.mockRestore() + } + }) + test("reloads for a credential registered in the background, even if one directory already sees Base", async () => { // A startup auto-registration that finished after the server started leaves the file unchanged // across this request, while caches built earlier (another directory, or the /api registry) From fcd6c5bda9d264ff99c5de11a30e00edbd8ab1ed Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 15:28:14 +0800 Subject: [PATCH 15/27] fix: tell a credential reissued after logout from an untouched one The register route's credential identity now also covers the rejected flag and the logout nonce. Logout rotates the nonce, so a registration that reissues the same key, URL and expiry after a logout in another process still reloads the provider caches. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- packages/opencode/src/altimate/free/client.ts | 3 ++ packages/opencode/src/server/server.ts | 9 ++++-- .../server/altimate-base-registration.test.ts | 28 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 1fe3abc145..bc557876a0 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -54,6 +54,8 @@ export interface Credentials { expiresAt?: string installSecret: string rejected?: boolean + /** Rotated by every logout; lets a caller tell a re-registered credential from an untouched one. */ + logoutNonce?: string } export type RegistrationFailureKind = "network" | "http" | "response" | "cancelled" @@ -114,6 +116,7 @@ function credentialsFromStored(stored: FreeTierStore.Record | undefined): Creden expiresAt: stored.expiresAt, installSecret: stored.installSecret, ...(stored.rejected ? { rejected: true } : {}), + ...(stored.logoutNonce ? { logoutNonce: stored.logoutNonce } : {}), } } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index a096009c77..cd3c50be9a 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -821,10 +821,13 @@ export namespace Server { // pure disruption for zero benefit. if (outcome.ok) { const after = await FreeTier.credentials().catch(() => undefined) - // Expiry is part of the identity: an expired credential loads as absent, so renewing it - // with the same key and URL still changes what a provider loader sees. + // Identity covers everything that decides whether a loader sees Base: an expired or + // rejected credential loads as absent, and a logout elsewhere rotates the nonce, so the + // same key and URL reissued after either still has to reload. const identity = (value: typeof after) => - value ? `${value.baseURL}\n${value.apiKey}\n${value.expiresAt ?? ""}` : undefined + value + ? [value.baseURL, value.apiKey, value.expiresAt ?? "", value.rejected ? "rejected" : "", value.logoutNonce ?? ""].join("\n") + : undefined const fingerprint = identity(after) const changed = identity(before) !== fingerprint // An unchanged file is not enough to skip: a startup registration that finished in the diff --git a/packages/opencode/test/server/altimate-base-registration.test.ts b/packages/opencode/test/server/altimate-base-registration.test.ts index 1e10fcbade..6b7279de9d 100644 --- a/packages/opencode/test/server/altimate-base-registration.test.ts +++ b/packages/opencode/test/server/altimate-base-registration.test.ts @@ -205,6 +205,34 @@ describe("Altimate Base registration route", () => { } }) + test("reloads when the same credential is reissued after a logout in another process", async () => { + // Logout rotates the nonce; directories opened while logged out cached no Base. A later + // registration can reissue the identical key, URL and expiry, so only the nonce differs. + const first = { apiKey: "sk-aba", baseURL: "https://gateway.test", installSecret: "s", logoutNonce: "n1" } + const reissued = { ...first, logoutNonce: "n2" } + const disposeAll = spyOn(Instance, "disposeAll") + const post = () => + app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + try { + mockCredentialsSequence(first) + mockRegister(async () => first) + await post() + expect(disposeAll).toHaveBeenCalledTimes(1) + credentialsSpy?.mockRestore() + registerSpy?.mockRestore() + mockCredentialsSequence(reissued) + mockRegister(async () => reissued) + expect(await (await post()).json()).toMatchObject({ ok: true }) + expect(disposeAll).toHaveBeenCalledTimes(2) + } finally { + disposeAll.mockRestore() + } + }) + test("reloads for a credential registered in the background, even if one directory already sees Base", async () => { // A startup auto-registration that finished after the server started leaves the file unchanged // across this request, while caches built earlier (another directory, or the /api registry) From f29253fd938f07ff1be7f8c6ff6211cbe1560db7 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 15:36:12 +0800 Subject: [PATCH 16/27] fix: return the logout nonce from a fresh Base registration `registerOnce()` persisted the nonce but left it out of the returned credentials, so callers saw a different identity than the stored one. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- packages/opencode/src/altimate/free/client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index bc557876a0..b30f022bd0 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -365,6 +365,7 @@ async function registerOnce( baseURL, installSecret, ...(expiresAt ? { expiresAt } : {}), + ...(expectedLogoutNonce ? { logoutNonce: expectedLogoutNonce } : {}), } await FreeTierStore.write({ version: 1, From 66056a614409cd67fd63ecf7372a463be8fa7d75 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 16:06:51 +0800 Subject: [PATCH 17/27] fix: serialize the register route's reload, and tighten the Base tests - The register route runs its check-and-reload one call at a time, so concurrent calls cannot both dispose the same live resources. Inside that section it skips only when this process has already reloaded for the credential now on disk, which makes the before/after read unnecessary. - Tests: a concurrent-register case; an ACP case where the model changes between sessions; the picker's HTTP fallback asserts method and body; the disclosure notice flushes its KV write before cleanup; the retry case's name says it covers the re-fire only; the slow-registration test releases its gate on failure; the error tests assert their type and the budget message; a duplicate logout test is removed. - Docs: drop the stale "consented" registration wording. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- docs/docs/configure/providers.md | 2 +- packages/opencode/src/server/server.ts | 87 +++++++++---------- .../opencode/test/acp/service-session.test.ts | 28 ++++++ .../altimate-base-auto-register.test.ts | 13 +-- .../altimate/altimate-base-catalog.test.ts | 2 +- packages/opencode/test/provider/error.test.ts | 3 + .../server/altimate-base-registration.test.ts | 36 +++++++- .../cli/tui/dialog-model-welcome.test.tsx | 4 +- .../altimate-base-disclosure-notice.test.tsx | 6 +- .../component/select-altimate-base.test.ts | 6 +- 10 files changed, 120 insertions(+), 67 deletions(-) diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index 0ce2467355..38ee4c2dd5 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -111,7 +111,7 @@ altimate The URL must use HTTPS. Credentials, query strings, and fragments in the URL are rejected. `ALTIMATE_FREE_GATEWAY_URL` is retained as a legacy fallback, but `ALTIMATE_BASE_GATEWAY_URL` takes precedence. If the configured gateway host -changes, credentials issued by the previous host are not loaded and the consented registration +changes, credentials issued by the previous host are not loaded and the registration flow must run again. Altimate Base waits up to **5 minutes** for the gateway to send response headers, because the diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index cd3c50be9a..8fe667512d 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -76,6 +76,8 @@ export namespace Server { // because a cache built before a background registration finished cannot be told apart from one // built after it, in any directory or either registry. let appliedBaseCredential: string | undefined + // Serializes the register route's check-and-reload, so concurrent calls reload at most once. + let baseReloadQueue: Promise = Promise.resolve() // altimate_change end export const Default = lazy(() => createApp({})) @@ -781,15 +783,6 @@ export namespace Server { ) } - // altimate_change — Codex review finding: `FreeTier.register()` returns success whether - // it minted/rotated a credential OR just found an existing valid one (the idempotent - // fast path — see its "already registered" branch in free/client.ts). Everything below - // is instance-wide teardown that only matters when the credential on disk actually - // changed; comparing it before/after `gate.register()` (rather than changing - // `register()`'s own return shape, which every other caller — the picker, and a dozen - // existing tests — depends on as a plain `Credentials`) reports that without touching - // that contract. - const before = await FreeTier.credentials().catch(() => undefined) const gate = FreeTierConsent.createRegistrationGate({ register: () => FreeTier.register({ origin: "server" }), onUnexpectedError: (error) => log.error("Altimate Base registration failed", { error }), @@ -815,45 +808,47 @@ export namespace Server { // A failure in either leaves the credential written but provider lists possibly stale, so // it is reported rather than swallowed: the client needs to know its picker may be wrong. // - // Skipped entirely when nothing changed: an idempotent register (already valid - // credentials for this gateway) has nothing for a provider loader to re-read, so tearing - // down every session, LSP, PTY, MCP connection and file watcher in the process would be - // pure disruption for zero benefit. + // Skipped when this process has already reloaded for the credential now on disk: an + // idempotent register (the "already registered" fast path) then has nothing for a + // provider loader to re-read, and tearing down every session, LSP, PTY, MCP connection + // and file watcher again would be pure disruption. An unchanged file alone is not enough: + // a startup registration that finished in the background leaves caches built before it + // without Base, in any directory and in either registry. + // + // The check and the reload run one at a time, so two concurrent calls cannot both pass + // the check and dispose the same live resources twice; the second sees the first's mark. if (outcome.ok) { - const after = await FreeTier.credentials().catch(() => undefined) - // Identity covers everything that decides whether a loader sees Base: an expired or - // rejected credential loads as absent, and a logout elsewhere rotates the nonce, so the - // same key and URL reissued after either still has to reload. - const identity = (value: typeof after) => - value - ? [value.baseURL, value.apiKey, value.expiresAt ?? "", value.rejected ? "rejected" : "", value.logoutNonce ?? ""].join("\n") + const reload = baseReloadQueue.then(async () => { + const current = await FreeTier.credentials().catch(() => undefined) + // Identity covers everything that decides whether a loader sees Base: an expired or + // rejected credential loads as absent, and a logout elsewhere rotates the nonce, so the + // same key and URL reissued after either still has to reload. + const fingerprint = current + ? [current.baseURL, current.apiKey, current.expiresAt ?? "", current.rejected ? "rejected" : "", current.logoutNonce ?? ""].join("\n") : undefined - const fingerprint = identity(after) - const changed = identity(before) !== fingerprint - // An unchanged file is not enough to skip: a startup registration that finished in the - // background leaves caches built before it without Base, in any directory and in either - // registry. Skip only when this process has already reloaded for this exact credential. - if (!changed && fingerprint !== undefined && fingerprint === appliedBaseCredential) { - return c.json(outcome) - } - const disposed = await Promise.all([ - Instance.disposeAll().then( - () => true, - (error) => { - log.error("Altimate Base registered but legacy instance disposal failed", { error }) - return false - }, - ), - AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll())).then( - () => true, - (error) => { - log.error("Altimate Base registered but InstanceStore disposal failed", { error }) - return false - }, - ), - ]).then((results) => results.every(Boolean)) - if (disposed) appliedBaseCredential = fingerprint - return c.json(disposed ? outcome : { ...outcome, staleProviders: true as const }) + if (fingerprint !== undefined && fingerprint === appliedBaseCredential) return true + const disposed = await Promise.all([ + Instance.disposeAll().then( + () => true, + (error) => { + log.error("Altimate Base registered but legacy instance disposal failed", { error }) + return false + }, + ), + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll())).then( + () => true, + (error) => { + log.error("Altimate Base registered but InstanceStore disposal failed", { error }) + return false + }, + ), + ]).then((results) => results.every(Boolean)) + if (disposed) appliedBaseCredential = fingerprint + return disposed + }) + baseReloadQueue = reload.catch(() => undefined) + const reloaded = await reload + return c.json(reloaded ? outcome : { ...outcome, staleProviders: true as const }) } return c.json(outcome) }, diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index b87f394917..f3d15067f8 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -402,6 +402,34 @@ describe("ACP service sessions", () => { }) }) + // altimate_change start — the cases above all resolve to Base, so they cannot tell a fresh + // re-read from a reused first selection; this one changes the answer between sessions. + it("re-reads a recent pick that changes between sessions in a cached directory", async () => { + const base = { + ...provider, + id: ProviderID.make("altimate-free"), + models: { + [ModelID.make("altimate-base")]: { + ...provider.models[modelID], + id: ModelID.make("altimate-base"), + providerID: ProviderID.make("altimate-free"), + }, + }, + } satisfies Provider.Info + await withTestStateHome(async () => { + const stateFile = path.join(Global.Path.state, "model.json") + await fs.writeFile(stateFile, JSON.stringify({ recent: [{ providerID: "altimate-free", modelID: "altimate-base" }] })) + const { service } = makeService([], { providers: [provider, base] }) + const first = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + expect(select(first, "model")?.currentValue).toBe("altimate-free/altimate-base") + + await fs.writeFile(stateFile, JSON.stringify({ recent: [{ providerID, modelID }] })) + const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + expect(select(second, "model")?.currentValue).toBe(`${providerID}/${modelID}`) + }) + }) + // altimate_change end + it("fails before creating a session when the configured model is unavailable", async () => { const bigPickleProvider = { ...provider, diff --git a/packages/opencode/test/altimate/altimate-base-auto-register.test.ts b/packages/opencode/test/altimate/altimate-base-auto-register.test.ts index b29b1dee2c..4ed96491c1 100644 --- a/packages/opencode/test/altimate/altimate-base-auto-register.test.ts +++ b/packages/opencode/test/altimate/altimate-base-auto-register.test.ts @@ -99,18 +99,6 @@ describe("FreeTier.autoRegister", () => { expect(gateway.registerCalls).toHaveLength(0) }) - test("a logout that lands before the registration lock is acquired is not missed", async () => { - // Simulates the race the spec calls out: nothing has registered yet (no pre-existing - // credential), and a logout call — which takes the SAME lock — completes before autoRegister's - // own lock body runs. Because that body reads the store fresh from inside the lock (no - // pre-lock "expected" value carried in), it sees the logout unconditionally. - await FreeTierStore.remove() - await FreeTier.logout() - const result = await FreeTier.autoRegister() - expect(result).toEqual({ status: "skipped", reason: "logged-out" }) - expect(gateway.registerCalls).toHaveLength(0) - }) - test("is skipped when no gateway URL is configured, with no network call", async () => { delete process.env.ALTIMATE_BASE_GATEWAY_URL delete process.env.ALTIMATE_FREE_GATEWAY_URL @@ -199,6 +187,7 @@ describe("FreeTier.autoRegisterWithin", () => { const registered = await waitFor(() => FreeTier.isRegistered(), (v) => v === true) expect(registered).toBe(true) } finally { + resolveRequest() slow.mockRestore() } }) diff --git a/packages/opencode/test/altimate/altimate-base-catalog.test.ts b/packages/opencode/test/altimate/altimate-base-catalog.test.ts index 9c97370bb8..6a31ff1ffc 100644 --- a/packages/opencode/test/altimate/altimate-base-catalog.test.ts +++ b/packages/opencode/test/altimate/altimate-base-catalog.test.ts @@ -57,7 +57,7 @@ afterEach(() => { gateway.restore() }) -/** Registers a real credential through the production consent path against the fake gateway. */ +/** Registers a real credential through the real `/register` path against the fake gateway. */ async function registerCredential(): Promise { gateway.registerNext({ kind: "ok" }) await FreeTier.register({ origin: "picker" }) diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts index 947f0062fe..3355539862 100644 --- a/packages/opencode/test/provider/error.test.ts +++ b/packages/opencode/test/provider/error.test.ts @@ -464,6 +464,7 @@ describe("ProviderError.parseAPICallError: Altimate Base isolation", () => { providerID: "altimate-free" as any, error: rateLimited("throttling_error", "", { "retry-after": "12" }), }) + expect(result.type).toBe("api_error") if (result.type === "api_error") { expect(result.responseHeaders?.["retry-after"]).toBe("12") } @@ -526,6 +527,8 @@ describe("ProviderError.parseAPICallError: Altimate Base isolation", () => { "retry-after": "900", }), }) + expect(result.type).toBe("api_error") + expect(result.message).toContain("Altimate Base has reached its shared daily limit") if (result.type === "api_error") { expect(result.isRetryable).toBe(false) expect(result.responseHeaders?.["retry-after"]).toBe("900") diff --git a/packages/opencode/test/server/altimate-base-registration.test.ts b/packages/opencode/test/server/altimate-base-registration.test.ts index 6b7279de9d..07945673f3 100644 --- a/packages/opencode/test/server/altimate-base-registration.test.ts +++ b/packages/opencode/test/server/altimate-base-registration.test.ts @@ -136,7 +136,7 @@ describe("Altimate Base registration route", () => { // idempotent path is pure disruption for zero benefit, since no provider loader has anything new // to re-read. test("disposes every instance when registration actually mints a new credential", async () => { - mockCredentialsSequence(undefined, { apiKey: "sk-new", baseURL: "https://gateway.test", installSecret: "s" }) + mockCredentialsSequence({ apiKey: "sk-new", baseURL: "https://gateway.test", installSecret: "s" }) mockRegister(async () => ({ apiKey: "sk-new", baseURL: "https://gateway.test", installSecret: "s" })) const disposeAll = spyOn(Instance, "disposeAll") try { @@ -196,7 +196,7 @@ describe("Altimate Base registration route", () => { expect(disposeAll).toHaveBeenCalledTimes(1) credentialsSpy?.mockRestore() registerSpy?.mockRestore() - mockCredentialsSequence(expired, renewed) + mockCredentialsSequence(renewed) mockRegister(async () => renewed) expect(await (await post()).json()).toMatchObject({ ok: true }) expect(disposeAll).toHaveBeenCalledTimes(2) @@ -233,6 +233,38 @@ describe("Altimate Base registration route", () => { } }) + test("two concurrent register calls reload once", async () => { + const shared = { apiKey: "sk-concurrent", baseURL: "https://gateway.test", installSecret: "s" } + mockCredentialsSequence(shared) + mockRegister(async () => shared) + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + // Hold the first disposal open so the second request arrives while it is still in flight. + const disposeAll = spyOn(Instance, "disposeAll").mockImplementation(async () => { + await held + }) + const post = () => + app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + try { + const first = post() + const second = post() + await Bun.sleep(50) + release() + const bodies = await Promise.all([first, second].map(async (r) => (await r).json())) + for (const body of bodies) expect(body).toMatchObject({ ok: true }) + expect(disposeAll).toHaveBeenCalledTimes(1) + } finally { + release() + disposeAll.mockRestore() + } + }) + test("reloads for a credential registered in the background, even if one directory already sees Base", async () => { // A startup auto-registration that finished after the server started leaves the file unchanged // across this request, while caches built earlier (another directory, or the /api registry) diff --git a/packages/tui/test/cli/tui/dialog-model-welcome.test.tsx b/packages/tui/test/cli/tui/dialog-model-welcome.test.tsx index b8e2a58998..4e69c96e90 100644 --- a/packages/tui/test/cli/tui/dialog-model-welcome.test.tsx +++ b/packages/tui/test/cli/tui/dialog-model-welcome.test.tsx @@ -288,7 +288,9 @@ test("outside a first run the picker records an impression but not a choice", as // first-run welcome picker — the one shown to users with no model at all — a failed Base // registration then bricked Enter, `/` and mouse-up for the rest of the dialog session. Confirms // the fix: the same row can be retried after a failure, and the register attempt actually re-fires. -test("a failed Altimate Base selection on the welcome picker can be retried", async () => { +// It covers the re-fire only: the mocked `/provider` never lists Base's model, so the second +// attempt cannot complete a selection here (selectAltimateBase's success path is tested directly). +test("a failed Altimate Base selection on the welcome picker re-fires registration when retried", async () => { const picker = await mountPicker("first_run", [...ALL_PROVIDER_IDS, "altimate-free"], { registerOutcomes: ["error", "ok"], }) diff --git a/packages/tui/test/component/altimate-base-disclosure-notice.test.tsx b/packages/tui/test/component/altimate-base-disclosure-notice.test.tsx index a9be684183..837d14abc9 100644 --- a/packages/tui/test/component/altimate-base-disclosure-notice.test.tsx +++ b/packages/tui/test/component/altimate-base-disclosure-notice.test.tsx @@ -42,7 +42,7 @@ const baseProvider = { id: "altimate-free", name: "Altimate", models: { "altimat async function mount(options: { preSeedShown: boolean }) { const [ - { KVProvider }, + { KVProvider, useKV }, { LocalProvider, useLocal }, { ArgsProvider }, { ThemeProvider }, @@ -87,10 +87,12 @@ async function mount(options: { preSeedShown: boolean }) { let localAccessor: ReturnType | undefined let toastAccessor: ReturnType | undefined + let kvAccessor: ReturnType | undefined const shownMessages: string[] = [] function Probe() { localAccessor = useLocal() toastAccessor = useToast() + kvAccessor = useKV() useAltimateBaseDisclosureNotice() createEffect(() => { const message = toastAccessor?.currentToast?.message @@ -139,6 +141,8 @@ async function mount(options: { preSeedShown: boolean }) { }, async cleanup() { app.renderer.destroy() + // Let the notice's "shown" write land before its state directory is removed. + await kvAccessor?.flush() await tmp[Symbol.asyncDispose]() }, } diff --git a/packages/tui/test/component/select-altimate-base.test.ts b/packages/tui/test/component/select-altimate-base.test.ts index d15d812d92..5a69aa713c 100644 --- a/packages/tui/test/component/select-altimate-base.test.ts +++ b/packages/tui/test/component/select-altimate-base.test.ts @@ -160,8 +160,8 @@ describe("selectAltimateBase", () => { const calls: string[] = [] const fakes = fakeCollaborators({ registerAltimateBase: undefined, - fetchImpl: (async (input: RequestInfo | URL) => { - calls.push(String(input)) + fetchImpl: (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push(`${init?.method} ${String(input)} ${String(init?.body)}`) return Response.json({ ok: true }) }) as typeof fetch, }) @@ -169,7 +169,7 @@ describe("selectAltimateBase", () => { const result = await selectAltimateBase(fakes) expect(result).toBe(true) - expect(calls).toEqual(["http://test/altimate/base/register"]) + expect(calls).toEqual(["POST http://test/altimate/base/register {}"]) expect(fakes.modelSetCalls).toHaveLength(1) expect(fakes.dialogReplaceCount).toBe(0) }) From 13c5c783c98d6fbde1d70bd88c93898c5ccf9042 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 16:12:53 +0800 Subject: [PATCH 18/27] fix: keep Zen's variant off a restored Base session, and cycle past a repaired Zen pick - Restoring a session whose model was keyless Zen selects Altimate Base instead; the prompt now applies the saved variant only when the restored model is the one that was saved, and clears it otherwise. - `cycle()` resolves stale keyless-Zen entries in `recent` to Base the way `currentModel()` does, so a repaired current model is found in the order and cycling moves on instead of doing nothing. New test covers it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- packages/tui/src/component/prompt/index.tsx | 7 +- packages/tui/src/context/local.tsx | 11 +- .../tui/test/context/stale-zen-cycle.test.tsx | 179 ++++++++++++++++++ 3 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 packages/tui/test/context/stale-zen-cycle.test.tsx diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 64e99b58cf..defb14f13a 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -400,8 +400,11 @@ export function Prompt(props: PromptProps) { if (msg.model) { // altimate_change start — restore the recorded model, and its effort only if that model // was actually applied (an invalid/unavailable model must not keep a stale variant) - if (local.model.restoreSession(msg.model)) { - local.model.variant.set(msg.model.variant) + const restored = local.model.restoreSession(msg.model) + if (restored) { + // A stale keyless-Zen model is restored as Altimate Base; Zen's variant means nothing there. + const same = restored.providerID === msg.model.providerID && restored.modelID === msg.model.modelID + local.model.variant.set(same ? msg.model.variant : undefined) } // altimate_change end } diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 4fc4fb5b26..fc5a4d07f3 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -916,7 +916,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const findCurrent = (order: readonly { providerID: string; modelID: string }[]) => order.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) if (!cycleOrder || cycleOrderVersion !== recentsVersion || findCurrent(cycleOrder) === -1) { - cycleOrder = modelStore.recent.slice() + // Resolve stale keyless-Zen entries to Base the same way `currentModel()` does, so a + // repaired current model is found in the order and Zen is never cycled back onto. + const seen = new Set() + cycleOrder = modelStore.recent.flatMap((entry) => { + const resolved = substituteStaleZen(entry) ?? entry + const key = `${resolved.providerID}/${resolved.modelID}` + if (seen.has(key)) return [] + seen.add(key) + return [{ providerID: resolved.providerID, modelID: resolved.modelID }] + }) cycleOrderVersion = recentsVersion } const index = findCurrent(cycleOrder) diff --git a/packages/tui/test/context/stale-zen-cycle.test.tsx b/packages/tui/test/context/stale-zen-cycle.test.tsx new file mode 100644 index 0000000000..2877611ee5 --- /dev/null +++ b/packages/tui/test/context/stale-zen-cycle.test.tsx @@ -0,0 +1,179 @@ +// A stale keyless-Zen entry at the front of `recent` is shown as Altimate Base by +// `currentModel()`. `cycle()` must resolve that entry the same way, or the repaired current model +// is missing from its order and cycling does nothing. +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { tmpdir } from "../fixture/fixture" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" +import { createEventSource, createFetch, directory, json } from "../fixture/tui-sdk" + +async function waitUntil(predicate: () => boolean, timeout = 2_000) { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(5) + } +} + +const STALE_ZEN = { providerID: "opencode", modelID: "model-a" } +const BASE = { providerID: "altimate-free", modelID: "altimate-base" } +const OWN = { providerID: "anthropic", modelID: "own-model" } + +function makeModel(id: string, providerID = "opencode") { + return { + id, + providerID, + name: id, + family: providerID, + status: "active", + capabilities: {}, + cost: { input: 0, output: 0 }, + limit: { context: 65_536, output: 4_096 }, + } +} + +async function mount() { + const [ + { KVProvider }, + { LocalProvider, useLocal }, + { ArgsProvider }, + { ThemeProvider }, + { ToastProvider }, + { SDKProvider }, + { ProjectProvider }, + { SyncProvider }, + { RouteProvider }, + { ExitProvider }, + { TuiConfigProvider }, + ] = await Promise.all([ + import("../../src/context/kv"), + import("../../src/context/local"), + import("../../src/context/args"), + import("../../src/context/theme"), + import("../../src/ui/toast"), + import("../../src/context/sdk"), + import("../../src/context/project"), + import("../../src/context/sync"), + import("../../src/context/route"), + import("../../src/context/exit"), + import("../../src/config"), + ]) + + const tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + await Bun.write(path.join(state, "kv.json"), "{}") + // The only persisted history is a stale keyless-Zen pick followed by the user's own model. + await Bun.write(path.join(state, "model.json"), JSON.stringify({ recent: [STALE_ZEN, OWN] })) + + const zenProvider = { + id: "opencode", + name: "OpenCode Zen", + options: { apiKey: "public" }, + models: { "model-a": makeModel("model-a") }, + env: [], + } + const baseProvider = { + id: "altimate-free", + name: "Altimate Base", + models: { "altimate-base": makeModel("altimate-base", "altimate-free") }, + env: [], + } + const ownProvider = { + id: "anthropic", + name: "Anthropic", + models: { "own-model": makeModel("own-model", "anthropic") }, + env: [], + } + const providers = [zenProvider, baseProvider, ownProvider] + const agent = { + name: "build", + mode: "primary" as const, + hidden: false, + permission: {}, + options: {}, + } + const inner = createFetch((url) => { + if (url.pathname === "/instance/dispose") return json({}) + if (url.pathname === "/config/providers") return json({ providers, default: {} }) + if (url.pathname === "/provider") + return json({ all: providers, default: {}, connected: ["opencode", "altimate-free", "anthropic"] }) + if (url.pathname === "/agent") return json([agent]) + if (url.pathname === "/project/proj_test/directories") return json([]) + return undefined + }) + const source = createEventSource() + + let localAccessor: ReturnType | undefined + function Capture() { + localAccessor = useLocal() + return null + } + + const app = await testRender(() => ( + + {}}> + + + + + + + + + + + + + + + + + + + + + + + + )) + await app.renderOnce() + await waitUntil(() => localAccessor !== undefined && localAccessor.model.ready) + const local = localAccessor! + + return { + local, + async cleanup() { + app.renderer.destroy() + await local.model.persisted().catch(() => {}) + await tmp[Symbol.asyncDispose]() + }, + } +} + + +test("cycle() moves off a Base model that replaced a stale keyless-Zen recent", async () => { + const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME + await using isolatedState = await tmpdir() + process.env.OPENCODE_TEST_STATE_HOME = isolatedState.path + const { local, cleanup } = await mount() + try { + // A keyless-Zen pick for this agent (e.g. carried over from an older session) shows as Base. + await waitUntil(() => local.model.ready) + local.model.set(STALE_ZEN) + await waitUntil(() => local.model.current()?.modelID === BASE.modelID) + local.model.cycle(1) + await waitUntil(() => local.model.current()?.modelID === OWN.modelID) + local.model.cycle(1) + // Back to Base, never onto the keyless-Zen entry the order was built from. + await waitUntil(() => local.model.current()?.modelID === BASE.modelID) + expect(local.model.current()?.providerID).toBe(BASE.providerID) + } finally { + await cleanup() + if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME + else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome + } +}) From 3a975b4af98e1d35fa0a039b49a9a580ec517bbc Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 16:22:48 +0800 Subject: [PATCH 19/27] fix: keep the rejected-to-restored reload, cycle off explicit Zen, and pin the concurrency test - The register route reads the credential before registering again and compares it inside the serialized section, so a rejected credential restored with identical fields still reloads. New test covers it. - `cycle()` keeps keyless-Zen entries as-is when the current model is an explicit Zen pick, which `currentModel()` honors, so cycling can move away from it. New test covers it. - The concurrency test waits for both requests to pass registration and for the first disposal to start before releasing it, instead of a fixed sleep. It fails without the serialization. - Test fixture: rename a mock provider to avoid a product-name leak. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- packages/opencode/src/server/server.ts | 21 ++++--- .../server/altimate-base-registration.test.ts | 55 +++++++++++++++++-- packages/tui/src/context/local.tsx | 8 ++- .../tui/test/context/stale-zen-cycle.test.tsx | 25 ++++++++- 4 files changed, 91 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 8fe667512d..f62dfdae2e 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -783,6 +783,9 @@ export namespace Server { ) } + // Read before registering: a credential that was rejected, expired or logged out and is + // now reissued with identical fields still has to reload, and only this read sees that. + const before = await FreeTier.credentials().catch(() => undefined) const gate = FreeTierConsent.createRegistrationGate({ register: () => FreeTier.register({ origin: "server" }), onUnexpectedError: (error) => log.error("Altimate Base registration failed", { error }), @@ -818,15 +821,17 @@ export namespace Server { // The check and the reload run one at a time, so two concurrent calls cannot both pass // the check and dispose the same live resources twice; the second sees the first's mark. if (outcome.ok) { - const reload = baseReloadQueue.then(async () => { - const current = await FreeTier.credentials().catch(() => undefined) - // Identity covers everything that decides whether a loader sees Base: an expired or - // rejected credential loads as absent, and a logout elsewhere rotates the nonce, so the - // same key and URL reissued after either still has to reload. - const fingerprint = current - ? [current.baseURL, current.apiKey, current.expiresAt ?? "", current.rejected ? "rejected" : "", current.logoutNonce ?? ""].join("\n") + // Identity covers everything that decides whether a loader sees Base: an expired or + // rejected credential loads as absent, and a logout elsewhere rotates the nonce, so the + // same key and URL reissued after either still has to reload. + const identity = (value: typeof before) => + value + ? [value.baseURL, value.apiKey, value.expiresAt ?? "", value.rejected ? "rejected" : "", value.logoutNonce ?? ""].join("\n") : undefined - if (fingerprint !== undefined && fingerprint === appliedBaseCredential) return true + const reload = baseReloadQueue.then(async () => { + const fingerprint = identity(await FreeTier.credentials().catch(() => undefined)) + const changed = identity(before) !== fingerprint + if (!changed && fingerprint !== undefined && fingerprint === appliedBaseCredential) return true const disposed = await Promise.all([ Instance.disposeAll().then( () => true, diff --git a/packages/opencode/test/server/altimate-base-registration.test.ts b/packages/opencode/test/server/altimate-base-registration.test.ts index 07945673f3..ff9306c2ab 100644 --- a/packages/opencode/test/server/altimate-base-registration.test.ts +++ b/packages/opencode/test/server/altimate-base-registration.test.ts @@ -236,13 +236,28 @@ describe("Altimate Base registration route", () => { test("two concurrent register calls reload once", async () => { const shared = { apiKey: "sk-concurrent", baseURL: "https://gateway.test", installSecret: "s" } mockCredentialsSequence(shared) - mockRegister(async () => shared) + // Both requests must be past registration before either reloads, so they genuinely overlap. + let entered = 0 + let bothEntered!: () => void + const overlap = new Promise((resolve) => { + bothEntered = resolve + }) + mockRegister(async () => { + if (++entered === 2) bothEntered() + await overlap + return shared + }) let release!: () => void const held = new Promise((resolve) => { release = resolve }) - // Hold the first disposal open so the second request arrives while it is still in flight. + let firstDisposeStarted!: () => void + const disposing = new Promise((resolve) => { + firstDisposeStarted = resolve + }) + // Hold the first disposal open while the second request is already waiting behind it. const disposeAll = spyOn(Instance, "disposeAll").mockImplementation(async () => { + firstDisposeStarted() await held }) const post = () => @@ -252,11 +267,11 @@ describe("Altimate Base registration route", () => { body: JSON.stringify({}), }) try { - const first = post() - const second = post() - await Bun.sleep(50) + const requests = [post(), post()] + await overlap + await disposing release() - const bodies = await Promise.all([first, second].map(async (r) => (await r).json())) + const bodies = await Promise.all(requests.map(async (r) => (await r).json())) for (const body of bodies) expect(body).toMatchObject({ ok: true }) expect(disposeAll).toHaveBeenCalledTimes(1) } finally { @@ -265,6 +280,34 @@ describe("Altimate Base registration route", () => { } }) + test("reloads when a rejected credential is restored with identical fields", async () => { + // Directories opened while the credential was rejected cached no Base; clearing the flag can + // reissue exactly the credential this process reloaded for before it was rejected. + const good = { apiKey: "sk-rejected", baseURL: "https://gateway.test", installSecret: "s" } + const rejected = { ...good, rejected: true } + const disposeAll = spyOn(Instance, "disposeAll") + const post = () => + app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + try { + mockCredentialsSequence(good) + mockRegister(async () => good) + await post() + expect(disposeAll).toHaveBeenCalledTimes(1) + credentialsSpy?.mockRestore() + registerSpy?.mockRestore() + mockCredentialsSequence(rejected, good) + mockRegister(async () => good) + expect(await (await post()).json()).toMatchObject({ ok: true }) + expect(disposeAll).toHaveBeenCalledTimes(2) + } finally { + disposeAll.mockRestore() + } + }) + test("reloads for a credential registered in the background, even if one directory already sees Base", async () => { // A startup auto-registration that finished after the server started leaves the file unchanged // across this request, while caches built earlier (another directory, or the /api registry) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index fc5a4d07f3..3af63f88ab 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -917,10 +917,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ order.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) if (!cycleOrder || cycleOrderVersion !== recentsVersion || findCurrent(cycleOrder) === -1) { // Resolve stale keyless-Zen entries to Base the same way `currentModel()` does, so a - // repaired current model is found in the order and Zen is never cycled back onto. + // repaired current model is found in the order and Zen is never cycled back onto. An + // explicit Zen pick (`--model`, config, agent) stays Zen in `currentModel()`, so the + // order keeps Zen as-is then, or cycling could not find it to move away. + const currentProvider = sync.data.provider.find((candidate) => candidate.id === current.providerID) + const keepZen = !!currentProvider && isPublicZenProvider(currentProvider) const seen = new Set() cycleOrder = modelStore.recent.flatMap((entry) => { - const resolved = substituteStaleZen(entry) ?? entry + const resolved = keepZen ? entry : (substituteStaleZen(entry) ?? entry) const key = `${resolved.providerID}/${resolved.modelID}` if (seen.has(key)) return [] seen.add(key) diff --git a/packages/tui/test/context/stale-zen-cycle.test.tsx b/packages/tui/test/context/stale-zen-cycle.test.tsx index 2877611ee5..8c807e478b 100644 --- a/packages/tui/test/context/stale-zen-cycle.test.tsx +++ b/packages/tui/test/context/stale-zen-cycle.test.tsx @@ -35,7 +35,7 @@ function makeModel(id: string, providerID = "opencode") { } } -async function mount() { +async function mount(agentModel?: { providerID: string; modelID: string }) { const [ { KVProvider }, { LocalProvider, useLocal }, @@ -71,7 +71,7 @@ async function mount() { const zenProvider = { id: "opencode", - name: "OpenCode Zen", + name: "Zen", options: { apiKey: "public" }, models: { "model-a": makeModel("model-a") }, env: [], @@ -95,6 +95,7 @@ async function mount() { hidden: false, permission: {}, options: {}, + ...(agentModel ? { model: agentModel } : {}), } const inner = createFetch((url) => { if (url.pathname === "/instance/dispose") return json({}) @@ -155,6 +156,26 @@ async function mount() { } +test("cycle() still moves off an explicitly chosen keyless-Zen model", async () => { + const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME + await using isolatedState = await tmpdir() + process.env.OPENCODE_TEST_STATE_HOME = isolatedState.path + // The agent's own configured model is explicit, so `currentModel()` keeps it as Zen. + const { local, cleanup } = await mount(STALE_ZEN) + try { + await waitUntil(() => local.model.ready) + await waitUntil(() => local.model.current()?.providerID === STALE_ZEN.providerID) + await Bun.sleep(100) + expect(local.model.current()?.providerID).toBe(STALE_ZEN.providerID) + local.model.cycle(1) + await waitUntil(() => local.model.current()?.modelID === OWN.modelID) + } finally { + await cleanup() + if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME + else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome + } +}) + test("cycle() moves off a Base model that replaced a stale keyless-Zen recent", async () => { const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME await using isolatedState = await tmpdir() From c64a48c24a11486a08b61580a341544b65d4fb76 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 16:34:34 +0800 Subject: [PATCH 20/27] fix: reload once when overlapping register calls repair the same credential Each register request notes the reload count before its pre-registration read. If a reload for the credential now on disk finished since then, it already covers that read, so overlapping repairs of a rejected, expired or logged-out credential reload once. A sequential restore still reloads. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- packages/opencode/src/server/server.ts | 14 +++++++-- .../server/altimate-base-registration.test.ts | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f62dfdae2e..8da4b9cff7 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -78,6 +78,8 @@ export namespace Server { let appliedBaseCredential: string | undefined // Serializes the register route's check-and-reload, so concurrent calls reload at most once. let baseReloadQueue: Promise = Promise.resolve() + // Bumped by every completed reload, so a request can tell that one ran after its own read. + let baseReloadGeneration = 0 // altimate_change end export const Default = lazy(() => createApp({})) @@ -785,6 +787,7 @@ export namespace Server { // Read before registering: a credential that was rejected, expired or logged out and is // now reissued with identical fields still has to reload, and only this read sees that. + const generationAtStart = baseReloadGeneration const before = await FreeTier.credentials().catch(() => undefined) const gate = FreeTierConsent.createRegistrationGate({ register: () => FreeTier.register({ origin: "server" }), @@ -830,8 +833,12 @@ export namespace Server { : undefined const reload = baseReloadQueue.then(async () => { const fingerprint = identity(await FreeTier.credentials().catch(() => undefined)) + // A reload that finished after this request's read, for the credential now on disk, + // already covers whatever that read saw: overlapping repairs reload once. + const coveredSinceRead = baseReloadGeneration !== generationAtStart const changed = identity(before) !== fingerprint - if (!changed && fingerprint !== undefined && fingerprint === appliedBaseCredential) return true + const applied = fingerprint !== undefined && fingerprint === appliedBaseCredential + if (applied && (!changed || coveredSinceRead)) return true const disposed = await Promise.all([ Instance.disposeAll().then( () => true, @@ -848,7 +855,10 @@ export namespace Server { }, ), ]).then((results) => results.every(Boolean)) - if (disposed) appliedBaseCredential = fingerprint + if (disposed) { + appliedBaseCredential = fingerprint + baseReloadGeneration++ + } return disposed }) baseReloadQueue = reload.catch(() => undefined) diff --git a/packages/opencode/test/server/altimate-base-registration.test.ts b/packages/opencode/test/server/altimate-base-registration.test.ts index ff9306c2ab..67b9a65675 100644 --- a/packages/opencode/test/server/altimate-base-registration.test.ts +++ b/packages/opencode/test/server/altimate-base-registration.test.ts @@ -280,6 +280,37 @@ describe("Altimate Base registration route", () => { } }) + test("two overlapping repairs of a rejected credential reload once", async () => { + // Both requests read the rejected credential before either registers, then share one repair. + const good = { apiKey: "sk-overlap", baseURL: "https://gateway.test", installSecret: "s" } + const rejected = { ...good, rejected: true } + mockCredentialsSequence(rejected, rejected, good) + let entered = 0 + let bothEntered!: () => void + const overlap = new Promise((resolve) => { + bothEntered = resolve + }) + mockRegister(async () => { + if (++entered === 2) bothEntered() + await overlap + return good + }) + const disposeAll = spyOn(Instance, "disposeAll") + const post = () => + app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + try { + const bodies = await Promise.all([post(), post()].map(async (r) => (await r).json())) + for (const body of bodies) expect(body).toMatchObject({ ok: true }) + expect(disposeAll).toHaveBeenCalledTimes(1) + } finally { + disposeAll.mockRestore() + } + }) + test("reloads when a rejected credential is restored with identical fields", async () => { // Directories opened while the credential was rejected cached no Base; clearing the flag can // reissue exactly the credential this process reloaded for before it was rejected. From c16e5211d93531eba4bb5862ebe5e7dec9c8d525 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 16:41:05 +0800 Subject: [PATCH 21/27] fix: snapshot the reload count after the pre-registration read A reload finishing while the read was in flight counted as covering it, which could skip a reload a later rejection made necessary. Taking the snapshot after the read errs toward one extra reload instead. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- packages/opencode/src/server/server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 8da4b9cff7..0960cced14 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -787,8 +787,10 @@ export namespace Server { // Read before registering: a credential that was rejected, expired or logged out and is // now reissued with identical fields still has to reload, and only this read sees that. - const generationAtStart = baseReloadGeneration const before = await FreeTier.credentials().catch(() => undefined) + // Snapshot after the read: a reload that finished during it must not count as covering + // it. Erring this way costs at most one extra reload, never a stale directory. + const generationAtStart = baseReloadGeneration const gate = FreeTierConsent.createRegistrationGate({ register: () => FreeTier.register({ origin: "server" }), onUnexpectedError: (error) => log.error("Altimate Base registration failed", { error }), From 16873f0738db95121f4425f1e1d1b8d94063431a Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 16:47:26 +0800 Subject: [PATCH 22/27] fix: capture the pre-registration read and reload count on the reload queue Reading the credential and noting the reload count as one step on the same serialized queue as the reloads means no reload can finish in between, so the count says exactly which reloads followed the read: overlapping repairs reload once and a stale read never skips a needed reload. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- packages/opencode/src/server/server.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 0960cced14..42cf06339d 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -787,10 +787,14 @@ export namespace Server { // Read before registering: a credential that was rejected, expired or logged out and is // now reissued with identical fields still has to reload, and only this read sees that. - const before = await FreeTier.credentials().catch(() => undefined) - // Snapshot after the read: a reload that finished during it must not count as covering - // it. Erring this way costs at most one extra reload, never a stale directory. - const generationAtStart = baseReloadGeneration + // The read and the reload count are captured together on the reload queue, so no reload + // can finish in between: the count says exactly which reloads came after this read. + const snapshot = baseReloadQueue.then(async () => ({ + before: await FreeTier.credentials().catch(() => undefined), + generationAtStart: baseReloadGeneration, + })) + baseReloadQueue = snapshot.catch(() => undefined) + const { before, generationAtStart } = await snapshot const gate = FreeTierConsent.createRegistrationGate({ register: () => FreeTier.register({ origin: "server" }), onUnexpectedError: (error) => log.error("Altimate Base registration failed", { error }), From 0565811785210e13b73d3db3aafe6387039d8d38 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 18:00:25 +0800 Subject: [PATCH 23/27] fix: honor an explicit keyless-Zen pick, and notice a late Base registration in the same launch - The TUI records whether each agent's selection was explicit (`--model`, picker, cycle, favorite). `currentModel()` repairs a stale keyless-Zen selection to Base only when it was not explicit, e.g. a session restored before Base existed, so an explicit Zen choice gets Zen's own error instead of being routed to Base. - `autoRegisterWithin()` takes an optional callback for a registration that finishes after the startup wait gave up. `run`, `serve` (except for the VS Code extension), `acp` and `web` use it to print the disclosure in the same launch rather than the next. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- packages/opencode/src/altimate/free/client.ts | 17 +++++++- packages/opencode/src/cli/cmd/acp.ts | 4 +- packages/opencode/src/cli/cmd/run.ts | 3 +- packages/opencode/src/cli/cmd/serve.ts | 13 ++++-- packages/opencode/src/cli/cmd/web.ts | 4 +- .../altimate-base-auto-register.test.ts | 40 ++++++++++++++++++- packages/tui/src/context/local.tsx | 8 +++- .../tui/test/context/stale-zen-cycle.test.tsx | 23 ++++++++++- 8 files changed, 100 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index b30f022bd0..34da580918 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -734,15 +734,28 @@ export async function autoRegister(signal?: AbortSignal): Promise { +export function autoRegisterWithin( + ms = 3000, + /** Called if the startup wait gave up ("pending") and the attempt then registered Base. */ + onLateRegistration?: () => void, +): Promise { const attempt = autoRegister().catch((error) => { log.error("Altimate Base auto-registration rejected unexpectedly", { error }) return { status: "failed", kind: "error" } as const }) + let gaveUp = false const timeout = new Promise<{ status: "pending" }>((resolve) => { - const timer = setTimeout(() => resolve({ status: "pending" }), ms) + const timer = setTimeout(() => { + gaveUp = true + resolve({ status: "pending" }) + }, ms) timer.unref?.() }) + if (onLateRegistration) { + void attempt.then((result) => { + if (gaveUp && result.status === "registered") onLateRegistration() + }) + } return Promise.race([attempt, timeout]) } diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 1e4e9d1cdf..1a728f4483 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -26,8 +26,10 @@ export const AcpCommand = effectCmd({ process.env.OPENCODE_CLIENT = "acp" // altimate_change start — auto-register before Server.listen, ahead of the ACP directory // snapshot (providers/defaultModel) that ACP.init/loadDirectorySnapshot builds - const autoRegisterResult = yield* Effect.promise(() => FreeTier.autoRegisterWithin()) const { FreeTierConsent } = yield* Effect.promise(() => import("@/altimate/free/consent")) + const autoRegisterResult = yield* Effect.promise(() => + FreeTier.autoRegisterWithin(undefined, () => void FreeTierConsent.printDisclosureOnceForHeadless(true)), + ) yield* Effect.promise(() => FreeTierConsent.printDisclosureOnceForHeadless(autoRegisterResult.status === "registered")) // altimate_change end const opts = yield* resolveNetworkOptions(args) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 81f0a12fe2..1a5af3bc6e 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1662,7 +1662,8 @@ You are speaking to a non-technical business executive. Follow these rules stric { const { FreeTier } = await import("../../altimate/free/client") const { FreeTierConsent } = await import("../../altimate/free/consent") - const result = await FreeTier.autoRegisterWithin() + // A registration that outlasts the wait still gets its notice in this launch, not the next. + const result = await FreeTier.autoRegisterWithin(undefined, () => void FreeTierConsent.printDisclosureOnceForHeadless(true)) await FreeTierConsent.printDisclosureOnceForHeadless(result.status === "registered") } // altimate_change end diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 2c95752a46..96ad919055 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -48,12 +48,19 @@ export const ServeCommand = effectCmd({ // altimate_change start — auto-register Altimate Base before provider state is first built. // `serve` is the VS Code/Cursor extension's process — no TUI, no interactive gate — so this is // the only chance to have Base ready before the first provider list/default-model resolution. - const autoRegisterResult = yield* Effect.promise(() => FreeTier.autoRegisterWithin()) // The VS Code extension (ALTIMATE_CLI_CLIENT=datamates) renders its own notice in the chat // panel; printing this one too would be a duplicate for the one client that actually has a UI // for it. Every other `serve` caller has no UI at all, so stderr is the only surface it has. - if (OpencodeFlag.ALTIMATE_CLI_CLIENT !== "datamates") { - const { FreeTierConsent } = yield* Effect.promise(() => import("../../altimate/free/consent")) + const printsNotice = OpencodeFlag.ALTIMATE_CLI_CLIENT !== "datamates" + const { FreeTierConsent } = yield* Effect.promise(() => import("../../altimate/free/consent")) + // A registration that outlasts the wait still gets its notice in this process, not the next. + const autoRegisterResult = yield* Effect.promise(() => + FreeTier.autoRegisterWithin( + undefined, + printsNotice ? () => void FreeTierConsent.printDisclosureOnceForHeadless(true) : undefined, + ), + ) + if (printsNotice) { yield* Effect.promise(() => FreeTierConsent.printDisclosureOnceForHeadless(autoRegisterResult.status === "registered")) } // altimate_change end diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 6262260fbd..6eea9e3e6a 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -45,7 +45,9 @@ export const WebCommand = cmd({ } const opts = await AppRuntime.runPromise(resolveNetworkOptions(args)) // altimate_change start — auto-register Altimate Base before provider state is first built - const autoRegisterResult = await FreeTier.autoRegisterWithin() + const autoRegisterResult = await FreeTier.autoRegisterWithin(undefined, () => + void FreeTierConsent.printDisclosureOnceForHeadless(true), + ) await FreeTierConsent.printDisclosureOnceForHeadless(autoRegisterResult.status === "registered") // altimate_change end const server = Server.listen(opts) diff --git a/packages/opencode/test/altimate/altimate-base-auto-register.test.ts b/packages/opencode/test/altimate/altimate-base-auto-register.test.ts index 4ed96491c1..f64f8bbf95 100644 --- a/packages/opencode/test/altimate/altimate-base-auto-register.test.ts +++ b/packages/opencode/test/altimate/altimate-base-auto-register.test.ts @@ -144,8 +144,46 @@ describe("FreeTier.autoRegister", () => { describe("FreeTier.autoRegisterWithin", () => { test("returns once registered, well within a generous budget", async () => { gateway.registerNext({ kind: "ok" }) - const result = await FreeTier.autoRegisterWithin(3000) + let late = 0 + const result = await FreeTier.autoRegisterWithin(3000, () => late++) expect(result).toEqual({ status: "registered" }) + // Registered within the wait: the caller's own result covers it, so no late callback. + await Bun.sleep(20) + expect(late).toBe(0) + }) + + test("reports a registration that finishes after the wait gave up", async () => { + gateway.restore() + let resolveRequest!: () => void + const gate = new Promise((resolve) => { + resolveRequest = resolve + }) + const slow = spyOn(globalThis, "fetch").mockImplementation((async ( + _input: RequestInfo | URL, + _init?: RequestInit, + ) => { + await gate + return new Response( + JSON.stringify({ + api_key: "sk-altimate-base-late", + base_url: GATEWAY_URL, + model: FreeTier.MODEL_ID, + expires_at: new Date(Date.now() + 86_400_000).toISOString(), + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + }) as typeof fetch) + try { + let late = 0 + expect(await FreeTier.autoRegisterWithin(30, () => late++)).toEqual({ status: "pending" }) + expect(late).toBe(0) + resolveRequest() + expect(await waitFor(() => Promise.resolve(late), (v) => v === 1)).toBe(1) + expect(await FreeTier.isRegistered()).toBe(true) + } finally { + resolveRequest() + slow.mockRestore() + } }) test("returns at the budget while a slow registration keeps going in the background", async () => { diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 3af63f88ab..b5aaa63c08 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -680,6 +680,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // `--model`/config `model` (see `explicitFallbackModel` above `fallbackModel()`). Each // candidate is checked in the SAME priority order this memo used before; only the two // implicit branches route through `substituteStaleZen`. + // Whether each agent's in-memory selection was an explicit pick (`--model`, picker, cycle, + // favorite). Only a non-explicit one, e.g. a session restored before Base existed, is repaired. + const [explicitAgentPick, setExplicitAgentPick] = createStore>({}) + function substituteStaleZen(model: { providerID: string; modelID: string } | undefined) { if (!model) return model const provider = sync.data.provider.find((candidate) => candidate.id === model.providerID) @@ -693,7 +697,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const a = agent.current() const persistedAgentPick = a ? modelStore.model[a.name] : undefined - if (persistedAgentPick && isModelValid(persistedAgentPick)) return substituteStaleZen(persistedAgentPick) + if (persistedAgentPick && isModelValid(persistedAgentPick)) + return explicitAgentPick[a!.name] ? persistedAgentPick : substituteStaleZen(persistedAgentPick) const agentConfiguredModel = a?.model if (agentConfiguredModel && isModelValid(agentConfiguredModel)) return agentConfiguredModel @@ -720,6 +725,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const a = agent.current() if (!a) return setModelStore("model", a.name, model) + setExplicitAgentPick(a.name, !!options?.explicit) if (options?.recent) setRecent(recentModels(model, modelStore.recent)) // A picker-driven selection, as opposed to session restore or programmatic migration — // see `hasExplicitModel` above for why this needs its own persisted marker. diff --git a/packages/tui/test/context/stale-zen-cycle.test.tsx b/packages/tui/test/context/stale-zen-cycle.test.tsx index 8c807e478b..3e62261e4a 100644 --- a/packages/tui/test/context/stale-zen-cycle.test.tsx +++ b/packages/tui/test/context/stale-zen-cycle.test.tsx @@ -182,9 +182,9 @@ test("cycle() moves off a Base model that replaced a stale keyless-Zen recent", process.env.OPENCODE_TEST_STATE_HOME = isolatedState.path const { local, cleanup } = await mount() try { - // A keyless-Zen pick for this agent (e.g. carried over from an older session) shows as Base. + // A session last run on keyless Zen is restored as Base: a repaired, non-explicit selection. await waitUntil(() => local.model.ready) - local.model.set(STALE_ZEN) + local.model.restoreSession(STALE_ZEN) await waitUntil(() => local.model.current()?.modelID === BASE.modelID) local.model.cycle(1) await waitUntil(() => local.model.current()?.modelID === OWN.modelID) @@ -198,3 +198,22 @@ test("cycle() moves off a Base model that replaced a stale keyless-Zen recent", else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome } }) + +test("an explicit keyless-Zen selection, as --model hands it over, is not replaced by Base", async () => { + const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME + await using isolatedState = await tmpdir() + process.env.OPENCODE_TEST_STATE_HOME = isolatedState.path + const { local, cleanup } = await mount() + try { + await waitUntil(() => local.model.ready) + // The same call app.tsx makes for `--model`, and the pickers make for a deliberate choice. + local.model.set(STALE_ZEN, { recent: true }) + await waitUntil(() => local.model.current()?.providerID === STALE_ZEN.providerID) + await Bun.sleep(100) + expect(local.model.current()).toMatchObject(STALE_ZEN) + } finally { + await cleanup() + if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME + else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome + } +}) From 51c6a8ae4173f4e4fc400d21646d021e3e5605da Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 18:27:47 +0800 Subject: [PATCH 24/27] fix: keep an explicit keyless-Zen pick across conversation switches The TUI now remembers which models were explicitly picked during this launch rather than flagging the agent's current selection, which session restore reset. Both `currentModel()` and `restoreSession()` leave an explicitly picked keyless-Zen model alone, so returning to a conversation doesn't reroute it to Base. Tests pass model copies, because the store merges a newly set object into the one already stored. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- packages/tui/src/context/local.tsx | 14 +++++---- .../tui/test/context/stale-zen-cycle.test.tsx | 31 +++++++++++++++++-- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index b5aaa63c08..1515418061 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -680,9 +680,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // `--model`/config `model` (see `explicitFallbackModel` above `fallbackModel()`). Each // candidate is checked in the SAME priority order this memo used before; only the two // implicit branches route through `substituteStaleZen`. - // Whether each agent's in-memory selection was an explicit pick (`--model`, picker, cycle, - // favorite). Only a non-explicit one, e.g. a session restored before Base existed, is repaired. - const [explicitAgentPick, setExplicitAgentPick] = createStore>({}) + // Models explicitly picked during this launch (`--model`, picker, cycle, favorite). A stale + // keyless-Zen selection is repaired to Base only when it is not one of these, so switching + // conversations and back cannot reroute a deliberate choice. See R8 for restarts. + const [explicitPicks, setExplicitPicks] = createStore>({}) + const pickKey = (model: { providerID: string; modelID: string }) => `${model.providerID}/${model.modelID}` function substituteStaleZen(model: { providerID: string; modelID: string } | undefined) { if (!model) return model @@ -698,7 +700,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const persistedAgentPick = a ? modelStore.model[a.name] : undefined if (persistedAgentPick && isModelValid(persistedAgentPick)) - return explicitAgentPick[a!.name] ? persistedAgentPick : substituteStaleZen(persistedAgentPick) + return explicitPicks[pickKey(persistedAgentPick)] ? persistedAgentPick : substituteStaleZen(persistedAgentPick) const agentConfiguredModel = a?.model if (agentConfiguredModel && isModelValid(agentConfiguredModel)) return agentConfiguredModel @@ -725,7 +727,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const a = agent.current() if (!a) return setModelStore("model", a.name, model) - setExplicitAgentPick(a.name, !!options?.explicit) + if (options?.explicit) setExplicitPicks(pickKey(model), true) if (options?.recent) setRecent(recentModels(model, modelStore.recent)) // A picker-driven selection, as opposed to session restore or programmatic migration — // see `hasExplicitModel` above for why this needs its own persisted marker. @@ -1088,7 +1090,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ restoreSession(model: ModelRef) { const provider = sync.data.provider.find((candidate) => candidate.id === model.providerID) const resolved = - provider && isPublicZenProvider(provider) && isModelValid(ALTIMATE_BASE_MODEL) + provider && isPublicZenProvider(provider) && isModelValid(ALTIMATE_BASE_MODEL) && !explicitPicks[pickKey(model)] ? { ...ALTIMATE_BASE_MODEL } : model if (!selectModel(resolved)) return undefined diff --git a/packages/tui/test/context/stale-zen-cycle.test.tsx b/packages/tui/test/context/stale-zen-cycle.test.tsx index 3e62261e4a..afa2d61ebf 100644 --- a/packages/tui/test/context/stale-zen-cycle.test.tsx +++ b/packages/tui/test/context/stale-zen-cycle.test.tsx @@ -18,6 +18,8 @@ async function waitUntil(predicate: () => boolean, timeout = 2_000) { } } +// Always pass copies: the model store merges a newly set object into the one already there, +// so handing it these constants directly would let one call overwrite another's constant. const STALE_ZEN = { providerID: "opencode", modelID: "model-a" } const BASE = { providerID: "altimate-free", modelID: "altimate-base" } const OWN = { providerID: "anthropic", modelID: "own-model" } @@ -161,7 +163,7 @@ test("cycle() still moves off an explicitly chosen keyless-Zen model", async () await using isolatedState = await tmpdir() process.env.OPENCODE_TEST_STATE_HOME = isolatedState.path // The agent's own configured model is explicit, so `currentModel()` keeps it as Zen. - const { local, cleanup } = await mount(STALE_ZEN) + const { local, cleanup } = await mount({ ...STALE_ZEN }) try { await waitUntil(() => local.model.ready) await waitUntil(() => local.model.current()?.providerID === STALE_ZEN.providerID) @@ -184,7 +186,7 @@ test("cycle() moves off a Base model that replaced a stale keyless-Zen recent", try { // A session last run on keyless Zen is restored as Base: a repaired, non-explicit selection. await waitUntil(() => local.model.ready) - local.model.restoreSession(STALE_ZEN) + local.model.restoreSession({ ...STALE_ZEN }) await waitUntil(() => local.model.current()?.modelID === BASE.modelID) local.model.cycle(1) await waitUntil(() => local.model.current()?.modelID === OWN.modelID) @@ -207,7 +209,7 @@ test("an explicit keyless-Zen selection, as --model hands it over, is not replac try { await waitUntil(() => local.model.ready) // The same call app.tsx makes for `--model`, and the pickers make for a deliberate choice. - local.model.set(STALE_ZEN, { recent: true }) + local.model.set({ ...STALE_ZEN }, { recent: true }) await waitUntil(() => local.model.current()?.providerID === STALE_ZEN.providerID) await Bun.sleep(100) expect(local.model.current()).toMatchObject(STALE_ZEN) @@ -217,3 +219,26 @@ test("an explicit keyless-Zen selection, as --model hands it over, is not replac else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome } }) + +test("an explicit keyless-Zen pick survives switching conversations and back", async () => { + const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME + await using isolatedState = await tmpdir() + process.env.OPENCODE_TEST_STATE_HOME = isolatedState.path + const { local, cleanup } = await mount() + try { + await waitUntil(() => local.model.ready) + local.model.set({ ...STALE_ZEN }, { recent: true }) + await waitUntil(() => local.model.current()?.providerID === STALE_ZEN.providerID) + // Open another conversation recorded on the user's own model, then return to the Zen one. + expect(local.model.restoreSession({ ...OWN })).toMatchObject(OWN) + await waitUntil(() => local.model.current()?.modelID === OWN.modelID) + expect(local.model.restoreSession({ ...STALE_ZEN })).toMatchObject(STALE_ZEN) + await waitUntil(() => local.model.current()?.providerID === STALE_ZEN.providerID) + expect(local.model.current()).toMatchObject(STALE_ZEN) + } finally { + await cleanup() + if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME + else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome + } +}) + From e31844dc76321ffa1d890fa003511fb1b40a6955 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 19:11:29 +0800 Subject: [PATCH 25/27] fix: carry an explicit keyless-Zen pick to an agent without its own model An agent with no stored pick inherits the most recent one through `fallbackModel()`, whose recents loop skipped a keyless-Zen entry without checking this launch's explicit picks. The loop and the shared Zen repair helper now both honor that record, so Tab to another agent keeps a deliberate Zen choice. New test covers it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- packages/tui/src/context/local.tsx | 19 ++++++++------- .../tui/test/context/stale-zen-cycle.test.tsx | 24 ++++++++++++++++++- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 1515418061..40b6723021 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -610,6 +610,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) // altimate_change end + // altimate_change start — models explicitly picked during this launch (`--model`, picker, + // cycle, favorite). A stale keyless-Zen selection is repaired to Base only when it is not one + // of these, so switching conversations or agents cannot reroute a deliberate choice. See R8 + // for restarts. Declared before `fallbackModel`, which reads it as soon as it is created. + const [explicitPicks, setExplicitPicks] = createStore>({}) + const pickKey = (model: { providerID: string; modelID: string }) => `${model.providerID}/${model.modelID}` + // altimate_change end + const fallbackModel = createMemo(() => { // altimate_change const explicit = explicitFallbackModel() // altimate_change — declared above if (explicit) return explicit // altimate_change @@ -644,7 +652,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (!isModelValid(item)) continue if (baseAvailable) { const provider = sync.data.provider.find((candidate) => candidate.id === item.providerID) - if (provider && isPublicZenProvider(provider)) continue + if (provider && isPublicZenProvider(provider) && !explicitPicks[pickKey(item)]) continue } return item } @@ -680,16 +688,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // `--model`/config `model` (see `explicitFallbackModel` above `fallbackModel()`). Each // candidate is checked in the SAME priority order this memo used before; only the two // implicit branches route through `substituteStaleZen`. - // Models explicitly picked during this launch (`--model`, picker, cycle, favorite). A stale - // keyless-Zen selection is repaired to Base only when it is not one of these, so switching - // conversations and back cannot reroute a deliberate choice. See R8 for restarts. - const [explicitPicks, setExplicitPicks] = createStore>({}) - const pickKey = (model: { providerID: string; modelID: string }) => `${model.providerID}/${model.modelID}` function substituteStaleZen(model: { providerID: string; modelID: string } | undefined) { if (!model) return model const provider = sync.data.provider.find((candidate) => candidate.id === model.providerID) - if (provider && isPublicZenProvider(provider) && isModelValid(ALTIMATE_BASE_MODEL)) { + if (provider && isPublicZenProvider(provider) && isModelValid(ALTIMATE_BASE_MODEL) && !explicitPicks[pickKey(model)]) { return { ...ALTIMATE_BASE_MODEL } } return model @@ -700,7 +703,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const persistedAgentPick = a ? modelStore.model[a.name] : undefined if (persistedAgentPick && isModelValid(persistedAgentPick)) - return explicitPicks[pickKey(persistedAgentPick)] ? persistedAgentPick : substituteStaleZen(persistedAgentPick) + return substituteStaleZen(persistedAgentPick) const agentConfiguredModel = a?.model if (agentConfiguredModel && isModelValid(agentConfiguredModel)) return agentConfiguredModel diff --git a/packages/tui/test/context/stale-zen-cycle.test.tsx b/packages/tui/test/context/stale-zen-cycle.test.tsx index afa2d61ebf..971a7a9522 100644 --- a/packages/tui/test/context/stale-zen-cycle.test.tsx +++ b/packages/tui/test/context/stale-zen-cycle.test.tsx @@ -104,7 +104,7 @@ async function mount(agentModel?: { providerID: string; modelID: string }) { if (url.pathname === "/config/providers") return json({ providers, default: {} }) if (url.pathname === "/provider") return json({ all: providers, default: {}, connected: ["opencode", "altimate-free", "anthropic"] }) - if (url.pathname === "/agent") return json([agent]) + if (url.pathname === "/agent") return json([agent, { ...agent, name: "plan" }]) if (url.pathname === "/project/proj_test/directories") return json([]) return undefined }) @@ -242,3 +242,25 @@ test("an explicit keyless-Zen pick survives switching conversations and back", a } }) +test("an explicit keyless-Zen pick carries to another agent without its own model", async () => { + const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME + await using isolatedState = await tmpdir() + process.env.OPENCODE_TEST_STATE_HOME = isolatedState.path + const { local, cleanup } = await mount() + try { + await waitUntil(() => local.model.ready) + local.model.set({ ...STALE_ZEN }, { recent: true }) + await waitUntil(() => local.model.current()?.providerID === STALE_ZEN.providerID) + const from = local.agent.current()?.name + local.agent.move(1) + await waitUntil(() => local.agent.current()?.name !== from) + // The other agent has no pick of its own, so it inherits the most recent one: the Zen choice. + await Bun.sleep(100) + expect(local.model.current()).toMatchObject(STALE_ZEN) + } finally { + await cleanup() + if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME + else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome + } +}) + From 8ed38f8052df8adffdd50e1a796ee52da9316011 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Wed, 23 Sep 2026 19:18:18 +0800 Subject: [PATCH 26/27] fix: key explicit picks unambiguously Provider and model IDs can contain slashes, so joining them with one could let two different picks share a key. Encode the pair as JSON. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- packages/tui/src/context/local.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 40b6723021..647a69d8da 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -615,7 +615,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // of these, so switching conversations or agents cannot reroute a deliberate choice. See R8 // for restarts. Declared before `fallbackModel`, which reads it as soon as it is created. const [explicitPicks, setExplicitPicks] = createStore>({}) - const pickKey = (model: { providerID: string; modelID: string }) => `${model.providerID}/${model.modelID}` + const pickKey = (model: { providerID: string; modelID: string }) => JSON.stringify([model.providerID, model.modelID]) // altimate_change end const fallbackModel = createMemo(() => { // altimate_change From 5903ed457a13c655e9cd1ac4fb920ad3f2ac6416 Mon Sep 17 00:00:00 2001 From: Haider Date: Wed, 23 Sep 2026 17:44:10 +0530 Subject: [PATCH 27/27] test: pin autoRegisterWithin's late-notice callback wiring per entrypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 re-review NIT: nothing checked that run/acp/web always pass a real onLateRegistration callback, or that serve does the same except when serving the VS Code extension (ALTIMATE_CLI_CLIENT=datamates, which renders its own notice). Source-assertion test, following the pattern in test/branding/upstream-guard.test.ts — the CLI commands are Effect-based and heavy to execute directly. Verified by mutation: dropping run.ts's callback, and dropping serve.ts's datamates gate, each fail exactly one assertion. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01H8gZMvZunXzx4LPSZzCafq --- .../entrypoint-late-notice-wiring.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 packages/opencode/test/altimate/entrypoint-late-notice-wiring.test.ts diff --git a/packages/opencode/test/altimate/entrypoint-late-notice-wiring.test.ts b/packages/opencode/test/altimate/entrypoint-late-notice-wiring.test.ts new file mode 100644 index 0000000000..b678dfc203 --- /dev/null +++ b/packages/opencode/test/altimate/entrypoint-late-notice-wiring.test.ts @@ -0,0 +1,76 @@ +/** + * Pins that every entrypoint wires autoRegisterWithin()'s onLateRegistration callback the way + * the headless-disclosure guarantee requires: run/acp/web always pass a real callback so a + * registration that completes after the startup wait still prints the disclosure once; serve + * passes one too, EXCEPT when it's serving the VS Code extension (ALTIMATE_CLI_CLIENT=datamates), + * which renders its own notice in the chat panel and must not get a second one from stdout. + * + * Source assertions, not execution — the CLI commands here are Effect-based and heavy to run + * directly. Follows the pattern in test/branding/upstream-guard.test.ts. + */ +import { describe, test, expect } from "bun:test" +import { readFileSync } from "fs" +import { join, resolve } from "path" + +const cmdDir = resolve(import.meta.dir, "..", "..", "src", "cli", "cmd") + +function read(file: string): string { + return readFileSync(join(cmdDir, file), "utf-8") +} + +/** + * Extracts the argument list of the first `autoRegisterWithin(...)` call, respecting nested + * parens (its own arguments include calls like `printDisclosureOnceForHeadless(true)`, which a + * naive non-greedy regex stops inside of instead of at the real closing paren). + */ +function autoRegisterWithinArgs(source: string): string | null { + const start = source.indexOf("autoRegisterWithin(") + if (start === -1) return null + const openParen = start + "autoRegisterWithin".length + let depth = 0 + for (let i = openParen; i < source.length; i++) { + if (source[i] === "(") depth++ + else if (source[i] === ")") { + depth-- + if (depth === 0) return source.slice(openParen + 1, i) + } + } + return null +} + +// A real callback: any arrow function that ultimately calls printDisclosureOnceForHeadless(true). +// Matches both single-line (`() => void FreeTierConsent.printDisclosureOnceForHeadless(true)`) +// and multi-line arrow bodies, without caring about exact whitespace. +const REAL_CALLBACK = /\(\)\s*=>[\s\S]{0,80}?printDisclosureOnceForHeadless\(true\)/ + +describe("autoRegisterWithin() late-notice callback wiring per entrypoint", () => { + test.each(["run.ts", "acp.ts", "web.ts"])("%s always passes a real onLateRegistration callback", (file) => { + const source = read(file) + const args = autoRegisterWithinArgs(source) + expect(args, `${file} must call autoRegisterWithin()`).not.toBeNull() + expect(args, `${file}'s autoRegisterWithin() call`).toMatch(REAL_CALLBACK) + // Guards against a regression that passes the callback conditionally (that's serve.ts's job, + // not these three) — none of them may reference ALTIMATE_CLI_CLIENT or ternary out. + expect(args, `${file} must not gate its callback like serve.ts does`).not.toMatch(/\?\s*\(\)\s*=>/) + }) + + test("serve.ts passes a real callback when NOT serving the datamates (VS Code) client", () => { + const source = read("serve.ts") + expect(source).toMatch(/ALTIMATE_CLI_CLIENT\s*!==\s*["']datamates["']/) + const args = autoRegisterWithinArgs(source) + expect(args, "serve.ts must call autoRegisterWithin()").not.toBeNull() + // The callback argument must be conditioned on the same flag check, with `undefined` as the + // datamates branch — asserting the ternary shape directly, not just "a callback exists + // somewhere in this file" (which the datamates test below would also satisfy). + expect(args).toMatch(/printsNotice\s*\?[\s\S]{0,80}?printDisclosureOnceForHeadless\(true\)[\s\S]{0,20}?:\s*undefined/) + }) + + test("serve.ts's printsNotice is false exactly when ALTIMATE_CLI_CLIENT is datamates", () => { + const source = read("serve.ts") + // printsNotice must be defined FROM the datamates check — not some other, unrelated + // condition that happens to also gate the callback. If a future refactor renames or + // decouples this, the previous test's ternary-shape assertion would still pass on a + // `printsNotice` that no longer means "not datamates" — this pins the definition itself. + expect(source).toMatch(/printsNotice\s*=\s*OpencodeFlag\.ALTIMATE_CLI_CLIENT\s*!==\s*["']datamates["']/) + }) +})