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..38ee4c2dd5 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -62,23 +62,43 @@ 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: every +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 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 +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 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 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 +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 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: @@ -91,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/docs/docs/getting-started/quickstart.md b/docs/docs/getting-started/quickstart.md index 8067420bbc..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. 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. 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 4f1bc30588..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 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 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 9611255454..720e460b22 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -11,13 +11,23 @@ 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 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 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. 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 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 +automatically masked before storage, but don't rely on it — avoid sending secrets or confidential +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 +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 +120,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 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 5f1dad6e69..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 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, 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. | @@ -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. | diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 74b41a1b99..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" @@ -787,23 +790,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 +841,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 +863,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 && isPublicZen(provider)) continue return { providerID, modelID } } @@ -890,18 +902,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 && 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 +923,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 — + // 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 +967,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 && 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/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 227464fc48..34da580918 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -1,6 +1,7 @@ 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 { FreeTierCapability } from "./capability" import { Installation } from "../../installation" import { Log } from "../util/log" import { FreeTierStore } from "./store" @@ -21,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 @@ -29,10 +47,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 @@ -40,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" @@ -49,6 +65,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" @@ -96,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 } : {}), } } @@ -116,8 +137,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 } @@ -170,6 +191,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 @@ -299,7 +332,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 @@ -329,6 +365,7 @@ async function registerOnce( baseURL, installSecret, ...(expiresAt ? { expiresAt } : {}), + ...(expectedLogoutNonce ? { logoutNonce: expectedLogoutNonce } : {}), } await FreeTierStore.write({ version: 1, @@ -345,47 +382,35 @@ async function registerOnce( } /** - * Register only after redeeming a one-shot consent token. - * - * 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. + * 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). * - * 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. + * 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). * - * 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` 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 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) - throw expired - } let configuredGateway: string try { configuredGateway = gatewayUrl() } catch (error) { - reportRegistration(registrationResult(error), startedAt, error) + reportRegistration(registrationResult(error), startedAt, error, input.origin) throw error } - // altimate_change end const dedupeKey = configuredGateway - const pending = inflight.get(dedupeKey) + const pending = explicitInflight.get(dedupeKey) if (pending) return pending const started = (async () => { @@ -394,7 +419,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 +430,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,20 +445,20 @@ 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) }) })().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. started.then( - () => reportRegistration("success", startedAt), - (error: unknown) => reportRegistration(registrationResult(error), startedAt, error), + () => reportRegistration("success", startedAt, undefined, input.origin), + (error: unknown) => reportRegistration(registrationResult(error), startedAt, error, input.origin), ) // altimate_change end return started @@ -450,7 +475,14 @@ function registrationResult(error: unknown): RegistrationResult { return "error" } -function reportRegistration(result: RegistrationResult, startedAt: number, error?: unknown) { +function reportRegistration( + result: RegistrationResult, + startedAt: number, + error?: unknown, + // "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 = { type: "altimate_base_registration", @@ -459,6 +491,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 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 } // 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 +511,254 @@ 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" | "backoff" } + | { status: "failed"; kind: Exclude } + +function autoRegisterDisabledByEnv(): boolean { + const raw = process.env["ALTIMATE_BASE_AUTO_REGISTER"]?.trim().toLowerCase() + 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 +// 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, + error: unknown, +): number | undefined { + if (kind === "network") return AUTO_REGISTER_BACKOFF_MS + if (kind === "http" && error instanceof RegistrationError) { + 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 +} + +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] +} + +// Concurrent entrypoints (a TUI and a `serve`, say) can update the record at the same moment; the +// read-modify-write runs under its own lock so one cannot drop or resurrect the other's entry. +// Not LOCK_KEY: these run after the registration lock is released, and must not queue behind it. +const BACKOFF_LOCK_KEY = "altimate-base-auto-register-backoff" + +async function setPersistedAutoRegisterBackoff(gateway: string, until: number): Promise { + 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 { + 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 +// 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 + * 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 at startup, automatically — no consent gate, no user action. Every entrypoint calls + * 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. + */ +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" } + + // 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 = autoInflight.get(dedupeKey) + const startedAt = performance.now() + const started = + existing ?? + (() => { + const promise = autoRegisterLocked(configuredGateway, signal).finally(() => { + if (autoInflight.get(dedupeKey) === promise) autoInflight.delete(dedupeKey) + }) + autoInflight.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") + // 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) { + 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, + /** 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(() => { + 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]) +} + function targetUrl(input: RequestInfo | URL): string { return typeof input === "string" ? input : input instanceof URL ? input.href : input.url } @@ -515,8 +804,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 @@ -531,8 +820,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 @@ -570,13 +859,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/altimate/free/consent.ts b/packages/opencode/src/altimate/free/consent.ts index 721fbf86fe..86c44e017d 100644 --- a/packages/opencode/src/altimate/free/consent.ts +++ b/packages/opencode/src/altimate/free/consent.ts @@ -1,15 +1,19 @@ 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" /** - * 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 +21,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 +39,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") { @@ -86,4 +86,58 @@ export function createRegistrationConsentGate(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") +} + + + +/** + * 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 { + // 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}`) +} + +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 + export * as FreeTierConsent from "./consent" 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 8754661a9c..6f09e3de16 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -689,6 +689,13 @@ export namespace Telemetry { duration_ms: number /** HTTP status when result is "http". */ status?: number + /** 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/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 1e0532ce99..1a728f4483 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 start — 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,14 @@ 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 + 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) // 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..1a5af3bc6e 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1656,6 +1656,17 @@ 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") + const { FreeTierConsent } = await import("../../altimate/free/consent") + // 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 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..96ad919055 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -5,23 +5,18 @@ 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 -// 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 +33,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.") @@ -65,6 +45,25 @@ 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. + // 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. + 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 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..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" @@ -23,6 +21,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 +167,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. @@ -252,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/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index f0e54f8eee..6eea9e3e6a 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -6,6 +6,10 @@ import { AppRuntime } from "../../effect/app-runtime" import { Flag } from "../../flag/flag" import open from "open" import { networkInterfaces } from "os" +// 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() @@ -40,6 +44,12 @@ 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 + const autoRegisterResult = await FreeTier.autoRegisterWithin(undefined, () => + void FreeTierConsent.printDisclosureOnceForHeadless(true), + ) + await FreeTierConsent.printDisclosureOnceForHeadless(autoRegisterResult.status === "registered") + // altimate_change end const server = Server.listen(opts) UI.empty() UI.println(UI.logo(" ")) 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/provider/error.ts b/packages/opencode/src/provider/error.ts index aeea86f1ff..fd8c849f2c 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -275,6 +275,52 @@ 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. + // + // 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 { + 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 + // 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 +375,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 — 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: + "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, + 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 +433,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/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f05cd2ec18..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,6 +2196,7 @@ export namespace Provider { ) } + // 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 +2235,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() + // 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 +2254,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 +2282,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 +2296,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/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 cd4f1a8738..42cf06339d 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -38,14 +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" @@ -72,6 +71,16 @@ 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 + // 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({})) // altimate_change start — upstream_fix: preserve upstream v1.17.9 /api HttpApi routes. @@ -678,30 +687,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 +718,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 +736,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. 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: { @@ -771,18 +763,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 +785,21 @@ 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 + // 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. + // 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 }), + }) + 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 @@ -839,24 +819,57 @@ 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 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 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)) - return c.json(disposed ? outcome : { ...outcome, staleProviders: true as const }) + // 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 + 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 + const applied = fingerprint !== undefined && fingerprint === appliedBaseCredential + if (applied && (!changed || coveredSinceRead)) 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 + baseReloadGeneration++ + } + 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/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b2ebabeefe..d2d2095fef 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 @@ -99,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 @@ -2030,9 +2035,30 @@ 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 `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) { + // 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 && 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..f3d15067f8 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: [] })) @@ -397,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, @@ -455,7 +488,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 +517,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 +543,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 +570,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 +598,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/_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-auto-register.test.ts b/packages/opencode/test/altimate/altimate-base-auto-register.test.ts new file mode 100644 index 0000000000..f64f8bbf95 --- /dev/null +++ b/packages/opencode/test/altimate/altimate-base-auto-register.test.ts @@ -0,0 +1,355 @@ +// 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) + // 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(() => { + 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("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" }) + 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 () => { + // 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 { + resolveRequest() + slow.mockRestore() + } + }) +}) + +// 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("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) => { + 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/altimate/altimate-base-catalog.test.ts b/packages/opencode/test/altimate/altimate-base-catalog.test.ts index ef3999916a..6a31ff1ffc 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 @@ -62,10 +57,10 @@ 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.registerAfterConsent(consented()) + await FreeTier.register({ origin: "picker" }) } describe("model catalog: altimate-free/altimate-base", () => { @@ -173,15 +168,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/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 6a515f6ad2..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}$/) @@ -53,9 +48,9 @@ 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()) + await FreeTier.register({ origin: "picker" }) gateway.chatNext({ kind: "throttle-tokens" }) const response = await FreeTier.authorizedFetch(`${GATEWAY_URL}/v1/chat/completions`, { @@ -67,9 +62,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-headless-disclosure.test.ts b/packages/opencode/test/altimate/altimate-base-headless-disclosure.test.ts new file mode 100644 index 0000000000..0404a9acfe --- /dev/null +++ b/packages/opencode/test/altimate/altimate-base-headless-disclosure.test.ts @@ -0,0 +1,43 @@ +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" +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 +// 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/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 18702eb07c..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(() => { @@ -56,7 +50,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 +60,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 +179,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/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/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["']/) + }) +}) diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts index 9a3ca0a143..3355539862 100644 --- a/packages/opencode/test/provider/error.test.ts +++ b/packages/opencode/test/provider/error.test.ts @@ -432,6 +432,155 @@ 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" }), + }) + expect(result.type).toBe("api_error") + if (result.type === "api_error") { + expect(result.responseHeaders?.["retry-after"]).toBe("12") + } + }) + + // 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, + error: rateLimited("budget_exceeded", "Budget has been exceeded! Current cost: 50.01, Max budget: 50", { + "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") + } + }) +}) + +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( + "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) + } + // 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/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/opencode/test/server/altimate-base-registration.test.ts b/packages/opencode/test/server/altimate-base-registration.test.ts index 58556e2a51..67b9a65675 100644 --- a/packages/opencode/test/server/altimate-base-registration.test.ts +++ b/packages/opencode/test/server/altimate-base-registration.test.ts @@ -1,170 +1,370 @@ -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 { Instance } from "../../src/project/instance" +import { Provider } from "../../src/provider/provider" 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. - -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") }) - }) - - 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) - }) -}) +// 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 +// 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 -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 - }, - }) - }) +function mockRegister(impl: () => Promise) { + registerSpy = spyOn(FreeTier, "register").mockImplementation(impl as typeof FreeTier.register) +} - test("provide() is single-shot", () => { - expect(() => - FreeTierHost.provide({ setToken() {}, register: async () => ({ ok: true }) }), - ).toThrow(/already provided/) +// 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 }) +} - 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 +afterEach(async () => { + registerSpy?.mockRestore() + registerSpy = undefined + // altimate_change — see `credentialsSpy` above + credentialsSpy?.mockRestore() + credentialsSpy = undefined + await disposeAllInstances() + await resetDatabase() +}) + +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 } - 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(200) - expect(await response.json()).toEqual({ ok: true }) - expect(armed).toHaveLength(1) - expect(armed[0]).toMatch(/^[0-9a-f]{64}$/) - expect(redeemed).toEqual(armed) }) - test("a stale or absent disclosure hash is refused without touching the gate", async () => { - armed.length = 0 - redeemed.length = 0 + 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: "0".repeat(64) }), + body: JSON.stringify({}), }) 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) + expect(registerSpy).toHaveBeenCalledWith({ origin: "server" }) }) - test("the hash comparison is case-insensitive on the client's hex", async () => { - armed.length = 0 - outcome = { ok: true } + 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: FreeTierConsent.disclosureHash().toUpperCase() }), + body: JSON.stringify({ acceptedDisclosureSha256: "0".repeat(64) }), }) - expect(await response.json()).toEqual({ ok: true }) - expect(armed).toHaveLength(1) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ ok: true }) + expect(registerSpy).toHaveBeenCalledTimes(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) }) + + // 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({ 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 once this process has reloaded for the unchanged credential", async () => { + const existing = { apiKey: "sk-existing", baseURL: "https://gateway.test", installSecret: "s" } + mockCredentialsSequence(existing) + mockRegister(async () => existing) + const disposeAll = spyOn(Instance, "disposeAll") + const post = () => + app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + 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() + } + }) + + 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(renewed) + mockRegister(async () => renewed) + expect(await (await post()).json()).toMatchObject({ ok: true }) + expect(disposeAll).toHaveBeenCalledTimes(2) + } finally { + disposeAll.mockRestore() + } + }) + + 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("two concurrent register calls reload once", async () => { + const shared = { apiKey: "sk-concurrent", baseURL: "https://gateway.test", installSecret: "s" } + mockCredentialsSequence(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 + }) + 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 = () => + app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + try { + const requests = [post(), post()] + await overlap + await disposing + release() + 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 { + release() + disposeAll.mockRestore() + } + }) + + 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. + 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) + // 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", { + 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 }) describe("disclosure hash", () => { 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.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", () => { 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..5c4963991d 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,22 @@ export function DialogModelWelcome(props: { function chooseAltimateBase(): boolean { if (!providers().some((provider) => provider.value === "altimate-free")) return false - dialog.replace(() => ) + // 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, + onRegisterResult: (result) => { + if (firstRunActive()) trackOnboarding({ name: "altimate_base_register_result", result, origin: "welcome" }) + }, + }).then((selected) => { + if (!selected) activated = false + }) return true } @@ -409,424 +425,139 @@ 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 — 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 { - 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 headers = new Headers(sdk.headers) + headers.set("Content-Type", "application/json") + const response = await sdk.fetch(`${sdk.url}/altimate/base/register`, { + method: "POST", + headers, + 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() +// altimate_change start — exported so tests can simulate "already shown, this is a later launch" +// by pre-seeding kv.json with this key, the same way ALTIMATE_BASE_MIGRATION_DECLINED_KEY is +// exported from context/local.tsx for the same reason. +export const ALTIMATE_BASE_DISCLOSURE_SHOWN_KEY = "altimate_base_disclosure_shown_v1" +// altimate_change end + +/** + * 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 + 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 }) }) - // 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") - }) +/** + * 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. + * + * 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 + sync: ReturnType + 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 + // 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 - 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" ? ( - - ) : ( - - ), - ) + 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 }) + 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(() => {}) + 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"]), + ) + 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..546b1c0543 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() @@ -59,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) @@ -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,21 @@ export function DialogModel(props: { via_search: props.viaSearch ?? false, }) } - dialog.replace(() => ) + // 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, + 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 64c63fbb6e..4546ff20fd 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,21 @@ export function createDialogProviderOptions() { via_search: false, }) } - dialog.replace(() => ) + // 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, + onRegisterResult: (result) => { + if (firstRunActive()) + trackOnboarding({ name: "altimate_base_register_result", result, origin: "model" }) + }, + }).then((selected) => { + if (!selected) altimateBaseActivated = false + }) return } // altimate_change end 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/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/local.tsx b/packages/tui/src/context/local.tsx index d5da655f82..647a69d8da 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -116,6 +116,45 @@ 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 +} + +/** + * `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( current: ModelRef | undefined, explicit: boolean, @@ -326,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)) @@ -559,35 +590,47 @@ 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 + + // 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 }) => JSON.stringify([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 - // 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,25 +644,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) && !explicitPicks[pickKey(item)]) 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`). 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) => - providerAllowed(candidate.id) && (managedBaseAllowed || candidate.id !== ALTIMATE_BASE_MODEL.providerID), - ) + const provider = pickImplicitFallbackProvider(sync.data.provider, providerAllowed, baseAvailable) // altimate_change end if (!provider) return undefined const defaultModel = sync.data.provider_default[provider.id] @@ -632,16 +677,43 @@ 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. + // + // 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) && !explicitPicks[pickKey(model)]) { + return { ...ALTIMATE_BASE_MODEL } + } + return model + } + const currentModel = createMemo(() => { const a = agent.current() - return ( - getFirstValidModel( - () => a && modelStore.model[a.name], - () => a && a.model, - fallbackModel, - ) ?? undefined - ) + + 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 // altimate_change start — share validated selection with legacy-default and session migration function selectModel(model: ModelRef, options?: { recent?: boolean; explicit?: boolean }) { @@ -658,6 +730,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const a = agent.current() if (!a) return setModelStore("model", a.name, model) + 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. @@ -854,7 +927,20 @@ 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. 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 = keepZen ? entry : (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) @@ -998,11 +1084,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) && !explicitPicks[pickKey(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/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 8d77b260ff..9ca54dd886 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,14 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ event: emitter, fetch: props.fetch ?? fetch, url: props.url, + // 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 } }, }) 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/cli/tui/dialog-model-welcome.test.tsx b/packages/tui/test/cli/tui/dialog-model-welcome.test.tsx index cfa7a50842..4e69c96e90 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,42 @@ 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. +// 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"], + }) + 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/altimate-base-disclosure-notice.test.tsx b/packages/tui/test/component/altimate-base-disclosure-notice.test.tsx new file mode 100644 index 0000000000..837d14abc9 --- /dev/null +++ b/packages/tui/test/component/altimate-base-disclosure-notice.test.tsx @@ -0,0 +1,194 @@ +/** @jsxImportSource @opentui/solid */ +// altimate_change start — coverage for `useAltimateBaseDisclosureNotice()`, the 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. Mounts the real provider stack with a +// single "altimate-free" provider (so `fallbackModel()`'s implicit last-resort branch resolves to +// Base with nothing else to configure — see local.tsx), and drives the hook's own kv-persisted +// "already shown" flag exactly the way a real second launch would (a pre-seeded kv.json file), per +// the isolation pattern in context/cycle-stability.test.tsx. +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { ALTIMATE_BASE_DISCLOSURE } from "@opencode-ai/core/altimate-base-disclosure" +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" +import { + ALTIMATE_BASE_DISCLOSURE_SHOWN_KEY, + useAltimateBaseDisclosureNotice, +} from "../../src/component/altimate-onboarding" + +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 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, useKV }, + { 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 + let kvAccessor: ReturnType | undefined + const shownMessages: string[] = [] + function Probe() { + localAccessor = useLocal() + toastAccessor = useToast() + kvAccessor = useKV() + 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() + // Let the notice's "shown" write land before its state directory is removed. + await kvAccessor?.flush() + 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/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/component/select-altimate-base.test.ts b/packages/tui/test/component/select-altimate-base.test.ts new file mode 100644 index 0000000000..5a69aa713c --- /dev/null +++ b/packages/tui/test/component/select-altimate-base.test.ts @@ -0,0 +1,290 @@ +// 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 { 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, 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 }> + 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 + // 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 + 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 }[] = [] + + // 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: { + dispose: async () => { + disposed = true + }, + }, + }, + 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 + + 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++ + 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 { + sdk, + sync, + local, + toast, + dialog, + modelSetCalls, + toastCalls, + get disposed() { + return disposed + }, + get bootstrapped() { + return bootstrapped + }, + get dialogClearCount() { + return dialogClearCount + }, + get dialogReplaceCount() { + return dialogReplaceCount + }, + // altimate_change start — see `stack`'s declaration above + simulateDismiss() { + stack = [] + }, + simulateReplace() { + stack = [{ id: "something-else" }] + }, + // altimate_change end + } +} + +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, init?: RequestInit) => { + calls.push(`${init?.method} ${String(input)} ${String(init?.body)}`) + return Response.json({ ok: true }) + }) as typeof fetch, + }) + + const result = await selectAltimateBase(fakes) + + expect(result).toBe(true) + expect(calls).toEqual(["POST http://test/altimate/base/register {}"]) + expect(fakes.modelSetCalls).toHaveLength(1) + 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("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" }), + }) + + 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 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/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 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..aea317d8d9 --- /dev/null +++ b/packages/tui/test/context/explicit-model-authoritative.test.tsx @@ -0,0 +1,209 @@ +/** @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 + + 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" }) + } 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 + + 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" }) + } 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 diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index ca128b6e88..cc70bb8771 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -17,6 +17,11 @@ import { // altimate_change start — fixes #1301: broaden legacy-default migration eligibility isFreeZenModel, shouldOfferManagedBaseDefault, + // 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, @@ -518,3 +523,55 @@ 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 + +// 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 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..971a7a9522 --- /dev/null +++ b/packages/tui/test/context/stale-zen-cycle.test.tsx @@ -0,0 +1,266 @@ +// 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) + } +} + +// 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" } + +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(agentModel?: { providerID: string; modelID: 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"), "{}") + // 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: "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: {}, + ...(agentModel ? { model: agentModel } : {}), + } + 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, { ...agent, name: "plan" }]) + 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() 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() + process.env.OPENCODE_TEST_STATE_HOME = isolatedState.path + const { local, cleanup } = await mount() + 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 }) + 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 + } +}) + +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 + } +}) + +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 + } +}) + +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 + } +}) +