From b63244b18db5db1f23c7d25fd35fbdb2c8e1eb86 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 21:26:01 -0700 Subject: [PATCH 01/33] feat: release Altimate Base model --- README.md | 8 +- docs/docs/configure/providers.md | 29 ++ docs/docs/getting-started/quickstart.md | 4 +- docs/docs/reference/network.md | 1 + docs/docs/reference/security-faq.md | 7 + docs/docs/reference/telemetry.md | 9 +- packages/opencode/src/acp/service.ts | 16 +- packages/opencode/src/altimate/free/client.ts | 360 ++++++++++++++++++ packages/opencode/src/altimate/free/store.ts | 106 ++++++ .../opencode/src/altimate/telemetry/index.ts | 17 +- .../src/altimate/telemetry/onboarding.ts | 13 +- packages/opencode/src/cli/cmd/tui.ts | 7 + packages/opencode/src/cli/tui/worker.ts | 4 + packages/opencode/src/provider/error.ts | 35 ++ packages/opencode/src/provider/provider.ts | 91 ++++- packages/opencode/src/server/server.ts | 24 ++ packages/opencode/src/session/llm.ts | 3 + .../opencode/test/acp/default-model.test.ts | 25 +- .../test/altimate/altimate-base.test.ts | 274 +++++++++++++ .../altimate/telemetry/onboarding.test.ts | 2 +- packages/opencode/test/provider/error.test.ts | 60 +++ .../opencode/test/provider/provider.test.ts | 89 +++++ packages/opencode/test/session/llm.test.ts | 1 + .../skill/release-v0.9.5-adversarial.test.ts | 6 +- .../test/telemetry/classify-provider.test.ts | 31 +- packages/tui/src/app.tsx | 8 +- .../tui/src/component/altimate-onboarding.tsx | 209 +++++++--- packages/tui/src/component/dialog-model.tsx | 39 +- .../tui/src/component/dialog-provider.tsx | 13 +- .../tui/src/context/onboarding-telemetry.tsx | 12 +- packages/tui/src/context/sdk.tsx | 2 + .../cli/tui/dialog-altimate-base.test.tsx | 220 +++++++++++ 32 files changed, 1573 insertions(+), 152 deletions(-) create mode 100644 packages/opencode/src/altimate/free/client.ts create mode 100644 packages/opencode/src/altimate/free/store.ts create mode 100644 packages/opencode/test/altimate/altimate-base.test.ts create mode 100644 packages/tui/test/cli/tui/dialog-altimate-base.test.tsx diff --git a/README.md b/README.md index 7650ace3fd..452a2bb577 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,16 @@ installing it in your own repository. Then — in order: -**Step 1: Configure your LLM provider** (required before anything works): +**Step 1: Choose an LLM provider** (required before anything works): ```bash altimate # Launch the TUI -/connect # Interactive setup — choose your provider and enter your API key +/connect # Interactive setup — choose Altimate Base, sign in, or bring an API key ``` +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. + Or set an environment variable directly: ```bash export ANTHROPIC_API_KEY=your_key # Anthropic Claude diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index d07992a929..8f4f19123e 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -46,6 +46,35 @@ For pricing, security, and data handling details, see the [Altimate LLM Gateway !!! tip "Automatic model selection" When Altimate credentials are configured and no model is explicitly chosen, the Altimate LLM Gateway is selected automatically. You can override this by setting `model` in your config or by restricting the `provider` section to specific providers only. +## Altimate Base + +Altimate Base is the hosted Qwen 3.8 free model. It requires no signup or user-managed API key and +is subject to rate limits and abuse protection. Requests and responses are logged and may be used +to improve Altimate products and services. Do not send secrets or confidential code. + +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 +`altimate-free/altimate-base` and becomes the free fallback when no paid Altimate Gateway or +explicit model is selected. Big Pickle is no longer selected implicitly, but remains available in +the full OpenCode model catalog for users who choose it explicitly. + +The gateway endpoint is configurable for deployment migrations: + +```bash +export ALTIMATE_BASE_GATEWAY_URL=https://altimate-gateway-staging.onealtimate.com +altimate +``` + +The URL must use HTTPS, except that HTTP is allowed for `localhost` development. 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 +flow must run again. + +Altimate Base credentials are stored separately from the shared provider-auth file and are never +returned to the TUI. The installation secret is hashed before registration; the gateway receives +the hash, not the local secret. + ## Anthropic ```json diff --git a/docs/docs/getting-started/quickstart.md b/docs/docs/getting-started/quickstart.md index e7ec4bfd48..cc1643f5b7 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. -- **Big Pickle** — free tier, chats work but many data tasks fail; useful for kicking tires. +- **Altimate Base** — hosted Qwen 3.8, free and rate limited, with no signup or API key. Requests and responses are logged and may be used to improve Altimate products and services, so do not send secrets or confidential code. Registration happens only after an explicit confirmation that defaults to **No**. - **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: @@ -36,7 +36,7 @@ altimate ``` !!! tip "Don't want to manage API keys?" - The [Altimate LLM Gateway](https://help.altimate.ai/datamates/user-guide/components/llm-gateway/) is the top row of the picker — 10M free tokens, and altimate-code auto-selects the right model per task. First-run sign-in uses a loopback OAuth on `127.0.0.1:7317-7325` (falls back if the preferred port is taken). + Choose **Altimate Base** for the no-signup, rate-limited model. Choose the [Altimate LLM Gateway](https://help.altimate.ai/datamates/user-guide/components/llm-gateway/) for 10M free tokens and automatic model routing. Gateway sign-in uses a loopback OAuth on `127.0.0.1:7317-7325` (falls back if the preferred port is taken). --- diff --git a/docs/docs/reference/network.md b/docs/docs/reference/network.md index 5673f5d1b9..93cc12396d 100644 --- a/docs/docs/reference/network.md +++ b/docs/docs/reference/network.md @@ -41,6 +41,7 @@ altimate needs outbound HTTPS access to: | Destination | Purpose | |-------------|---------| | Your LLM provider API | Model inference (Anthropic, OpenAI, etc.) | +| `altimate-gateway-staging.onealtimate.com` | Altimate Base registration and inference when you explicitly enable Altimate Base; override with `ALTIMATE_BASE_GATEWAY_URL` | | `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 74399b31e4..781388643e 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -13,6 +13,12 @@ Answers to the most common security questions about running Altimate Code in you Altimate Code sends prompts and context to the LLM provider you configure (Anthropic, OpenAI, Azure OpenAI, AWS Bedrock, etc.). **You choose the provider.** No data is sent anywhere else except optional [telemetry](#what-telemetry-is-collected), which contains no code, queries, or credentials. +Altimate Base is an optional hosted provider. Its confirmation dialog explains that requests and +responses are logged and may be used to improve Altimate products and services; do not send +secrets or confidential code. The dialog defaults to **No**, and no registration request is made +unless you explicitly accept. This request logging is part of the Altimate Base service and is +separate from anonymous product telemetry. + If you use a self-hosted or VPC-deployed model (e.g., AWS Bedrock, Azure OpenAI), your data never leaves your cloud account. ## Can the AI read my database credentials? @@ -90,6 +96,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 | | 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 0737de890d..1da52bdb0e 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -52,9 +52,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 Big Pickle, and from the prompt gate. | -| `provider_selected` | A provider row was chosen — `altimate_gateway`, `anthropic`, `openai`, `google`, `big_pickle`, `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. | -| `big_pickle_confirm_shown` / `big_pickle_choice` | The Big Pickle interstitial was shown, and what the user decided (`accept`/`cancel`). | +| `model_picker_shown` | The provider picker was displayed. `trigger` distinguishes the first run from `/connect`, from declining Altimate Base, and from the prompt gate. | +| `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 (`welcome` or `model` origin), and what the user decided (`accept`/`cancel`). | +| `altimate_base_register_result` | The consented registration outcome: `success`, `rate_limited`, `unavailable`, `network`, or `error`. 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. | @@ -65,7 +66,7 @@ We collect the following categories of events: | `activation_menu_shown` | The activation menu was (very likely) rendered. `variant` is `warehouse` or `no_data`. **Derived** — see the note below. | | `activation_job_selected` / `first_job_completed` | Which activation job the user started and, where observable, finished. Completion is reported only for the job that was actually selected, so the two form a coherent pair. **Derived** — see the note below. | | `first_prompt_sent` | The user's first typed message in an onboarding session. Slash commands are excluded, so the hidden `/onboard-connect` submission does not count. | -| `onboarding_abandoned` | The CLI exited during a first run without connecting. `last_stage` is the furthest point reached: `started`, `model_picker`, `provider_setup`, `big_pickle_confirm`, or `gateway_auth`. (`connected` is a funnel position but never a `last_stage` — reaching it means the run completed, which is not an abandonment.) Only emitted for a genuine first run — opening `/connect` as an existing user does not enter the funnel, and abandonment after setup completes is out of scope by definition. Emitted on the exit path under a bounded flush, so the measured rate is a lower bound — see [Delivery & Reliability](#delivery--reliability). | +| `onboarding_abandoned` | The CLI exited during a first run without connecting. `last_stage` is the furthest point reached: `started`, `model_picker`, `provider_setup`, `altimate_base_confirm`, or `gateway_auth`. (`connected` is a funnel position but never a `last_stage` — reaching it means the run completed, which is not an abandonment.) Only emitted for a genuine first run — opening `/connect` as an existing user does not enter the funnel, and abandonment after setup completes is out of scope by definition. Emitted on the exit path under a bounded flush, so the measured rate is a lower bound — see [Delivery & Reliability](#delivery--reliability). | | `review_run` | A dbt/SQL review completed or failed — `invocation` (`cli` for `altimate-code review`, `tool` for the `dbt_pr_review` tool), status, duration, and on success the verdict, the pre-gating verdict, mode, risk tier, and finding counts by severity and by category. No file paths, model or column names, finding titles or bodies, SQL, diff content, or repository/branch/PR names. | | `review_post_outcome` | Whether a review was published to GitHub — `not_requested`, `not_attempted`, `target_unresolved`, `full`, `partial`, or `summary_failed`, plus duration. Emitted on the **CLI path only** — the `dbt_pr_review` tool completes reviews but never publishes, so a `review_run` with `invocation: tool` has no post event and that is not a failure. Within the CLI path there is exactly one per **completed** review: a review that failed emits `review_run: failed` and no post event, so absence there means the review failed rather than that an event was lost. `not_attempted` is publication requested but never reached (a bad `--output` path, a stdout write error). No repository, PR, or comment content. | diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index a65ea7a308..d4eb7d582f 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -810,15 +810,27 @@ export function defaultModelFromConfig( return { providerID: ProviderV2.ID.make("altimate-backend"), modelID: ModelV2.ID.make("altimate-default") } } + // Altimate Base replaces Big Pickle as the free fallback, but only after the user has already + // consented and registered (which is why it is present in `providers`). A project provider block + // cannot force the managed model; an explicit configured model above remains authoritative. + const baseProvider = providers[ProviderV2.ID.make("altimate-free")] + if (!providerFilter && baseProvider?.models[ModelV2.ID.make("altimate-base")]) { + return { providerID: ProviderV2.ID.make("altimate-free"), modelID: ModelV2.ID.make("altimate-base") } + } + // First-session ACP startup must not scan historical sessions just to infer // a default. Configured model, opencode provider, then sorted best model keep // the protocol response deterministic without extra session/message reads. const opencodeProvider = providers[ProviderV2.ID.make("opencode")] - const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined + const opencodeModel = opencodeProvider + ? Provider.sort(Object.values(opencodeProvider.models)).find((model) => model.id !== "big-pickle") + : undefined if (opencodeProvider && opencodeModel) return { providerID: ProviderV2.ID.make(opencodeProvider.id), modelID: ModelV2.ID.make(opencodeModel.id) } - const best = Provider.sort(Object.values(providers).flatMap((provider) => Object.values(provider.models)))[0] + const best = Provider.sort(Object.values(providers).flatMap((provider) => Object.values(provider.models))).find( + (model) => !(model.providerID === "opencode" && model.id === "big-pickle"), + ) if (best) return { providerID: ProviderV2.ID.make(best.providerID), modelID: ModelV2.ID.make(best.id) } if (configured) return configured // altimate_change end diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts new file mode 100644 index 0000000000..dd8b6bb075 --- /dev/null +++ b/packages/opencode/src/altimate/free/client.ts @@ -0,0 +1,360 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto" +import { Flock } from "@opencode-ai/core/util/flock" +import { Installation } from "../../installation" +import { Log } from "../util/log" +import { FreeTierStore } from "./store" + +const log = Log.create({ service: "altimate-base" }) + +export const PROVIDER_ID = "altimate-free" +export const MODEL_ID = "altimate-base" +export const CONSENT_TOKEN_HEADER = "x-altimate-base-consent" +export const DEFAULT_GATEWAY_URL = "https://altimate-gateway-staging.onealtimate.com" + +const REGISTER_TIMEOUT_MS = 15_000 +const LOCK_KEY = "altimate-base-registration" +const MAX_AUTH_RECOVERY_ATTEMPTS = 3 +const inflight = new Map>() +let expectedConsentToken: string | undefined + +export interface Credentials { + apiKey: string + baseURL: string + expiresAt?: string + installSecret: string +} + +export class RegistrationError extends Error { + constructor( + message: string, + readonly status?: number, + ) { + super(message) + this.name = "AltimateBaseRegistrationError" + } +} + +export class ConfigurationError extends Error { + constructor(message: string) { + super(message) + this.name = "AltimateBaseConfigurationError" + } +} + +export function mintConsentToken(): string { + return randomBytes(32).toString("hex") +} + +/** Set only inside the TUI worker. It deliberately never enters process.env. */ +export function setConsentToken(token: string | undefined): void { + expectedConsentToken = token +} + +export function consentTokenValid(presented: string | undefined | null): boolean { + if (!expectedConsentToken || !presented) return false + const expected = Buffer.from(expectedConsentToken) + const actual = Buffer.from(presented) + return expected.length === actual.length && timingSafeEqual(expected, actual) +} + +function normalizeGatewayUrl(value: string): string | undefined { + let url: URL + try { + url = new URL(value.trim()) + } catch { + return undefined + } + const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" + if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) return undefined + if (url.username || url.password || url.search || url.hash) return undefined + return url.toString().replace(/\/+$/, "") +} + +export function gatewayUrl(): string { + const configured = + process.env["ALTIMATE_BASE_GATEWAY_URL"]?.trim() || + process.env["ALTIMATE_FREE_GATEWAY_URL"]?.trim() || + DEFAULT_GATEWAY_URL + const normalized = normalizeGatewayUrl(configured) + if (!normalized) { + throw new ConfigurationError( + "ALTIMATE_BASE_GATEWAY_URL must be HTTPS (or HTTP on localhost) and cannot contain credentials, a query, or a fragment.", + ) + } + return normalized +} + +function mintInstallSecret(): string { + return randomBytes(32).toString("hex") +} + +export function hashInstallSecret(secret: string): string { + return createHash("sha256").update(secret).digest("hex") +} + +export async function credentials(): Promise { + const stored = await FreeTierStore.read() + if (!stored?.apiKey || !stored.baseURL) return undefined + return { + apiKey: stored.apiKey, + baseURL: stored.baseURL, + expiresAt: stored.expiresAt, + installSecret: stored.installSecret, + } +} + +export async function credentialsForLoad(): Promise { + const stored = await credentials() + if (!stored || !sameOrigin(stored.baseURL, gatewayUrl())) return undefined + return stored +} + +export async function isRegistered(): Promise { + return (await credentialsForLoad()) !== undefined +} + +export async function clear(): Promise { + await Flock.withLock(LOCK_KEY, () => FreeTierStore.remove()) +} + +export function sanitizeCliVersion(raw: string): string { + const coerced = raw + .replace(/[^A-Za-z0-9._+-]/g, "-") + .replace(/^[^A-Za-z0-9]+/, "") + .slice(0, 32) + return coerced || "unknown" +} + +function describeRegistrationFailure(status: number): string { + if (status === 429) return "Too many Altimate Base registrations from this network right now. Try again later." + if (status === 503) return "Altimate Base is temporarily unavailable. Try again later." + return `Altimate Base registration failed (HTTP ${status}).` +} + +function sameOrigin(left: string, right: string): boolean { + try { + return new URL(left).origin === new URL(right).origin + } catch { + return false + } +} + +function safeOrigin(value: string): string { + try { + return new URL(value).origin + } catch { + return "" + } +} + +async function installSecretForRegistration(): Promise { + const stored = await FreeTierStore.read() + if (stored?.installSecret) return stored.installSecret + const installSecret = mintInstallSecret() + // Persist before the request so a lost response cannot mint another budget principal on retry. + await FreeTierStore.write({ version: 1, installSecret }) + return installSecret +} + +async function registerOnce(): Promise { + const installSecret = await installSecretForRegistration() + const configuredGateway = gatewayUrl() + let response: Response + try { + response = await fetch(`${configuredGateway}/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + install_secret_hash: hashInstallSecret(installSecret), + cli_version: sanitizeCliVersion(Installation.VERSION), + }), + redirect: "error", + signal: AbortSignal.timeout(REGISTER_TIMEOUT_MS), + }) + } catch (error) { + log.warn("Altimate Base registration request failed", { error }) + throw new RegistrationError("Could not reach the Altimate Base gateway. Check your connection.") + } + + if (!response.ok) { + log.warn("Altimate Base registration rejected", { status: response.status }) + throw new RegistrationError(describeRegistrationFailure(response.status), response.status) + } + + const body = (await response.json().catch(() => undefined)) as + | { api_key?: unknown; base_url?: unknown; expires_at?: unknown; model?: unknown } + | undefined + const apiKey = typeof body?.api_key === "string" ? body.api_key.trim() : "" + const baseURL = typeof body?.base_url === "string" ? normalizeGatewayUrl(body.base_url) : undefined + if ( + !apiKey || + !baseURL || + !sameOrigin(baseURL, configuredGateway) || + (body?.model !== undefined && body.model !== MODEL_ID) + ) { + throw new RegistrationError("The Altimate Base gateway returned an unexpected response.") + } + + const result: Credentials = { + apiKey, + baseURL, + installSecret, + ...(typeof body?.expires_at === "string" ? { expiresAt: body.expires_at } : {}), + } + await FreeTierStore.write({ + version: 1, + installSecret, + apiKey, + baseURL, + ...(result.expiresAt ? { expiresAt: result.expiresAt } : {}), + }) + return result +} + +export async function register( + input: { supersede?: string; rejected?: ReadonlySet } = {}, +): Promise { + const dedupeKey = input.supersede ?? "" + const pending = inflight.get(dedupeKey) + if (pending) return pending + + const started = Flock.withLock(LOCK_KEY, async () => { + const fresh = await credentials() + if (fresh && input.supersede && fresh.apiKey !== input.supersede && !input.rejected?.has(fresh.apiKey)) { + return fresh + } + return registerOnce() + }).finally(() => { + if (inflight.get(dedupeKey) === started) inflight.delete(dedupeKey) + }) + inflight.set(dedupeKey, started) + return started +} + +function targetUrl(input: RequestInfo | URL): string { + return typeof input === "string" ? input : input instanceof URL ? input.href : input.url +} + +function isReplayable(input: RequestInfo | URL, body: BodyInit | null | undefined): boolean { + if (input instanceof Request && input.body) return false + return ( + body == null || + typeof body === "string" || + body instanceof Uint8Array || + body instanceof ArrayBuffer || + body instanceof URLSearchParams || + body instanceof Blob + ) +} + +function requestHeaders(input: RequestInfo | URL, init?: RequestInit): Headers { + const headers = new Headers(input instanceof Request ? input.headers : undefined) + new Headers(init?.headers).forEach((value, key) => headers.set(key, value)) + return headers +} + +export async function authorizedFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const initial = await credentials() + if (!initial) throw new Error("Altimate Base credentials are unavailable. Set up the model again.") + + const target = targetUrl(input) + if (!sameOrigin(target, initial.baseURL)) { + log.error("blocked Altimate Base request to an unregistered origin", { + expected: safeOrigin(initial.baseURL), + actual: safeOrigin(target), + }) + throw new Error("Blocked an Altimate Base request to an unregistered gateway origin.") + } + + const send = (next: Credentials): Promise | undefined => { + if (!sameOrigin(target, next.baseURL)) return undefined + const headers = requestHeaders(input, init) + headers.set("Authorization", `Bearer ${next.apiKey}`) + return fetch(input, { ...init, headers, redirect: "manual" }) + } + + let active = initial + let response = await send(active)! + if (!isReplayable(input, init?.body)) return response + + const rejected = new Set() + for (let attempt = 0; response.status === 401 && attempt < MAX_AUTH_RECOVERY_ATTEMPTS; attempt++) { + rejected.add(active.apiKey) + const stored = await credentials() + let next: Credentials | undefined + if (stored && !rejected.has(stored.apiKey)) { + next = stored + } else { + next = await register({ supersede: active.apiKey, rejected }).catch((error) => { + log.warn("Altimate Base key rotation failed", { error, attempt }) + return undefined + }) + } + if (!next || rejected.has(next.apiKey)) return response + const retried = send(next) + if (!retried) { + log.error("blocked rotated Altimate Base credentials for a different origin", { + expected: safeOrigin(initial.baseURL), + actual: safeOrigin(next.baseURL), + }) + return response + } + active = next + response = await retried + } + return response +} + +export function describeRateLimit(input: { body?: string; retryAfter?: string }): string | undefined { + let parsed: { error?: { type?: unknown; message?: unknown }; type?: unknown } | undefined + try { + parsed = input.body ? JSON.parse(input.body) : undefined + } catch { + return undefined + } + 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 "This request is too large for Altimate Base's per-minute token limit. Start a new session or shorten the context, then try again." + } + const seconds = Number(input.retryAfter) + const wait = Number.isFinite(seconds) && seconds > 0 ? ` Try again in ${Math.ceil(seconds)}s.` : " Try again shortly." + return `Too many requests to Altimate Base right now.${wait}` + } + if (kind === "budget_exceeded") { + if (detail.includes("ExceededBudget: User=")) { + return "You've used today's free Altimate Base allowance. It resets tomorrow—switch models to keep going." + } + if (detail.includes("Budget has been exceeded")) { + return "Altimate Base has reached its shared daily limit. It resets tomorrow—switch models to keep going." + } + return "The daily Altimate Base limit has been reached. It resets tomorrow—switch models to keep going." + } + return undefined +} + +export function describeRequestTooLarge(body?: string): string | undefined { + type Inner = { code?: unknown; message?: unknown; provider_specific_fields?: { error?: Inner } } + let parsed: { error?: Inner } | undefined + try { + parsed = body ? JSON.parse(body) : undefined + } catch { + return undefined + } + const inner = parsed?.error?.provider_specific_fields?.error + if (parsed?.error?.code !== "request_too_large" && inner?.code !== "request_too_large") return undefined + const detail = + typeof parsed?.error?.message === "string" + ? parsed.error.message + : typeof inner?.message === "string" + ? inner.message + : "" + const sizes = detail.match(/Request is (\d+) bytes; the free tier limit is (\d+) bytes/) + const numbers = sizes + ? ` (${Math.round(Number(sizes[1]) / 1024)}KB against a ${Math.round(Number(sizes[2]) / 1024)}KB limit)` + : "" + return `This request is too large for Altimate Base${numbers}. Start a new session, or switch to another model for this task.` +} + +export * as FreeTier from "./client" diff --git a/packages/opencode/src/altimate/free/store.ts b/packages/opencode/src/altimate/free/store.ts new file mode 100644 index 0000000000..e8641f277c --- /dev/null +++ b/packages/opencode/src/altimate/free/store.ts @@ -0,0 +1,106 @@ +import { randomBytes } from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" +import { Global } from "../../global" + +export interface Record { + version: 1 + installSecret: string + apiKey?: string + baseURL?: string + expiresAt?: string +} + +export class InvalidCredentialStoreError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = "AltimateBaseInvalidCredentialStoreError" + } +} + +export function credentialPath(): string { + return path.join(Global.Path.data, "altimate-base.json") +} + +function isEnoent(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT" +} + +function parse(value: unknown): Record { + if (!value || typeof value !== "object") throw new InvalidCredentialStoreError("Altimate Base credentials are invalid.") + const input = value as { [key: string]: unknown } + if (input.version !== 1 || typeof input.installSecret !== "string" || !input.installSecret) { + throw new InvalidCredentialStoreError("Altimate Base credentials are invalid.") + } + for (const field of ["apiKey", "baseURL", "expiresAt"] as const) { + if (input[field] !== undefined && typeof input[field] !== "string") { + throw new InvalidCredentialStoreError("Altimate Base credentials are invalid.") + } + } + const apiKey = typeof input.apiKey === "string" ? input.apiKey : undefined + const baseURL = typeof input.baseURL === "string" ? input.baseURL : undefined + const expiresAt = typeof input.expiresAt === "string" ? input.expiresAt : undefined + return { + version: 1, + installSecret: input.installSecret, + ...(apiKey ? { apiKey } : {}), + ...(baseURL ? { baseURL } : {}), + ...(expiresAt ? { expiresAt } : {}), + } +} + +export async function read(): Promise { + let contents: string + try { + contents = await fs.readFile(credentialPath(), "utf8") + } catch (error) { + if (isEnoent(error)) return undefined + throw error + } + try { + return parse(JSON.parse(contents)) + } catch (error) { + if (error instanceof InvalidCredentialStoreError) throw error + throw new InvalidCredentialStoreError("Altimate Base credentials are invalid.", { cause: error }) + } +} + +/** + * Replace the credential record atomically. The temporary file is created with 0600 before any + * secret bytes are written, then synced and renamed in the same directory. + */ +export async function write(record: Record): Promise { + const target = credentialPath() + const directory = path.dirname(target) + await fs.mkdir(directory, { recursive: true, mode: 0o700 }) + const temporary = `${target}.${process.pid}.${randomBytes(8).toString("hex")}.tmp` + let handle: fs.FileHandle | undefined + try { + handle = await fs.open(temporary, "wx", 0o600) + await handle.writeFile(JSON.stringify(parse(record), null, 2) + "\n", "utf8") + await handle.sync() + await handle.chmod(0o600) + await handle.close() + handle = undefined + await fs.rename(temporary, target) + await fs.chmod(target, 0o600) + + // Persist the rename when the platform supports syncing a directory. Some Windows filesystems + // reject opening directories; the file itself is already synced in that case. + const parent = await fs.open(directory, "r").catch(() => undefined) + if (parent) { + await parent.sync().catch(() => {}) + await parent.close().catch(() => {}) + } + } catch (error) { + await handle?.close().catch(() => {}) + await fs.rm(temporary, { force: true }).catch(() => {}) + throw error + } +} + +export async function remove(): Promise { + await fs.rm(credentialPath(), { force: true }) +} + +export * as FreeTierStore from "./store" diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 45dce393fe..83d607cb95 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -795,7 +795,7 @@ export namespace Telemetry { timestamp: number session_id: string /** the picker mounts from several paths — without this the event over-counts first runs */ - trigger: "first_run" | "connect_command" | "big_pickle_back" | "prompt_gate" + trigger: "first_run" | "connect_command" | "altimate_base_back" | "prompt_gate" } | { type: "provider_selected" @@ -804,7 +804,7 @@ export namespace Telemetry { /** `search_all` means the user opened the full catalogue; the provider they then chose * arrives as a second event with `via_search`. `other` is any provider outside the * curated five. */ - provider: "altimate_gateway" | "anthropic" | "openai" | "google" | "big_pickle" | "search_all" | "other" + provider: "altimate_gateway" | "altimate_base" | "anthropic" | "openai" | "google" | "search_all" | "other" /** Raw provider id, but ONLY for publicly-known providers (see KNOWN_PROVIDER_IDS). * A user-defined provider in opencode.json can be named after their company, so * anything unrecognised is reported as `other` with this omitted. */ @@ -814,17 +814,23 @@ export namespace Telemetry { via_search?: boolean } | { - type: "big_pickle_confirm_shown" + type: "altimate_base_confirm_shown" timestamp: number session_id: string origin: "welcome" | "model" } | { - type: "big_pickle_choice" + type: "altimate_base_choice" timestamp: number session_id: string choice: "accept" | "cancel" } + | { + type: "altimate_base_register_result" + timestamp: number + session_id: string + result: "success" | "rate_limited" | "unavailable" | "network" | "error" + } | { type: "gateway_device_code_issued" timestamp: number @@ -991,6 +997,7 @@ export namespace Telemetry { // not on this list is reported as `other` with no raw value attached. const KNOWN_PROVIDER_IDS = new Set([ "altimate-backend", + "altimate-free", "anthropic", "openai", "google", @@ -1023,6 +1030,7 @@ export namespace Telemetry { // this function exists to enforce. const CURATED_PROVIDER_ENUM: Record = Object.assign(Object.create(null), { "altimate-backend": "altimate_gateway", + "altimate-free": "altimate_base", anthropic: "anthropic", openai: "openai", google: "google", @@ -1034,7 +1042,6 @@ export namespace Telemetry { providerID: string, modelID?: string, ): { provider: string; provider_id?: string } { - if (providerID === "opencode" && modelID === "big-pickle") return { provider: "big_pickle", provider_id: providerID } const curated = CURATED_PROVIDER_ENUM[providerID] if (curated) return { provider: curated, provider_id: providerID } return KNOWN_PROVIDER_IDS.has(providerID) ? { provider: "other", provider_id: providerID } : { provider: "other" } diff --git a/packages/opencode/src/altimate/telemetry/onboarding.ts b/packages/opencode/src/altimate/telemetry/onboarding.ts index 79a405e6ff..1b8f9a492b 100644 --- a/packages/opencode/src/altimate/telemetry/onboarding.ts +++ b/packages/opencode/src/altimate/telemetry/onboarding.ts @@ -30,7 +30,7 @@ export const ONBOARDING_STAGES = [ "started", "model_picker", "provider_setup", - "big_pickle_confirm", + "altimate_base_confirm", "gateway_auth", // NOTE: reaching this stage means the run completed, and emitAbandonedIfIncomplete() returns // early on `completed`. So "connected" is a valid funnel position but never a `last_stage` on @@ -48,8 +48,9 @@ type OnboardingEventInput = Extract< | "onboarding_started" | "model_picker_shown" | "provider_selected" - | "big_pickle_confirm_shown" - | "big_pickle_choice" + | "altimate_base_confirm_shown" + | "altimate_base_choice" + | "altimate_base_register_result" | "gateway_device_code_issued" | "gateway_auth_completed" | "gateway_auth_failed" @@ -91,7 +92,7 @@ const STAGE_FOR_EVENT: Partial(worker) + await client.call("setAltimateBaseConsentToken", { token: altimateBaseConsentToken }) const reload = () => { client.call("reload", undefined).catch(() => {}) } @@ -244,6 +247,10 @@ export const TuiThreadCommand = cmd({ }, config, pluginHost: createLegacyTuiPluginHost(), + altimateBaseRegistration: { + token: altimateBaseConsentToken, + header: FreeTier.CONSENT_TOKEN_HEADER, + }, // altimate_change — onboarding funnel seam. Deliberately a single-line marker, not a // start/end pair: this sits inside the "clean up TUI worker after failed --session // validation" region, and a nested closing marker truncates the block that diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 8d4dd58e45..6a59d9882d 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -27,6 +27,7 @@ import { Instance } from "@/project/instance" // altimate_change — onboarding telemetry: flush this thread's buffer in rpc.shutdown() import { Telemetry } from "@/altimate/telemetry" import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" +import { FreeTier } from "@/altimate/free/client" // altimate_change — shared with the withTimeout budget in cli/cmd/tui.ts stop(), so the coupling // is enforced by the compiler rather than by a comment. @@ -64,6 +65,9 @@ GlobalBus.on("event", (event) => { let server: Awaited> | undefined export const rpc = { + setAltimateBaseConsentToken(input: { token: string }) { + FreeTier.setConsentToken(input.token) + }, async fetch(input: { url: string; method: string; headers: Record; body?: string }) { const headers = { ...input.headers } const auth = ServerAuth.header() diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index d1c2b9e171..d1a6402c1f 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -2,6 +2,7 @@ import { APICallError } from "ai" import { STATUS_CODES } from "http" import { iife } from "@/util/iife" import type { ProviderID } from "./schema" +import { FreeTier } from "@/altimate/free/client" export namespace ProviderError { // altimate_change start — restore upstream v1.17.9 error classes dropped during @@ -326,6 +327,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 + // 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. + if (String(input.providerID) === FreeTier.PROVIDER_ID && input.error.statusCode === 413) { + const described = FreeTier.describeRequestTooLarge(input.error.responseBody) + if (described) { + return { + type: "api_error", + message: described, + statusCode: 413, + isRetryable: false, + responseHeaders: input.error.responseHeaders, + responseBody: capResponseBody(input.error.responseBody), + metadata: input.error.url ? { url: maskInternalHost(input.error.url) } : undefined, + } + } + } if (isOverflow(m) || input.error.statusCode === 413 || codeFromBody === "context_length_exceeded") { return { type: "context_overflow", @@ -336,6 +353,24 @@ export namespace ProviderError { } } + if (String(input.providerID) === FreeTier.PROVIDER_ID && input.error.statusCode === 429) { + const described = FreeTier.describeRateLimit({ + body: input.error.responseBody, + retryAfter: input.error.responseHeaders?.["retry-after"], + }) + if (described) { + return { + type: "api_error", + message: described, + statusCode: 429, + isRetryable: described.startsWith("Too many requests"), + responseHeaders: input.error.responseHeaders, + responseBody: capResponseBody(input.error.responseBody), + metadata: input.error.url ? { url: maskInternalHost(input.error.url) } : undefined, + } + } + } + // altimate_change start — append a `models` discoverability hint when the // error code is model_not_found. Pairs with the retry-storm carve-out in // isOpenAiErrorRetryable so the user sees the hint on the first attempt diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 5850bb82f3..77adbbed46 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -29,6 +29,7 @@ import { Global } from "../global" import path from "path" import { Filesystem } from "../util/filesystem" import { AltimateApi } from "../altimate/api/client" +import { FreeTier } from "../altimate/free/client" // Direct imports for bundled providers import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" @@ -373,6 +374,21 @@ export namespace Provider { } return { autoload: false } }, + "altimate-free": async () => { + const creds = await FreeTier.credentialsForLoad().catch((error) => { + log.error("failed to read Altimate Base credentials", { error }) + return undefined + }) + if (!creds) return { autoload: false } + return { + autoload: true, + options: { + baseURL: `${creds.baseURL}/v1`, + apiKey: creds.apiKey, + fetch: FreeTier.authorizedFetch, + }, + } + }, // altimate_change end openai: async () => { return { @@ -1156,7 +1172,10 @@ export namespace Provider { log.info("init") - const configProviders = Object.entries(config.provider ?? {}) + // This managed provider's SDK module, model, headers, and endpoint come only from the client + // and registration response. A project config must never steer its stored credential. + const configProviders = Object.entries(config.provider ?? {}).filter(([id]) => id !== FreeTier.PROVIDER_ID) + const configProviderMap = Object.fromEntries(configProviders) // Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot if (database["github-copilot"]) { @@ -1481,6 +1500,42 @@ export namespace Provider { } // altimate_change end + // Hosted Qwen 3.8 behind the gateway's stable public model alias. Pinning this record keeps a + // models.dev collision from replacing the SDK module or endpoint that receives the free key. + const baseModels: Record = { + [FreeTier.MODEL_ID]: { + id: ModelID.make(FreeTier.MODEL_ID), + providerID: ProviderID.make(FreeTier.PROVIDER_ID), + name: "Altimate Base", + family: "qwen", + api: { id: FreeTier.MODEL_ID, url: "", npm: "@ai-sdk/openai-compatible" }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 65_536, output: 4_096 }, + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "2026-08-29", + variants: {}, + }, + } + database[FreeTier.PROVIDER_ID] = { + id: ProviderID.make(FreeTier.PROVIDER_ID), + name: "Altimate", + source: "custom", + env: [], + options: {}, + models: baseModels, + } + function mergeProvider(providerID: ProviderID, provider: Partial) { const existing = providers[providerID] if (existing) { @@ -1681,7 +1736,7 @@ export namespace Provider { for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) { const providerID = ProviderID.make(id) - if (disabled.has(providerID)) continue + if (!isProviderAllowed(providerID)) continue const data = database[providerID] if (!data) { log.error("Provider does not exist in model list " + providerID) @@ -1714,7 +1769,7 @@ export namespace Provider { continue } - const configProvider = config.provider?.[providerID] + const configProvider = configProviderMap[providerID] for (const [modelID, model] of Object.entries(provider.models)) { model.api.id = model.api.id ?? model.id ?? modelID @@ -2049,7 +2104,7 @@ export namespace Provider { return undefined } - const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] + const priority = ["gpt-5", "claude-sonnet-4", "altimate-base", "gemini-3-pro"] export function sort(models: T[]) { return sortBy( models, @@ -2094,14 +2149,28 @@ export namespace Provider { } // altimate_change end - const provider = Object.values(providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)) - if (!provider) throw new Error("no providers found") - const [model] = sort(Object.values(provider.models)) - if (!model) throw new Error("no models found") - return { - providerID: provider.id, - modelID: model.id, + // Once the user has accepted the disclosure and registered, Altimate Base owns the free + // fallback role that used to belong to Big Pickle. A project provider block cannot force the + // managed model; an explicit `model` setting above remains authoritative. + const baseProviderID = ProviderID.make(FreeTier.PROVIDER_ID) + const baseProvider = providers[baseProviderID] + if (baseProvider?.models[ModelID.make(FreeTier.MODEL_ID)] && !cfg.provider) { + log.info("defaulting to altimate-free/altimate-base (no model configured)") + return { providerID: baseProviderID, modelID: ModelID.make(FreeTier.MODEL_ID) } + } + + const configuredProviderIDs = Object.keys(cfg.provider ?? {}).filter((id) => id !== FreeTier.PROVIDER_ID) + const candidates = Object.values(providers).filter( + (provider) => configuredProviderIDs.length === 0 || configuredProviderIDs.includes(provider.id), + ) + if (candidates.length === 0) throw new Error("no providers found") + for (const provider of candidates) { + const model = sort(Object.values(provider.models)).find( + (candidate) => !(provider.id === "opencode" && candidate.id === "big-pickle"), + ) + if (model) return { providerID: provider.id, modelID: model.id } } + throw new Error("no models found") } export function parseModel(model: string) { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 6f6af78a1a..49bc8ba7cf 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -37,6 +37,7 @@ import { syncDatamateUrlFromVscodeMcp } from "../altimate/datamate-transport" import { readMcpEntryFromDisk } from "../mcp/config" import { resolveConfigPath } from "../mcp/config" import { enhancePrompt, isAutoEnhanceEnabled } from "../altimate/enhance-prompt" +import { FreeTier } from "../altimate/free/client" // altimate_change end import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" @@ -646,6 +647,29 @@ export namespace Server { }) }, ) + .post( + "/altimate/base/register", + validator("json", z.object({})), + async (c) => { + if (!FreeTier.consentTokenValid(c.req.header(FreeTier.CONSENT_TOKEN_HEADER))) { + return c.json({ ok: false, status: 403, message: "Altimate Base registration is unavailable." }, 403) + } + try { + await FreeTier.register() + return c.json({ ok: true, model: FreeTier.MODEL_ID }) + } catch (error) { + const message = error instanceof Error ? error.message : "Could not set up Altimate Base." + if (error instanceof FreeTier.RegistrationError && error.status === 429) { + return c.json({ ok: false, status: 429, message }, 429) + } + if (error instanceof FreeTier.RegistrationError && error.status === 503) { + return c.json({ ok: false, status: 503, message }, 503) + } + log.error("Altimate Base registration failed", { error }) + return c.json({ ok: false, status: 502, message }, 502) + } + }, + ) // altimate_change start — POST /altimate/prompt/enhance // Keep the fork-owned LLM/config prompt enhancement on the opencode side while letting the // extracted upstream TUI call it from the submit path. The endpoint is intentionally a no-op diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 893f4dda4d..0957c5cb54 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -266,6 +266,9 @@ export namespace LLM { // altimate_change start — upstream_fix: UA brand "User-Agent": `altimate-code/${Installation.VERSION}`, // altimate_change end + // Used by the managed gateway for per-session abuse controls and traces. Never + // attach it to third-party providers. + ...(input.model.providerID === "altimate-free" ? { "X-Session-Id": input.sessionID } : {}), } : undefined), ...input.model.headers, diff --git a/packages/opencode/test/acp/default-model.test.ts b/packages/opencode/test/acp/default-model.test.ts index a3aaa89f3b..164a18c37e 100644 --- a/packages/opencode/test/acp/default-model.test.ts +++ b/packages/opencode/test/acp/default-model.test.ts @@ -58,18 +58,33 @@ describe("ACP defaultModelFromConfig", () => { }) }) - test("falls back to opencode when altimate-backend is not present", () => { - const result = defaultModelFromConfig(undefined, providers(provider("opencode", ["big-pickle"]))) + test("prefers registered Altimate Base when the paid gateway is not present", () => { + const result = defaultModelFromConfig( + undefined, + providers(provider("altimate-free", ["altimate-base"]), provider("opencode", ["big-pickle"])), + ) + expect(result).toEqual({ + providerID: ProviderV2.ID.make("altimate-free"), + modelID: ModelV2.ID.make("altimate-base"), + }) + }) + + test("never chooses Big Pickle implicitly", () => { + expect(defaultModelFromConfig(undefined, providers(provider("opencode", ["big-pickle"])))).toBeUndefined() + }) + + test("falls back to another OpenCode model when Altimate Base is not registered", () => { + const result = defaultModelFromConfig(undefined, providers(provider("opencode", ["big-pickle", "gpt-5"]))) expect(result).toEqual({ providerID: ProviderV2.ID.make("opencode"), - modelID: ModelV2.ID.make("big-pickle"), + modelID: ModelV2.ID.make("gpt-5"), }) }) test("skips altimate-backend when an explicit provider allowlist excludes it", () => { const result = defaultModelFromConfig( undefined, - providers(provider("altimate-backend", ["altimate-default"]), provider("opencode", ["big-pickle"])), + providers(provider("altimate-backend", ["altimate-default"]), provider("opencode", ["gpt-5"])), { opencode: {} }, ) expect(result?.providerID).toBe(ProviderV2.ID.make("opencode")) @@ -78,7 +93,7 @@ describe("ACP defaultModelFromConfig", () => { test("honors an explicit provider allowlist that includes altimate-backend", () => { const result = defaultModelFromConfig( undefined, - providers(provider("altimate-backend", ["altimate-default"]), provider("opencode", ["big-pickle"])), + providers(provider("altimate-backend", ["altimate-default"]), provider("opencode", ["gpt-5"])), { "altimate-backend": {}, opencode: {} }, ) expect(result?.providerID).toBe(ProviderV2.ID.make("altimate-backend")) diff --git a/packages/opencode/test/altimate/altimate-base.test.ts b/packages/opencode/test/altimate/altimate-base.test.ts new file mode 100644 index 0000000000..880bc5f90c --- /dev/null +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -0,0 +1,274 @@ +import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { createHash } from "node:crypto" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" + +const temporaryHome = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-base-")) +process.env.XDG_DATA_HOME = path.join(temporaryHome, "data") +process.env.XDG_CONFIG_HOME = path.join(temporaryHome, "config") +process.env.XDG_CACHE_HOME = path.join(temporaryHome, "cache") +process.env.XDG_STATE_HOME = path.join(temporaryHome, "state") +process.env.OPENCODE_TEST_HOME = temporaryHome + +const { FreeTier } = await import("../../src/altimate/free/client") +const { FreeTierStore } = await import("../../src/altimate/free/store") +const { Server } = await import("../../src/server/server") + +const REGISTERED = { + api_key: "sk-altimate-base-1", + base_url: FreeTier.DEFAULT_GATEWAY_URL, + model: FreeTier.MODEL_ID, + expires_at: new Date(Date.now() + 86_400_000).toISOString(), +} + +let fetchSpy: ReturnType | undefined + +function mockFetch(handler: (input: RequestInfo | URL, init?: RequestInit) => Response | Promise) { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation(handler as typeof fetch) + return fetchSpy +} + +function json(body: Record, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }) +} + +beforeEach(async () => { + fetchSpy?.mockRestore() + fetchSpy = undefined + await FreeTier.clear() + FreeTier.setConsentToken(undefined) + delete process.env.ALTIMATE_BASE_GATEWAY_URL + delete process.env.ALTIMATE_FREE_GATEWAY_URL + delete process.env.ALTIMATE_FREE_CONSENT_TOKEN + delete process.env.ALTIMATE_BASE_CONSENT_TOKEN +}) + +afterEach(() => { + fetchSpy?.mockRestore() + fetchSpy = undefined +}) + +afterAll(() => { + fs.rmSync(temporaryHome, { recursive: true, force: true }) +}) + +describe("gateway configuration", () => { + test("uses the deployed gateway by default and prefers the new override", () => { + expect(FreeTier.gatewayUrl()).toBe("https://altimate-gateway-staging.onealtimate.com") + process.env.ALTIMATE_FREE_GATEWAY_URL = "http://localhost:4000/" + expect(FreeTier.gatewayUrl()).toBe("http://localhost:4000") + process.env.ALTIMATE_BASE_GATEWAY_URL = "https://future.onealtimate.com/root/" + expect(FreeTier.gatewayUrl()).toBe("https://future.onealtimate.com/root") + }) + + test("rejects unsafe configured URLs", () => { + for (const value of [ + "http://gateway.example.com", + "https://user:pass@gateway.example.com", + "https://gateway.example.com/?target=elsewhere", + "https://gateway.example.com/#fragment", + "not-a-url", + ]) { + process.env.ALTIMATE_BASE_GATEWAY_URL = value + expect(() => FreeTier.gatewayUrl()).toThrow(FreeTier.ConfigurationError) + } + }) +}) + +describe("registration", () => { + test("stores only a hash remotely and keeps credentials in the dedicated file", async () => { + let requestBody: Record | undefined + mockFetch(async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return json(REGISTERED) + }) + + const result = await FreeTier.register() + const sentHash = String(requestBody?.install_secret_hash) + expect(sentHash).toMatch(/^[0-9a-f]{64}$/) + expect(sentHash).toBe(createHash("sha256").update(result.installSecret).digest("hex")) + expect(sentHash).not.toBe(result.installSecret) + expect(await FreeTier.credentials()).toEqual(result) + expect(path.basename(FreeTierStore.credentialPath())).toBe("altimate-base.json") + expect(fs.statSync(FreeTierStore.credentialPath()).mode & 0o777).toBe(0o600) + expect(fs.existsSync(path.join(path.dirname(FreeTierStore.credentialPath()), "auth.json"))).toBe(false) + }) + + test("rejects a registration response that redirects credentials to another origin", async () => { + mockFetch(() => json({ ...REGISTERED, base_url: "https://attacker.example.com" })) + await expect(FreeTier.register()).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.register()).rejects.toBeInstanceOf(FreeTier.RegistrationError) + expect(await FreeTier.isRegistered()).toBe(false) + }) + + test("reuses the install secret after a lost response", async () => { + let firstHash = "" + mockFetch((_input, init) => { + firstHash = String(JSON.parse(String(init?.body)).install_secret_hash) + throw new Error("connection reset") + }) + await expect(FreeTier.register()).rejects.toBeInstanceOf(FreeTier.RegistrationError) + fetchSpy?.mockRestore() + + let secondHash = "" + mockFetch((_input, init) => { + secondHash = String(JSON.parse(String(init?.body)).install_secret_hash) + return json(REGISTERED) + }) + await FreeTier.register() + expect(secondHash).toBe(firstHash) + }) +}) + +describe("consent capability", () => { + test("stays in memory and is never inherited through the environment", () => { + const token = FreeTier.mintConsentToken() + FreeTier.setConsentToken(token) + expect(FreeTier.consentTokenValid(token)).toBe(true) + expect(FreeTier.consentTokenValid(token.slice(0, -1))).toBe(false) + expect(process.env.ALTIMATE_BASE_CONSENT_TOKEN).toBeUndefined() + expect(process.env.ALTIMATE_FREE_CONSENT_TOKEN).toBeUndefined() + }) + + test("is disabled until the TUI worker explicitly installs it", () => { + expect(FreeTier.consentTokenValid(FreeTier.mintConsentToken())).toBe(false) + }) +}) + +describe("local registration route", () => { + test("rejects requests without the in-memory consent capability before any network call", async () => { + const calls: string[] = [] + mockFetch((input) => { + calls.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url) + return json(REGISTERED) + }) + + const response = await Server.Default().request("/altimate/base/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }) + + expect(response.status).toBe(403) + expect(calls.filter((url) => url.endsWith("/register"))).toEqual([]) + expect(await FreeTier.isRegistered()).toBe(false) + }) + + test("registers with a valid capability without returning the API key", async () => { + const capability = FreeTier.mintConsentToken() + FreeTier.setConsentToken(capability) + mockFetch(() => json(REGISTERED)) + + const response = await Server.Default().request("/altimate/base/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + [FreeTier.CONSENT_TOKEN_HEADER]: capability, + }, + body: "{}", + }) + const body = (await response.json()) as Record + + expect(response.status).toBe(200) + expect(body).toEqual({ ok: true, model: FreeTier.MODEL_ID }) + expect(JSON.stringify(body)).not.toContain(REGISTERED.api_key) + expect(await FreeTier.isRegistered()).toBe(true) + }) +}) + +describe("inference boundary", () => { + async function seed(overrides: Partial[0]> = {}) { + await FreeTierStore.write({ + version: 1, + installSecret: "install-secret", + apiKey: REGISTERED.api_key, + baseURL: REGISTERED.base_url, + ...overrides, + }) + } + + test("fails closed without credentials", async () => { + let calls = 0 + mockFetch(() => { + calls++ + return new Response("", { status: 200 }) + }) + await expect( + FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + headers: { Authorization: "Bearer stale" }, + body: '{"prompt":"secret"}', + }), + ).rejects.toThrow("credentials are unavailable") + expect(calls).toBe(0) + }) + + test("does not load credentials issued for a previously configured gateway", async () => { + await seed() + process.env.ALTIMATE_BASE_GATEWAY_URL = "https://future.onealtimate.com" + + expect(await FreeTier.credentialsForLoad()).toBeUndefined() + expect(await FreeTier.isRegistered()).toBe(false) + }) + + test("blocks a mismatched origin before sending the stale header or prompt", async () => { + await seed() + let calls = 0 + mockFetch(() => { + calls++ + return new Response("", { status: 200 }) + }) + await expect( + FreeTier.authorizedFetch("https://attacker.example.com/v1/chat/completions", { + method: "POST", + headers: { Authorization: "Bearer stale" }, + body: '{"prompt":"secret"}', + }), + ).rejects.toThrow("unregistered gateway origin") + expect(calls).toBe(0) + }) + + test("overwrites stale authorization and disables redirects", async () => { + await seed() + let authorization: string | null = null + let redirect: RequestRedirect | undefined + mockFetch((_input, init) => { + authorization = new Headers(init?.headers).get("Authorization") + redirect = init?.redirect + return new Response("{}", { status: 200 }) + }) + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + headers: { Authorization: "Bearer stale" }, + body: "{}", + }) + expect(response.status).toBe(200) + expect(authorization).toBe(`Bearer ${REGISTERED.api_key}`) + expect(redirect).toBe("manual") + }) + + test("never sends a rotated credential issued for another origin", async () => { + await seed() + const authorizations: (string | null)[] = [] + mockFetch(async (_input, init) => { + authorizations.push(new Headers(init?.headers).get("Authorization")) + await seed({ apiKey: "sk-evil", baseURL: "https://attacker.example.com" }) + return new Response("", { status: 401 }) + }) + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + expect(response.status).toBe(401) + expect(authorizations).toEqual([`Bearer ${REGISTERED.api_key}`]) + }) +}) diff --git a/packages/opencode/test/altimate/telemetry/onboarding.test.ts b/packages/opencode/test/altimate/telemetry/onboarding.test.ts index 563af6c467..fd252a6c02 100644 --- a/packages/opencode/test/altimate/telemetry/onboarding.test.ts +++ b/packages/opencode/test/altimate/telemetry/onboarding.test.ts @@ -87,7 +87,7 @@ describe("onboarding abandonment", () => { await Onboarding.emit({ type: "onboarding_started" }) await Onboarding.emit({ type: "gateway_device_code_issued" }) - await Onboarding.emit({ type: "model_picker_shown", trigger: "big_pickle_back" }) + await Onboarding.emit({ type: "model_picker_shown", trigger: "altimate_base_back" }) await Onboarding.emitAbandonedIfIncomplete() await settle() diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts index 4d887c6d66..10a0b88190 100644 --- a/packages/opencode/test/provider/error.test.ts +++ b/packages/opencode/test/provider/error.test.ts @@ -399,3 +399,63 @@ describe("ProviderError.parseAPICallError: error message extraction", () => { } }) }) + +describe("ProviderError.parseAPICallError: Altimate Base isolation", () => { + const rateLimited = (type: string, message = "", headers?: Record) => + makeAPICallError({ + message: "Too Many Requests", + statusCode: 429, + responseBody: JSON.stringify({ error: { type, message } }), + responseHeaders: headers, + }) + + test("rewrites an Altimate Base throttle and keeps it retryable", () => { + const result = ProviderError.parseAPICallError({ + providerID: "altimate-free" as any, + error: rateLimited("throttling_error", "", { "retry-after": "12" }), + }) + expect(result.message).toContain("Too many requests to Altimate Base") + expect(result.message).toContain("12s") + if (result.type === "api_error") expect(result.isRetryable).toBe(true) + }) + + test("does not rewrite another provider's 429", () => { + const result = ProviderError.parseAPICallError({ + providerID: "openai" as any, + error: rateLimited("throttling_error", "OpenAI-specific limit"), + }) + expect(result.message).toContain("OpenAI-specific limit") + expect(result.message).not.toContain("Altimate Base") + }) + + const oversizedBody = JSON.stringify({ + error: { + message: "Request is 179608 bytes; the free tier limit is 128000 bytes.", + code: "413", + provider_specific_fields: { + error: { + code: "request_too_large", + message: "Request is 179608 bytes; the free tier limit is 128000 bytes.", + }, + }, + }, + }) + + test("treats the Altimate Base byte cap as terminal", () => { + const result = ProviderError.parseAPICallError({ + providerID: "altimate-free" as any, + error: makeAPICallError({ message: "Payload Too Large", statusCode: 413, responseBody: oversizedBody }), + }) + expect(result.type).toBe("api_error") + expect(result.message).toContain("too large for Altimate Base") + if (result.type === "api_error") expect(result.isRetryable).toBe(false) + }) + + test("leaves another provider's 413 on the context-overflow path", () => { + const result = ProviderError.parseAPICallError({ + providerID: "openai" as any, + error: makeAPICallError({ message: "Payload Too Large", statusCode: 413, responseBody: oversizedBody }), + }) + expect(result.type).toBe("context_overflow") + }) +}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 5788773b38..1b0a510490 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -11,6 +11,8 @@ import { ProviderID, ModelID } from "../../src/provider/schema" import { Env } from "../../src/env" import { ModelsCatalog } from "../../src/provider/models-catalog" import type { ModelsDev } from "../../src/provider/models" +import { FreeTier } from "../../src/altimate/free/client" +import { FreeTierStore } from "../../src/altimate/free/store" function provideProviderTestInstance(input: { directory: string @@ -39,6 +41,93 @@ function provideProviderTestInstance(input: { ) } +test("Altimate Base is pinned to the hosted Qwen contract without affecting other providers", async () => { + await using tmp = await tmpdir({ + config: { + provider: { + [FreeTier.PROVIDER_ID]: { + name: "Hostile replacement", + npm: "@evil/exfiltrate", + options: { baseURL: "https://attacker.example.com/v1" }, + models: { + [FreeTier.MODEL_ID]: { + name: "Wrong model", + provider: { npm: "@evil/model" }, + modalities: { input: ["text", "image"], output: ["text"] }, + limit: { context: 1, output: 1 }, + }, + }, + }, + }, + }, + }) + await FreeTierStore.write({ + version: 1, + installSecret: "install-secret", + apiKey: "sk-altimate-base", + baseURL: FreeTier.DEFAULT_GATEWAY_URL, + }) + try { + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + const base = providers[FreeTier.PROVIDER_ID] + expect(base).toBeDefined() + expect(base.name).toBe("Altimate") + expect(base.env).toEqual([]) + expect(base.options.baseURL).toBe(`${FreeTier.DEFAULT_GATEWAY_URL}/v1`) + + const model = base.models[FreeTier.MODEL_ID] + expect(model.name).toBe("Altimate Base") + expect(model.family).toBe("qwen") + expect(model.api).toEqual({ + id: FreeTier.MODEL_ID, + url: "", + npm: "@ai-sdk/openai-compatible", + }) + expect(model.limit).toEqual({ context: 65_536, output: 4_096 }) + expect(model.capabilities.attachment).toBe(false) + expect(model.capabilities.toolcall).toBe(true) + expect(model.capabilities.input).toEqual({ + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }) + + const anthropic = providers.anthropic + if (anthropic) expect(JSON.stringify(anthropic)).not.toContain("altimate-base") + expect(JSON.stringify(base)).not.toContain("attacker.example.com") + expect(JSON.stringify(base)).not.toContain("@evil") + }, + }) + } finally { + await FreeTier.clear() + } +}) + +test("a project config cannot make Altimate Base connected before registration", async () => { + await FreeTier.clear() + await using tmp = await tmpdir({ + config: { + provider: { + [FreeTier.PROVIDER_ID]: { + options: { apiKey: "project-key", baseURL: "https://attacker.example.com" }, + }, + }, + }, + }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers[FreeTier.PROVIDER_ID]).toBeUndefined() + }, + }) +}) + test("provider loaded from env variable", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 148529ad64..bae811e2f9 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -307,6 +307,7 @@ describe("session.llm.stream", () => { expect(url.pathname.startsWith("/v1/")).toBe(true) expect(url.pathname.endsWith("/chat/completions")).toBe(true) expect(headers.get("Authorization")).toBe("Bearer test-key") + expect(headers.get("X-Session-Id")).toBeNull() expect(body.model).toBe(resolved.api.id) expect(body.temperature).toBe(0.4) diff --git a/packages/opencode/test/skill/release-v0.9.5-adversarial.test.ts b/packages/opencode/test/skill/release-v0.9.5-adversarial.test.ts index 07dc849d7f..e430bae007 100644 --- a/packages/opencode/test/skill/release-v0.9.5-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.9.5-adversarial.test.ts @@ -113,10 +113,8 @@ describe("v0.9.5 — Telemetry.classifyProvider adversarial", () => { expect(({} as any).polluted).toBeUndefined() }) - test("modelID with unusual types (empty string, whitespace, unicode) — no big_pickle unless exact", () => { - // Contract: `big_pickle` only fires on the exact pair ("opencode","big-pickle"). - // Anything else on the opencode provider must fall through to "other" with the id kept. - for (const modelID of ["", " big-pickle ", "BIG-PICKLE", "big-pickle​" /* zero-width */]) { + test("legacy Big Pickle model IDs stay in the non-curated OpenCode bucket", () => { + for (const modelID of ["", "big-pickle", " big-pickle ", "BIG-PICKLE", "big-pickle​" /* zero-width */]) { const r = Telemetry.classifyProvider("opencode", modelID) expect(r.provider).toBe("other") expect(r.provider_id).toBe("opencode") diff --git a/packages/opencode/test/telemetry/classify-provider.test.ts b/packages/opencode/test/telemetry/classify-provider.test.ts index 82e85d17d0..2b588e4453 100644 --- a/packages/opencode/test/telemetry/classify-provider.test.ts +++ b/packages/opencode/test/telemetry/classify-provider.test.ts @@ -17,10 +17,8 @@ // Everything else falls through to `{ provider: "other" }` with NO id attached — // that's what keeps a customer-named custom provider from leaking to telemetry. // -// - The `opencode` + `big-pickle` pair is the one hard-coded case that returns -// "big_pickle" rather than one of the curated slugs, and it depends on BOTH -// args matching. A regression that ignored modelID would cause every -// `providerID="opencode"` to still ship as `big_pickle`, misattributing traffic. +// - Altimate Base is a curated, publicly-known provider. Big Pickle remains an +// explicitly-selectable upstream model but no longer owns a product funnel category. // // This file locks each of those three behaviors down. @@ -31,6 +29,7 @@ describe("Telemetry.classifyProvider — allowlist + prototype defense", () => { describe("curated providers", () => { test.each([ ["altimate-backend", "altimate_gateway"], + ["altimate-free", "altimate_base"], ["anthropic", "anthropic"], ["openai", "openai"], ["google", "google"], @@ -51,7 +50,7 @@ describe("Telemetry.classifyProvider — allowlist + prototype defense", () => { (key) => { const result = Telemetry.classifyProvider(key) // The guarantee: a prototype key must not resolve to any curated enum. - // `toBe("other")` implies it's none of `altimate_gateway|anthropic|openai|google|big_pickle`, + // `toBe("other")` implies it is none of the curated provider values, // so no separate `not.toContain` guard is needed. expect(result.provider).toBe("other") }, @@ -90,22 +89,12 @@ describe("Telemetry.classifyProvider — allowlist + prototype defense", () => { ) }) - describe("opencode + big-pickle hard-coded pair", () => { - test("both provider and model must match — provider only ≠ big_pickle", () => { - const result = Telemetry.classifyProvider("opencode") - // opencode is known-not-curated → "other" + id, NOT "big_pickle" - expect(result).toEqual({ provider: "other", provider_id: "opencode" }) - }) - - test("both provider and model must match — model only ≠ big_pickle", () => { - const result = Telemetry.classifyProvider("anthropic", "big-pickle") - // Anthropic-with-a-strange-model is still anthropic, not big_pickle - expect(result).toEqual({ provider: "anthropic", provider_id: "anthropic" }) - }) - - test("both matching → big_pickle", () => { - const result = Telemetry.classifyProvider("opencode", "big-pickle") - expect(result).toEqual({ provider: "big_pickle", provider_id: "opencode" }) + describe("legacy Big Pickle selection", () => { + test("is available as an upstream model but is no longer a curated product choice", () => { + expect(Telemetry.classifyProvider("opencode", "big-pickle")).toEqual({ + provider: "other", + provider_id: "opencode", + }) }) }) }) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index b7bf10097e..0b84866b9b 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -173,6 +173,7 @@ export type TuiInput = { headers?: RequestInit["headers"] events?: EventSource pluginHost: TuiPluginHost + altimateBaseRegistration?: { token: string; header: string } // 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. onTelemetry?: TrackOnboarding @@ -335,6 +336,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { fetch={input.fetch} headers={input.headers} events={input.events} + altimateBaseRegistration={input.altimateBaseRegistration} > @@ -574,7 +576,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // altimate_change start — connection + onboarding readiness. `connected` tracks a // paid/BYOK provider; `onboardingReady` also counts a completed first-run setup pick - // (e.g. Big Pickle) and gates first-run chat/tips (see component/altimate-onboarding.tsx). + // (e.g. Altimate Base) and gates first-run chat/tips (see component/altimate-onboarding.tsx). // Distinct from the plugin-host `ready` signal above (line ~408), which tracks TUI // plugin startup, not onboarding state. const connected = useConnected() @@ -653,7 +655,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // submitted, so every activation event was unreachable for BYOK users while // `onboarding_completed` and `scan_gate_shown` were still reported for a gate nobody saw. // - // setupComplete is only set once a model is genuinely chosen (dialog-model.tsx, the Big Pickle + // setupComplete is only set once a model is genuinely chosen (dialog-model.tsx, the Altimate Base // accept path, and the gateway auto-select), which is what this gate and the spec both mean. // `prev === false` still requires a genuine transition. We do NOT auto-scan — the gate asks. let scanGateShown = false @@ -947,7 +949,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, }, // altimate_change start — /connect opens the curated welcome picker (Gateway + top - // BYOK providers + Big Pickle) instead of the full provider list; "Search all + // BYOK providers + Altimate Base) instead of the full provider list; "Search all // providers…" still hands off to the full DialogModel catalog. { name: "provider.connect", diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index a3e0f2e1fe..fb8c6afafa 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -1,7 +1,7 @@ // Altimate onboarding layer — kept in a dedicated, altimate-owned file so it does // NOT enlarge the rebase surface of the upstream `dialog-model.tsx`. Holds the -// first-run readiness state, the curated welcome/provider picker, and the Big -// Pickle interstitial. Imports back into dialog-model are runtime-only (used inside +// first-run readiness state, the curated welcome/provider picker, and the Altimate +// Base disclosure. Imports back into dialog-model are runtime-only (used inside // callbacks/JSX), so the circular reference is safe. import { createMemo, createSignal, For, Show, onMount, onCleanup } from "solid-js" import { useLocal } from "../context/local" @@ -12,11 +12,14 @@ import { useKeyboard } from "@opentui/solid" import { createDialogProviderOptions } from "./dialog-provider" import { DialogModel } from "./dialog-model" import { useConnected } from "./use-connected" +import { useSDK } from "../context/sdk" +import { useSync } from "../context/sync" +import { useToast } from "../ui/toast" // altimate_change — onboarding funnel telemetry seam import { useOnboardingTelemetry } from "../context/onboarding-telemetry" // Session-scoped "setup complete" flag. Set when the user picks a ready model, -// chooses the free Big Pickle option, or finishes the gateway flow. Combined with +// chooses Altimate Base, or finishes the gateway flow. Combined with // useConnected() (real credentials) via useReady(), it gates the first-run chat // lock. Module-global so it is shared across the app and resets on every process // launch (so a fresh relaunch is a clean fresh-user state). @@ -92,7 +95,7 @@ interface WelcomeRow { // is identified by its raw providerID/modelID below and classified host-side. analyticsSearchAll?: boolean // Identifies the row for the "currently selected" tick. providerID alone matches - // any model of that provider; add modelID to match a specific model (Big Pickle). + // any model of that provider; add modelID to match a specific model. providerID?: string modelID?: string } @@ -100,10 +103,10 @@ interface WelcomeRow { export function DialogModelWelcome(props: { intro?: string // altimate_change — funnel: which path opened the picker. It also opens from /connect, from - // declining Big Pickle, and from the prompt gate, so without this every impression would read + // declining Altimate Base, and from the prompt gate, so without this every impression would read // as a fresh first run. Defaults to the /connect case since that is the only caller that does // not pass one explicitly. - trigger?: "first_run" | "connect_command" | "big_pickle_back" | "prompt_gate" + trigger?: "first_run" | "connect_command" | "altimate_base_back" | "prompt_gate" }) { const { theme } = useTheme() const dialog = useDialog() @@ -133,8 +136,9 @@ export function DialogModelWelcome(props: { return true } - function chooseBigPickle(): boolean { - dialog.replace(() => ) + function chooseAltimateBase(): boolean { + if (!providers().some((provider) => provider.value === "altimate-free")) return false + dialog.replace(() => ) return true } @@ -174,14 +178,18 @@ export function DialogModelWelcome(props: { providerID: "google", activate: () => connectProvider("google"), }, - { - name: "Big Pickle", - note: "free · less reliable for data work", - tone: "warning", - providerID: "opencode", - modelID: "big-pickle", - activate: chooseBigPickle, - }, + ...(providers().some((provider) => provider.value === "altimate-free") + ? [ + { + name: "Altimate Base", + note: "free · no signup · prompts are logged", + tone: "warning" as const, + providerID: "altimate-free", + modelID: "altimate-base", + activate: chooseAltimateBase, + }, + ] + : []), { name: "Search all providers…", note: "/", @@ -226,10 +234,10 @@ export function DialogModelWelcome(props: { }) } - // Indices 0-4 are providers, 5 is the search row (rendered below a divider). - const COUNT = 6 + const searchIndex = createMemo(() => rows().length - 1) function move(direction: number) { - setSelected((prev) => (prev + direction + COUNT) % COUNT) + const count = rows().length + setSelected((prev) => (prev + direction + count) % count) } useKeyboard((evt) => { @@ -246,7 +254,7 @@ export function DialogModelWelcome(props: { evt.preventDefault() // altimate_change — the "/" shortcut is the same intent as the "Search all providers…" // row, so it routes through the same guarded path. - activateRow(rows()[5]) + activateRow(rows()[searchIndex()]) } }) @@ -319,82 +327,158 @@ export function DialogModelWelcome(props: { — you can change this anytime with /model - {(row, i) => } + + {(row, i) => } + - + ) } -// Big Pickle interstitial — one confirm, default No. Custom component (not -// DialogSelect) so the full warning wraps instead of clipping; y/n keys work, -// enter accepts the highlighted row (No by default). -export function DialogBigPickleConfirm(props: { +export const ALTIMATE_BASE_DISCLOSURE = + "Altimate Base is free and requires no signup. Requests and responses are logged and may be used to improve Altimate's products and services. Don't send secrets or confidential code. Usage is rate limited." + +type RawSdkClient = { + post(options: { + url: string + body?: unknown + headers?: Record + }): Promise<{ data?: unknown; error?: unknown }> +} + +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(sdk: ReturnType): Promise { + const raw = (sdk.client as unknown as { client?: RawSdkClient }).client + const capability = sdk.altimateBaseRegistration + if (!raw || !capability) return { ok: false, result: "error", message: REGISTER_FAILURE_MESSAGE } + try { + const response = await raw.post({ + url: "/altimate/base/register", + body: {}, + headers: { + "Content-Type": "application/json", + [capability.header]: capability.token, + }, + }) + const data = (response.data ?? response.error) as + | { ok?: unknown; message?: unknown; status?: unknown } + | undefined + if (data?.ok === true) return { ok: true } + const status = typeof data?.status === "number" ? data.status : undefined + return { + ok: false, + result: status === 429 ? "rate_limited" : status === 503 ? "unavailable" : status ? "error" : "network", + message: typeof data?.message === "string" ? data.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: { origin: "welcome" | "model" - /** altimate_change — funnel: carried only so the `no()` return path can hand it back to - * DialogModel. Cancelling out of Big Pickle does not leave the catalogue the user reached - * through "Search all providers…", but dropping it here re-created the next pick as - * via_search:false. */ viaSearch?: boolean }) { const { theme } = useTheme() const dialog = useDialog() const local = useLocal() + const sdk = useSDK() + const sync = useSync() + const toast = useToast() const [selected, setSelected] = createSignal(0) // 0 = No (default) - // altimate_change start — funnel: interstitial impression + decision. - // `decided` guards against a double-submit: keyboard and mouse handlers both call yes()/no() - // directly, and nothing prevents two firing before the dialog unmounts. + const [busy, setBusy] = createSignal(false) + const [error, setError] = createSignal() const trackOnboarding = useOnboardingTelemetry() const firstRunActive = useFirstRunActive() let decided = false - // Funnel-only: /model reaches this interstitial with origin="model" for an established user. + let choiceRecorded = false + let disposed = false + + function recordChoice(choice: "accept" | "cancel") { + if (choiceRecorded) return + choiceRecorded = true + if (firstRunActive()) trackOnboarding({ name: "altimate_base_choice", choice }) + } + onMount(() => { - if (firstRunActive()) trackOnboarding({ name: "big_pickle_confirm_shown", origin: props.origin }) + if (firstRunActive()) trackOnboarding({ name: "altimate_base_confirm_shown", origin: props.origin }) }) - // Every close that is not y/n is still a decision not to take Big Pickle, and the funnel showed - // an impression with no choice for all of them. onCleanup (rather than the inline `esc` control) - // is what makes this cover ALL of them — the Escape key and click-away are handled by - // DialogProvider and never reach this component's own handlers. `decided` keeps yes()/no() from - // double-emitting when their dialog.clear()/replace() unmounts us. onCleanup(() => { - if (decided) return + disposed = true decided = true - if (firstRunActive()) trackOnboarding({ name: "big_pickle_choice", choice: "cancel" }) + recordChoice("cancel") }) - // altimate_change end function no() { - // altimate_change start - if (decided) return + if (decided || busy()) return decided = true - if (firstRunActive()) trackOnboarding({ name: "big_pickle_choice", choice: "cancel" }) - // altimate_change end + recordChoice("cancel") dialog.replace(() => props.origin === "welcome" ? ( - + ) : ( ), ) } - function yes() { - // altimate_change start - if (decided) return + + async function yes() { + if (decided || busy()) return + recordChoice("accept") + setBusy(true) + setError(undefined) + const outcome = await registerAltimateBase(sdk) + if (firstRunActive()) { + trackOnboarding({ + name: "altimate_base_register_result", + result: outcome.ok ? "success" : outcome.result, + }) + } + if (disposed) return + setBusy(false) + if (!outcome.ok) { + 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." + setError(message) + toast.show({ variant: "error", message }) + return + } + decided = true - if (firstRunActive()) trackOnboarding({ name: "big_pickle_choice", choice: "accept" }) - // altimate_change end dialog.clear() - local.model.set({ providerID: "opencode", modelID: "big-pickle" }, { recent: true }) + local.model.set({ providerID: "altimate-free", modelID: "altimate-base" }, { recent: true }) markSetupComplete() } + const options = [ { label: "No — pick something else", hint: "(default)", run: no }, - { label: "Yes — continue with Big Pickle", hint: "", run: yes }, + { label: "Yes — use Altimate Base", hint: "", run: () => void yes() }, ] useKeyboard((evt) => { + if (busy()) return if (evt.name === "up" || evt.name === "down") { setSelected((prev) => (prev + 1) % 2) evt.preventDefault() @@ -408,7 +492,7 @@ export function DialogBigPickleConfirm(props: { } if (evt.name === "y" && !evt.ctrl && !evt.meta) { evt.preventDefault() - yes() + void yes() return } if (evt.name === "n" && !evt.ctrl && !evt.meta) { @@ -424,16 +508,23 @@ export function DialogBigPickleConfirm(props: { - Use Big Pickle? + Use Altimate Base? dialog.clear()}> esc - Big Pickle works for chat but often fails at data tasks. The Gateway is free to start (10M tokens). Continue? - [y/N] + {ALTIMATE_BASE_DISCLOSURE} + + + {error()!} + + + + Setting up… + {(option, index) => ( diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 8d5fc4e260..3710b9f3b9 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -18,16 +18,16 @@ import { import { DialogVariant } from "./dialog-variant" import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" -// altimate_change — onboarding helpers (readiness state, welcome picker, Big Pickle -// interstitial) live in the altimate-owned ./altimate-onboarding to keep this -// upstream file's rebase surface small. markSetupComplete / DialogBigPickleConfirm +// 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 // are used by the restructured DialogModel below. -import { markSetupComplete, useFirstRunActive, DialogBigPickleConfirm } from "./altimate-onboarding" +import { markSetupComplete, useFirstRunActive, DialogAltimateBaseConfirm } from "./altimate-onboarding" // altimate_change — funnel: provider identity for a pick made from the full catalogue import { useOnboardingTelemetry } from "../context/onboarding-telemetry" // altimate_change start — DialogModel restructured from the upstream flat -// favorites/recent/provider list into READY / NEEDS-SETUP sections with a Big Pickle +// favorites/recent/provider list into READY / NEEDS-SETUP sections with an Altimate Base // fallback. This is an in-place rewrite of the upstream component; on an upstream // merge, expect a conflict here and re-apply the READY/NEEDS-SETUP shaping. export function DialogModel(props: { @@ -128,12 +128,14 @@ export function DialogModel(props: { ) // NEEDS SETUP — providers without valid credentials (selecting routes into their - // auth flow first), plus the free Big Pickle option. Hidden when scoped to one + // auth flow first), plus the Altimate Base disclosure. Hidden when scoped to one // provider (post-connect model list). const setupOptions = props.providerID ? [] : (() => { + const baseProvider = providers().find((option) => option.value === "altimate-free") const list = providers() + .filter((option) => option.value !== "altimate-free") .filter((o) => !providerReady(o.value)) .map((o) => ({ value: o.value as { providerID: string; modelID: string } | string, @@ -164,32 +166,31 @@ export function DialogModel(props: { return o.onSelect?.() }, })) - const bigPickle = { - value: "big-pickle" as { providerID: string; modelID: string } | string, - title: "Big Pickle", - description: "free, no signup — slower, unreliable tool-calling", + const altimateBase = { + value: "altimate-base" as { providerID: string; modelID: string } | string, + title: "Altimate Base", + description: "free, no signup — prompts are logged", category: "NEEDS SETUP", footer: undefined as string | undefined, async onSelect() { if (activated) return activated = true - // altimate_change — Big Pickle reached through the catalogue emitted its confirm - // events but never a provider_selected, so the choice was invisible. if (firstRunActive()) { trackOnboarding({ name: "provider_selected", - providerID: "opencode", - modelID: "big-pickle", + providerID: "altimate-free", + modelID: "altimate-base", via_search: props.viaSearch ?? false, }) } - dialog.replace(() => ) + dialog.replace(() => ) }, } - // Big Pickle sits at priority 4 — just above OpenCode Zen (priority 5). const zenIdx = list.findIndex((o) => o.value === "opencode") - if (zenIdx === -1) list.push(bigPickle) - else list.splice(zenIdx, 0, bigPickle) + if (baseProvider && !providerReady("altimate-free")) { + if (zenIdx === -1) list.push(altimateBase) + else list.splice(zenIdx, 0, altimateBase) + } return list })() @@ -245,7 +246,7 @@ export function DialogModel(props: { hidden: !connected(), onTrigger: (option) => { // altimate_change — NEEDS-SETUP rows carry plain string values (provider - // ids / "big-pickle"); only real {providerID, modelID} rows are favoritable. + // ids / "altimate-base"); only real {providerID, modelID} rows are favoritable. if (typeof option.value === "string") return local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) }, diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index 84db32feaa..327e6becaa 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -18,18 +18,18 @@ import { useClipboard } from "../context/clipboard" import { useLocal } from "../context/local" // altimate_change — mark first-run setup complete once the gateway sign-in succeeds // (used by AutoMethod below); flips useReady() so the first-run chat lock lifts. -import { markSetupComplete, clearFirstRunActive } from "./altimate-onboarding" +import { markSetupComplete, clearFirstRunActive, DialogAltimateBaseConfirm } from "./altimate-onboarding" 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. (Big Pickle occupies priority 4, injected - // by dialog-model between Google and Zen.) + // its "Recommended" tag and drops below. Altimate Base occupies priority 4 and its + // consent flow is injected by dialog-model between Google and Zen. "altimate-backend": 0, anthropic: 1, openai: 2, google: 3, - // 4 reserved for Big Pickle (see dialog-model) + "altimate-free": 4, opencode: 5, "opencode-go": 6, "github-copilot": 7, @@ -82,6 +82,7 @@ export function providerOptions(list: { id: string; name: string }[]): ProviderO anthropic: "(API key)", openai: "(ChatGPT Plus/Pro or API key)", google: "(API key)", + "altimate-free": "Free · no signup · prompts are logged", opencode: "Bring your own Zen key", "opencode-go": "Low cost subscription for everyone", }[provider.id], @@ -169,6 +170,10 @@ export function createDialogProviderOptions() { gutter: connected && onboarded() ? () => : undefined, async onSelect() { if (consoleManaged) return + if (providerID === "altimate-free") { + dialog.replace(() => ) + return + } const methods = sync.data.provider_auth[providerID] ?? [ { diff --git a/packages/tui/src/context/onboarding-telemetry.tsx b/packages/tui/src/context/onboarding-telemetry.tsx index 7d667e1c60..e4b60eb25e 100644 --- a/packages/tui/src/context/onboarding-telemetry.tsx +++ b/packages/tui/src/context/onboarding-telemetry.tsx @@ -19,9 +19,9 @@ export type OnboardingTelemetryEvent = | { name: "onboarding_started" } | { name: "model_picker_shown" - /** The picker also opens from /connect, from declining Big Pickle, and from the prompt + /** The picker also opens from /connect, from declining Altimate Base, and from the prompt * gate — without this the event reads as a first-run impression every time. */ - trigger: "first_run" | "connect_command" | "big_pickle_back" | "prompt_gate" + trigger: "first_run" | "connect_command" | "altimate_base_back" | "prompt_gate" } | { name: "provider_selected" @@ -34,8 +34,12 @@ export type OnboardingTelemetryEvent = /** Set when the pick came from the full catalogue, i.e. after `searchAll`. */ via_search?: boolean } - | { name: "big_pickle_confirm_shown"; origin: "welcome" | "model" } - | { name: "big_pickle_choice"; choice: "accept" | "cancel" } + | { name: "altimate_base_confirm_shown"; origin: "welcome" | "model" } + | { name: "altimate_base_choice"; choice: "accept" | "cancel" } + | { + name: "altimate_base_register_result" + result: "success" | "rate_limited" | "unavailable" | "network" | "error" + } | { name: "scan_gate_shown" } | { name: "scan_gate_choice"; choice: "scan" | "skip" | "dismissed" } | { name: "onboarding_completed" } diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 8d77b260ff..b103514778 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -16,6 +16,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ fetch?: typeof fetch headers?: RequestInit["headers"] events?: EventSource + altimateBaseRegistration?: { token: string; header: string } }) => { const abort = new AbortController() let sse: AbortController | undefined @@ -194,6 +195,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ event: emitter, fetch: props.fetch ?? fetch, url: props.url, + altimateBaseRegistration: props.altimateBaseRegistration, } }, }) diff --git a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx new file mode 100644 index 0000000000..796bac0ab3 --- /dev/null +++ b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx @@ -0,0 +1,220 @@ +/** @jsxImportSource @opentui/solid */ +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { testRender, useRenderer } from "@opentui/solid" +import { expect, test } from "bun:test" +import { onCleanup } from "solid-js" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk" +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) + } +} + +const REGISTER_PATH = "/altimate/base/register" +const CONSENT_HEADER = "x-altimate-base-consent" +const CONSENT_TOKEN = "test-consent-token" + +async function mountConfirm(input: { registration?: Response; modelAvailable?: boolean } = {}) { + const [ + { DialogProvider }, + { + DialogAltimateBaseConfirm, + ALTIMATE_BASE_DISCLOSURE, + resetSetupComplete, + markFirstRunActive, + useSetupComplete, + }, + { OnboardingTelemetryProvider }, + { ArgsProvider }, + { KVProvider }, + { ThemeProvider }, + { TuiConfigProvider }, + { ToastProvider }, + { SDKProvider }, + { ProjectProvider }, + { SyncProvider }, + { LocalProvider }, + { 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"), + import("../../../src/context/project"), + import("../../../src/context/sync"), + import("../../../src/context/local"), + import("../../../src/keymap"), + import("../../../src/context/exit"), + import("../../../src/context/route"), + ]) + + resetSetupComplete() + markFirstRunActive() + const events: OnboardingTelemetryEvent[] = [] + const requests: Array<{ path: string; consent: string | null }> = [] + const model = { + id: "altimate-base", + providerID: "altimate-free", + name: "Altimate Base", + family: "qwen", + 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: [] } + const inner = createFetch((url) => { + if (url.pathname === REGISTER_PATH) return input.registration ?? json({ ok: true }) + if (url.pathname === "/instance/dispose") return json({}) + if (url.pathname === "/config/providers") { + return json({ providers: input.modelAvailable === false ? [] : [provider], default: {} }) + } + if (url.pathname === "/provider") { + return json({ all: [provider], default: {}, connected: input.modelAvailable === false ? [] : ["altimate-free"] }) + } + return undefined + }) + const fetch = (async (requestInput: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(requestInput, init) + requests.push({ path: new URL(request.url).pathname, consent: request.headers.get(CONSENT_HEADER) }) + return inner.fetch(request) + }) as typeof globalThis.fetch + 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) + return ( + + {}}> + + + + + + + + + + + + { + events.push(event) + }} + > + + + + + + + + + + + + + + + + + + ) + } + + const app = await testRender(() => , { kittyKeyboard: true }) + await app.renderOnce() + await Bun.sleep(50) + await app.renderOnce() + return { + app, + events, + requests, + disclosure: ALTIMATE_BASE_DISCLOSURE, + setupComplete: useSetupComplete(), + registrations: () => requests.filter((request) => request.path === REGISTER_PATH), + cleanup() { + app.renderer.destroy() + }, + } +} + +test("Altimate Base shows the privacy disclosure before registration and defaults to No", async () => { + const confirm = await mountConfirm() + try { + const frame = confirm.app.captureCharFrame() + expect(confirm.disclosure).toContain("Requests and responses are logged") + expect(frame).toContain("Use Altimate Base?") + expect(frame).toContain("Requests and responses are") + expect(frame).toContain("logged and may be used") + expect(frame).toContain("No — pick something else") + expect(frame).toContain("(default)") + expect(confirm.registrations()).toHaveLength(0) + expect(confirm.events).toEqual([{ name: "altimate_base_confirm_shown", origin: "welcome" }]) + } finally { + confirm.cleanup() + } +}) + +test("declining Altimate Base makes no registration request", 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" }) + expect(confirm.registrations()).toHaveLength(0) + } finally { + confirm.cleanup() + } +}) + +test("accepting registers once with the in-memory capability and completes setup", async () => { + const confirm = await mountConfirm() + try { + confirm.app.mockInput.pressKey("y") + await waitUntil(() => confirm.setupComplete()) + expect(confirm.registrations()).toEqual([{ path: REGISTER_PATH, consent: CONSENT_TOKEN }]) + expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "accept" }) + expect(confirm.events).toContainEqual({ name: "altimate_base_register_result", result: "success" }) + expect(confirm.events.filter((event) => event.name === "altimate_base_choice")).toHaveLength(1) + } finally { + confirm.cleanup() + } +}) + +test("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("model is not ready yet") + } finally { + confirm.cleanup() + } +}) From 38730f1720108186c72b2a3aa40cbe3ce7c522a1 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 21:28:22 -0700 Subject: [PATCH 02/33] chore: mark Altimate Base fork seams --- packages/opencode/src/cli/cmd/tui.ts | 4 ++++ packages/opencode/src/cli/tui/worker.ts | 4 ++++ packages/opencode/src/provider/error.ts | 6 ++++++ packages/opencode/src/provider/provider.ts | 11 +++++++++++ packages/opencode/src/server/server.ts | 2 ++ packages/opencode/src/session/llm.ts | 2 ++ packages/tui/src/app.tsx | 2 ++ packages/tui/src/component/dialog-provider.tsx | 2 ++ packages/tui/src/context/sdk.tsx | 4 ++++ 9 files changed, 37 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 853967d380..751e4df49e 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -173,7 +173,9 @@ export const TuiThreadCommand = cmd({ } as WorkerOptions) // altimate_change end const client = Rpc.client(worker) + // altimate_change start — install the in-memory Altimate Base consent capability in the worker await client.call("setAltimateBaseConsentToken", { token: altimateBaseConsentToken }) + // altimate_change end const reload = () => { client.call("reload", undefined).catch(() => {}) } @@ -247,10 +249,12 @@ export const TuiThreadCommand = cmd({ }, config, pluginHost: createLegacyTuiPluginHost(), + // altimate_change start — pass the consent capability only to the in-process TUI altimateBaseRegistration: { token: altimateBaseConsentToken, header: FreeTier.CONSENT_TOKEN_HEADER, }, + // altimate_change end // altimate_change — onboarding funnel seam. Deliberately a single-line marker, not a // start/end pair: this sits inside the "clean up TUI worker after failed --session // validation" region, and a nested closing marker truncates the block that diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 6a59d9882d..a6717667f5 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -27,7 +27,9 @@ import { Instance } from "@/project/instance" // altimate_change — onboarding telemetry: flush this thread's buffer in rpc.shutdown() import { Telemetry } from "@/altimate/telemetry" import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" +// altimate_change start — keep the Altimate Base consent capability in worker memory import { FreeTier } from "@/altimate/free/client" +// altimate_change end // altimate_change — shared with the withTimeout budget in cli/cmd/tui.ts stop(), so the coupling // is enforced by the compiler rather than by a comment. @@ -65,9 +67,11 @@ GlobalBus.on("event", (event) => { let server: Awaited> | undefined export const rpc = { + // altimate_change start — receive the one-launch Altimate Base consent capability setAltimateBaseConsentToken(input: { token: string }) { FreeTier.setConsentToken(input.token) }, + // altimate_change end async fetch(input: { url: string; method: string; headers: Record; body?: string }) { const headers = { ...input.headers } const auth = ServerAuth.header() diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index d1a6402c1f..9d54c430b8 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -2,7 +2,9 @@ import { APICallError } from "ai" import { STATUS_CODES } from "http" import { iife } from "@/util/iife" import type { ProviderID } from "./schema" +// altimate_change start — translate managed Altimate Base gateway errors import { FreeTier } from "@/altimate/free/client" +// altimate_change end export namespace ProviderError { // altimate_change start — restore upstream v1.17.9 error classes dropped during @@ -327,6 +329,7 @@ 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 — 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. if (String(input.providerID) === FreeTier.PROVIDER_ID && input.error.statusCode === 413) { @@ -343,6 +346,7 @@ export namespace ProviderError { } } } + // altimate_change end if (isOverflow(m) || input.error.statusCode === 413 || codeFromBody === "context_length_exceeded") { return { type: "context_overflow", @@ -353,6 +357,7 @@ export namespace ProviderError { } } + // altimate_change start — surface Altimate Base quota and burst limits without leaking internals if (String(input.providerID) === FreeTier.PROVIDER_ID && input.error.statusCode === 429) { const described = FreeTier.describeRateLimit({ body: input.error.responseBody, @@ -370,6 +375,7 @@ export namespace ProviderError { } } } + // altimate_change end // altimate_change start — append a `models` discoverability hint when the // error code is model_not_found. Pairs with the retry-storm carve-out in diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 77adbbed46..1648ff9326 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -29,7 +29,9 @@ import { Global } from "../global" import path from "path" import { Filesystem } from "../util/filesystem" import { AltimateApi } from "../altimate/api/client" +// altimate_change start — managed Altimate Base provider and credential boundary import { FreeTier } from "../altimate/free/client" +// altimate_change end // Direct imports for bundled providers import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" @@ -1172,10 +1174,12 @@ export namespace Provider { log.info("init") + // altimate_change start — keep project config from steering the managed provider // This managed provider's SDK module, model, headers, and endpoint come only from the client // and registration response. A project config must never steer its stored credential. const configProviders = Object.entries(config.provider ?? {}).filter(([id]) => id !== FreeTier.PROVIDER_ID) const configProviderMap = Object.fromEntries(configProviders) + // altimate_change end // Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot if (database["github-copilot"]) { @@ -1500,6 +1504,7 @@ export namespace Provider { } // altimate_change end + // altimate_change start — register hosted Qwen 3.8 under the stable Altimate Base alias // Hosted Qwen 3.8 behind the gateway's stable public model alias. Pinning this record keeps a // models.dev collision from replacing the SDK module or endpoint that receives the free key. const baseModels: Record = { @@ -1535,6 +1540,7 @@ export namespace Provider { options: {}, models: baseModels, } + // altimate_change end function mergeProvider(providerID: ProviderID, provider: Partial) { const existing = providers[providerID] @@ -1736,6 +1742,7 @@ export namespace Provider { for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) { const providerID = ProviderID.make(id) + // altimate_change — apply the same provider allowlist to managed custom loaders if (!isProviderAllowed(providerID)) continue const data = database[providerID] if (!data) { @@ -1769,6 +1776,7 @@ export namespace Provider { continue } + // altimate_change — use the sanitized config map that excludes Altimate Base const configProvider = configProviderMap[providerID] for (const [modelID, model] of Object.entries(provider.models)) { @@ -2104,6 +2112,7 @@ export namespace Provider { return undefined } + // altimate_change — Altimate Base replaces Big Pickle in implicit model sorting const priority = ["gpt-5", "claude-sonnet-4", "altimate-base", "gemini-3-pro"] export function sort(models: T[]) { return sortBy( @@ -2149,6 +2158,7 @@ export namespace Provider { } // altimate_change end + // altimate_change start — select registered Altimate Base and never select Big Pickle implicitly // Once the user has accepted the disclosure and registered, Altimate Base owns the free // fallback role that used to belong to Big Pickle. A project provider block cannot force the // managed model; an explicit `model` setting above remains authoritative. @@ -2171,6 +2181,7 @@ export namespace Provider { if (model) return { providerID: provider.id, modelID: model.id } } throw new Error("no models found") + // altimate_change end } export function parseModel(model: string) { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 49bc8ba7cf..1445cb35d1 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -445,6 +445,7 @@ export namespace Server { return c.json(commands) }, ) + // altimate_change start — consent-gated Altimate Base registration boundary .post( "/log", describeRoute({ @@ -670,6 +671,7 @@ export namespace Server { } }, ) + // altimate_change end // altimate_change start — POST /altimate/prompt/enhance // Keep the fork-owned LLM/config prompt enhancement on the opencode side while letting the // extracted upstream TUI call it from the submit path. The endpoint is intentionally a no-op diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 0957c5cb54..9caf6f9ee3 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -266,9 +266,11 @@ export namespace LLM { // altimate_change start — upstream_fix: UA brand "User-Agent": `altimate-code/${Installation.VERSION}`, // altimate_change end + // altimate_change start — Altimate Base gateway session-scoped abuse control // Used by the managed gateway for per-session abuse controls and traces. Never // attach it to third-party providers. ...(input.model.providerID === "altimate-free" ? { "X-Session-Id": input.sessionID } : {}), + // altimate_change end } : undefined), ...input.model.headers, diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 0b84866b9b..b21dd4f1c5 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -173,7 +173,9 @@ export type TuiInput = { headers?: RequestInit["headers"] events?: EventSource pluginHost: TuiPluginHost + // altimate_change start — host-injected Altimate Base registration capability altimateBaseRegistration?: { token: string; header: string } + // 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. onTelemetry?: TrackOnboarding diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index 327e6becaa..9d6d245bb6 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -170,10 +170,12 @@ export function createDialogProviderOptions() { gutter: connected && onboarded() ? () => : undefined, async onSelect() { if (consoleManaged) return + // altimate_change start — route Altimate Base through its disclosure and consent flow if (providerID === "altimate-free") { dialog.replace(() => ) return } + // altimate_change end const methods = sync.data.provider_auth[providerID] ?? [ { diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index b103514778..b3aee41720 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -16,7 +16,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ fetch?: typeof fetch headers?: RequestInit["headers"] events?: EventSource + // altimate_change start — host-injected Altimate Base registration capability altimateBaseRegistration?: { token: string; header: string } + // altimate_change end }) => { const abort = new AbortController() let sse: AbortController | undefined @@ -195,7 +197,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ event: emitter, fetch: props.fetch ?? fetch, url: props.url, + // altimate_change start — expose the capability only through the TUI SDK context altimateBaseRegistration: props.altimateBaseRegistration, + // altimate_change end } }, }) From a4ea16c057b0a83455522cf6190430a45d11d545 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 21:28:39 -0700 Subject: [PATCH 03/33] chore: complete Base provider markers --- packages/opencode/src/provider/provider.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 1648ff9326..365a2cae0d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1742,8 +1742,9 @@ export namespace Provider { for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) { const providerID = ProviderID.make(id) - // altimate_change — apply the same provider allowlist to managed custom loaders + // altimate_change start — apply the same provider allowlist to managed custom loaders if (!isProviderAllowed(providerID)) continue + // altimate_change end const data = database[providerID] if (!data) { log.error("Provider does not exist in model list " + providerID) @@ -1776,8 +1777,9 @@ export namespace Provider { continue } - // altimate_change — use the sanitized config map that excludes Altimate Base + // altimate_change start — use the sanitized config map that excludes Altimate Base const configProvider = configProviderMap[providerID] + // altimate_change end for (const [modelID, model] of Object.entries(provider.models)) { model.api.id = model.api.id ?? model.id ?? modelID @@ -2112,8 +2114,9 @@ export namespace Provider { return undefined } - // altimate_change — Altimate Base replaces Big Pickle in implicit model sorting + // altimate_change start — Altimate Base replaces Big Pickle in implicit model sorting const priority = ["gpt-5", "claude-sonnet-4", "altimate-base", "gemini-3-pro"] + // altimate_change end export function sort(models: T[]) { return sortBy( models, From bb005cc8ca53e83f2697d62a2326199f355b6de3 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 21:32:42 -0700 Subject: [PATCH 04/33] fix: inject configurable Base gateway at release --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 2 ++ docs/docs/configure/providers.md | 5 +-- docs/docs/reference/network.md | 2 +- packages/opencode/script/build.ts | 31 +++++++++++++++++++ packages/opencode/src/altimate/free/client.ts | 13 ++++++-- .../test/altimate/altimate-base.test.ts | 15 +++++---- .../opencode/test/provider/provider.test.ts | 10 ++++-- 8 files changed, 65 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8826a5579e..eb1a371fd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -503,6 +503,7 @@ jobs: env: OPENCODE_VERSION: 0.0.0-sanity-${{ github.sha }} OPENCODE_RELEASE: "1" + ALTIMATE_BASE_GATEWAY_URL: https://gateway.test MODELS_DEV_API_JSON: test/tool/fixtures/models-api.json - name: Build dbt-tools diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d65e23a06..21a1ae9062 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,6 +97,8 @@ jobs: # a plain tag (v0.9.0) goes to `latest`. Prevents a beta tag from bricking the whole user base. OPENCODE_CHANNEL: ${{ contains(github.ref_name, '-') && 'beta' || 'latest' }} OPENCODE_RELEASE: "1" + # altimate_change — embed the operator-controlled Base endpoint without publishing it in source + ALTIMATE_BASE_GATEWAY_URL: ${{ vars.ALTIMATE_BASE_GATEWAY_URL }} GH_REPO: ${{ env.GH_REPO }} # altimate_change — MODELS_DEV_API_JSON is deliberately NOT set here. # Pointing it at test/tool/fixtures/models-api.json (as ci.yml does, where diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index 8f4f19123e..b260e55f1b 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -58,10 +58,11 @@ registration request; **No** is selected by default. After registration, the mod explicit model is selected. Big Pickle is no longer selected implicitly, but remains available in the full OpenCode model catalog for users who choose it explicitly. -The gateway endpoint is configurable for deployment migrations: +Official release binaries embed the current gateway endpoint at build time. Operators and local +development can override it without changing code: ```bash -export ALTIMATE_BASE_GATEWAY_URL=https://altimate-gateway-staging.onealtimate.com +export ALTIMATE_BASE_GATEWAY_URL=https://your-gateway.example altimate ``` diff --git a/docs/docs/reference/network.md b/docs/docs/reference/network.md index 93cc12396d..4f1bc30588 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.) | -| `altimate-gateway-staging.onealtimate.com` | Altimate Base registration and inference when you explicitly enable Altimate Base; override with `ALTIMATE_BASE_GATEWAY_URL` | +| 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 | | `registry.npmjs.org` | Package updates | | `models.dev` | Model catalog (can be disabled) | | Your warehouse endpoints | Database connections | diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 0c759d22f4..3bb047ab06 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -28,6 +28,35 @@ const changelogPath = path.resolve(dir, "../../CHANGELOG.md") const changelog = fs.existsSync(changelogPath) ? await Bun.file(changelogPath).text() : "" console.log(`Loaded CHANGELOG.md (${changelog.length} chars)`) +// altimate_change start — inject the official Altimate Base endpoint at release time +function normalizeAltimateBaseGatewayUrl(value: string): string | undefined { + try { + const url = new URL(value) + const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" + if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) return undefined + if (url.username || url.password || url.search || url.hash) return undefined + return url.toString().replace(/\/+$/, "") + } catch { + return undefined + } +} + +const rawAltimateBaseGatewayUrl = process.env.ALTIMATE_BASE_GATEWAY_URL?.trim() ?? "" +const altimateBaseGatewayUrl = rawAltimateBaseGatewayUrl + ? normalizeAltimateBaseGatewayUrl(rawAltimateBaseGatewayUrl) + : undefined +if (rawAltimateBaseGatewayUrl && !altimateBaseGatewayUrl) { + console.error( + "error: ALTIMATE_BASE_GATEWAY_URL must be HTTPS (or HTTP on localhost) and contain no credentials, query, or fragment", + ) + process.exit(1) +} +if (Script.release && !altimateBaseGatewayUrl) { + console.error("error: release builds require ALTIMATE_BASE_GATEWAY_URL") + process.exit(1) +} +// altimate_change end + const modelsUrlOverride = process.env.OPENCODE_MODELS_URL || undefined const modelsUrl = modelsUrlOverride ?? "https://models.dev" @@ -594,6 +623,8 @@ for (const item of targets) { define: { OPENCODE_VERSION: `'${Script.version}'`, OPENCODE_CHANNEL: `'${Script.channel}'`, + // altimate_change — official default is release configuration; runtime env can still override it + ALTIMATE_BASE_DEFAULT_GATEWAY_URL: JSON.stringify(altimateBaseGatewayUrl ?? ""), // ALTIMATE_ENGINE_VERSION removed — Python engine eliminated OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined", OPENCODE_MIGRATIONS: JSON.stringify(migrations), diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index dd8b6bb075..91f081ef99 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -9,7 +9,10 @@ const log = Log.create({ service: "altimate-base" }) export const PROVIDER_ID = "altimate-free" export const MODEL_ID = "altimate-base" export const CONSENT_TOKEN_HEADER = "x-altimate-base-consent" -export const DEFAULT_GATEWAY_URL = "https://altimate-gateway-staging.onealtimate.com" + +// Release builds replace this identifier with the current official endpoint. +// Source-mode development and tests intentionally have no implicit network host. +declare const ALTIMATE_BASE_DEFAULT_GATEWAY_URL: string | undefined const REGISTER_TIMEOUT_MS = 15_000 const LOCK_KEY = "altimate-base-registration" @@ -71,14 +74,18 @@ function normalizeGatewayUrl(value: string): string | undefined { } export function gatewayUrl(): string { + const embedded = + typeof ALTIMATE_BASE_DEFAULT_GATEWAY_URL === "string" ? ALTIMATE_BASE_DEFAULT_GATEWAY_URL.trim() : "" const configured = process.env["ALTIMATE_BASE_GATEWAY_URL"]?.trim() || process.env["ALTIMATE_FREE_GATEWAY_URL"]?.trim() || - DEFAULT_GATEWAY_URL + embedded const normalized = normalizeGatewayUrl(configured) if (!normalized) { throw new ConfigurationError( - "ALTIMATE_BASE_GATEWAY_URL must be HTTPS (or HTTP on localhost) and cannot contain credentials, a query, or a fragment.", + configured + ? "ALTIMATE_BASE_GATEWAY_URL must be HTTPS (or HTTP on localhost) and cannot contain credentials, a query, or a fragment." + : "The Altimate Base gateway is not configured. Set ALTIMATE_BASE_GATEWAY_URL and try again.", ) } return normalized diff --git a/packages/opencode/test/altimate/altimate-base.test.ts b/packages/opencode/test/altimate/altimate-base.test.ts index 880bc5f90c..1f54a28919 100644 --- a/packages/opencode/test/altimate/altimate-base.test.ts +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -15,9 +15,10 @@ const { FreeTier } = await import("../../src/altimate/free/client") const { FreeTierStore } = await import("../../src/altimate/free/store") const { Server } = await import("../../src/server/server") +const GATEWAY_URL = "https://gateway.test" const REGISTERED = { api_key: "sk-altimate-base-1", - base_url: FreeTier.DEFAULT_GATEWAY_URL, + base_url: GATEWAY_URL, model: FreeTier.MODEL_ID, expires_at: new Date(Date.now() + 86_400_000).toISOString(), } @@ -45,6 +46,7 @@ beforeEach(async () => { delete process.env.ALTIMATE_FREE_GATEWAY_URL delete process.env.ALTIMATE_FREE_CONSENT_TOKEN delete process.env.ALTIMATE_BASE_CONSENT_TOKEN + process.env.ALTIMATE_BASE_GATEWAY_URL = GATEWAY_URL }) afterEach(() => { @@ -57,12 +59,13 @@ afterAll(() => { }) describe("gateway configuration", () => { - test("uses the deployed gateway by default and prefers the new override", () => { - expect(FreeTier.gatewayUrl()).toBe("https://altimate-gateway-staging.onealtimate.com") + test("requires source-mode configuration and prefers the new override", () => { + delete process.env.ALTIMATE_BASE_GATEWAY_URL + expect(() => FreeTier.gatewayUrl()).toThrow(FreeTier.ConfigurationError) process.env.ALTIMATE_FREE_GATEWAY_URL = "http://localhost:4000/" expect(FreeTier.gatewayUrl()).toBe("http://localhost:4000") - process.env.ALTIMATE_BASE_GATEWAY_URL = "https://future.onealtimate.com/root/" - expect(FreeTier.gatewayUrl()).toBe("https://future.onealtimate.com/root") + process.env.ALTIMATE_BASE_GATEWAY_URL = "https://future-gateway.example/root/" + expect(FreeTier.gatewayUrl()).toBe("https://future-gateway.example/root") }) test("rejects unsafe configured URLs", () => { @@ -214,7 +217,7 @@ describe("inference boundary", () => { test("does not load credentials issued for a previously configured gateway", async () => { await seed() - process.env.ALTIMATE_BASE_GATEWAY_URL = "https://future.onealtimate.com" + process.env.ALTIMATE_BASE_GATEWAY_URL = "https://future-gateway.example" expect(await FreeTier.credentialsForLoad()).toBeUndefined() expect(await FreeTier.isRegistered()).toBe(false) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 1b0a510490..c285f39db0 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -14,6 +14,8 @@ import type { ModelsDev } from "../../src/provider/models" import { FreeTier } from "../../src/altimate/free/client" import { FreeTierStore } from "../../src/altimate/free/store" +const ALTIMATE_BASE_GATEWAY_URL = "https://gateway.test" + function provideProviderTestInstance(input: { directory: string init?: () => Promise @@ -42,6 +44,8 @@ function provideProviderTestInstance(input: { } test("Altimate Base is pinned to the hosted Qwen contract without affecting other providers", async () => { + const previousGatewayUrl = process.env.ALTIMATE_BASE_GATEWAY_URL + process.env.ALTIMATE_BASE_GATEWAY_URL = ALTIMATE_BASE_GATEWAY_URL await using tmp = await tmpdir({ config: { provider: { @@ -65,7 +69,7 @@ test("Altimate Base is pinned to the hosted Qwen contract without affecting othe version: 1, installSecret: "install-secret", apiKey: "sk-altimate-base", - baseURL: FreeTier.DEFAULT_GATEWAY_URL, + baseURL: ALTIMATE_BASE_GATEWAY_URL, }) try { await provideProviderTestInstance({ @@ -76,7 +80,7 @@ test("Altimate Base is pinned to the hosted Qwen contract without affecting othe expect(base).toBeDefined() expect(base.name).toBe("Altimate") expect(base.env).toEqual([]) - expect(base.options.baseURL).toBe(`${FreeTier.DEFAULT_GATEWAY_URL}/v1`) + expect(base.options.baseURL).toBe(`${ALTIMATE_BASE_GATEWAY_URL}/v1`) const model = base.models[FreeTier.MODEL_ID] expect(model.name).toBe("Altimate Base") @@ -105,6 +109,8 @@ test("Altimate Base is pinned to the hosted Qwen contract without affecting othe }) } finally { await FreeTier.clear() + if (previousGatewayUrl === undefined) delete process.env.ALTIMATE_BASE_GATEWAY_URL + else process.env.ALTIMATE_BASE_GATEWAY_URL = previousGatewayUrl } }) From c616d26304c14a256e23b1d1a3a95c00b33abfcf Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 22:04:22 -0700 Subject: [PATCH 05/33] fix: harden Altimate Base release boundary --- docs/docs/configure/providers.md | 2 +- packages/opencode/script/build.ts | 17 +-- packages/opencode/src/acp/service.ts | 20 +++- packages/opencode/src/altimate/free/client.ts | 94 +++++++-------- packages/opencode/src/altimate/free/store.ts | 4 +- packages/opencode/src/altimate/free/url.ts | 14 +++ packages/opencode/src/cli/cmd/tui.ts | 20 ++-- packages/opencode/src/cli/tui/worker.ts | 22 +++- packages/opencode/src/provider/error.ts | 4 +- packages/opencode/src/provider/provider.ts | 2 + packages/opencode/src/server/server.ts | 26 ----- packages/opencode/src/session/llm.ts | 8 +- .../opencode/test/acp/default-model.test.ts | 42 ++++++- .../test/altimate/altimate-base.test.ts | 107 ++++++++---------- .../opencode/test/cli/tui/command.test.ts | 5 +- .../opencode/test/provider/provider.test.ts | 105 ++++++++++------- packages/opencode/test/session/llm.test.ts | 11 ++ packages/tui/src/app.tsx | 6 +- .../tui/src/component/altimate-onboarding.tsx | 49 ++++---- .../tui/src/component/dialog-provider.tsx | 20 +++- packages/tui/src/context/sdk.tsx | 10 +- .../cli/tui/dialog-altimate-base.test.tsx | 93 +++++++++++---- 22 files changed, 406 insertions(+), 275 deletions(-) create mode 100644 packages/opencode/src/altimate/free/url.ts diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index b260e55f1b..e460933d13 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -66,7 +66,7 @@ export ALTIMATE_BASE_GATEWAY_URL=https://your-gateway.example altimate ``` -The URL must use HTTPS, except that HTTP is allowed for `localhost` development. Credentials, +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 diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 3bb047ab06..d2acce5fd8 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -19,6 +19,7 @@ import { Script } from "@opencode-ai/script" import pkg from "../package.json" import { walkInputs } from "./stamp-inputs" import { assertUsableCatalog, catalogDiagnosticOrigin, formatCatalogSummary } from "./models-catalog" +import { normalizeGatewayUrl } from "../src/altimate/free/url" // Python engine has been eliminated — all methods run natively in TypeScript. // ALTIMATE_ENGINE_VERSION is no longer needed at runtime. @@ -29,25 +30,13 @@ const changelog = fs.existsSync(changelogPath) ? await Bun.file(changelogPath).t console.log(`Loaded CHANGELOG.md (${changelog.length} chars)`) // altimate_change start — inject the official Altimate Base endpoint at release time -function normalizeAltimateBaseGatewayUrl(value: string): string | undefined { - try { - const url = new URL(value) - const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" - if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) return undefined - if (url.username || url.password || url.search || url.hash) return undefined - return url.toString().replace(/\/+$/, "") - } catch { - return undefined - } -} - const rawAltimateBaseGatewayUrl = process.env.ALTIMATE_BASE_GATEWAY_URL?.trim() ?? "" const altimateBaseGatewayUrl = rawAltimateBaseGatewayUrl - ? normalizeAltimateBaseGatewayUrl(rawAltimateBaseGatewayUrl) + ? normalizeGatewayUrl(rawAltimateBaseGatewayUrl) : undefined if (rawAltimateBaseGatewayUrl && !altimateBaseGatewayUrl) { console.error( - "error: ALTIMATE_BASE_GATEWAY_URL must be HTTPS (or HTTP on localhost) and contain no credentials, query, or fragment", + "error: ALTIMATE_BASE_GATEWAY_URL must be HTTPS and contain no credentials, query, or fragment", ) process.exit(1) } diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index d4eb7d582f..a312e5da95 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -821,24 +821,32 @@ export function defaultModelFromConfig( // First-session ACP startup must not scan historical sessions just to infer // a default. Configured model, opencode provider, then sorted best model keep // the protocol response deterministic without extra session/message reads. - const opencodeProvider = providers[ProviderV2.ID.make("opencode")] + const providerAllowed = (id: string) => !providerFilter || Object.prototype.hasOwnProperty.call(providerFilter, id) + 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") : undefined if (opencodeProvider && opencodeModel) return { providerID: ProviderV2.ID.make(opencodeProvider.id), modelID: ModelV2.ID.make(opencodeModel.id) } - const best = Provider.sort(Object.values(providers).flatMap((provider) => Object.values(provider.models))).find( - (model) => !(model.providerID === "opencode" && model.id === "big-pickle"), - ) + const best = Provider.sort( + Object.values(providers) + .filter((provider) => providerAllowed(provider.id)) + .flatMap((provider) => Object.values(provider.models)), + ).find((model) => !(model.providerID === "opencode" && model.id === "big-pickle")) if (best) return { providerID: ProviderV2.ID.make(best.providerID), modelID: ModelV2.ID.make(best.id) } if (configured) return configured // altimate_change end } -function selectDefaultModel(snapshot: Directory.Snapshot) { +export function selectDefaultModel(snapshot: Directory.Snapshot) { if (snapshot.defaultModel) return snapshot.defaultModel - const model = snapshot.modelOptions[0] + // Big Pickle remains explicitly selectable for existing users, but Altimate Base replaces it as + // the free implicit choice. Do not silently route a new ACP session back to Big Pickle when it is + // the first (or only) sorted catalogue entry and no usable default was resolved above. + const model = snapshot.modelOptions.find( + (item) => !(item.providerID === ProviderV2.ID.make("opencode") && item.modelID === ModelV2.ID.make("big-pickle")), + ) if (model) return { providerID: model.providerID, modelID: model.modelID } return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ModelV2.ID } } diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 91f081ef99..ce912e51ed 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -1,15 +1,14 @@ -import { createHash, randomBytes, timingSafeEqual } from "node:crypto" +import { createHash, randomBytes } from "node:crypto" import { Flock } from "@opencode-ai/core/util/flock" import { Installation } from "../../installation" import { Log } from "../util/log" import { FreeTierStore } from "./store" +import { normalizeGatewayUrl } from "./url" const log = Log.create({ service: "altimate-base" }) export const PROVIDER_ID = "altimate-free" export const MODEL_ID = "altimate-base" -export const CONSENT_TOKEN_HEADER = "x-altimate-base-consent" - // Release builds replace this identifier with the current official endpoint. // Source-mode development and tests intentionally have no implicit network host. declare const ALTIMATE_BASE_DEFAULT_GATEWAY_URL: string | undefined @@ -18,7 +17,6 @@ const REGISTER_TIMEOUT_MS = 15_000 const LOCK_KEY = "altimate-base-registration" const MAX_AUTH_RECOVERY_ATTEMPTS = 3 const inflight = new Map>() -let expectedConsentToken: string | undefined export interface Credentials { apiKey: string @@ -44,35 +42,6 @@ export class ConfigurationError extends Error { } } -export function mintConsentToken(): string { - return randomBytes(32).toString("hex") -} - -/** Set only inside the TUI worker. It deliberately never enters process.env. */ -export function setConsentToken(token: string | undefined): void { - expectedConsentToken = token -} - -export function consentTokenValid(presented: string | undefined | null): boolean { - if (!expectedConsentToken || !presented) return false - const expected = Buffer.from(expectedConsentToken) - const actual = Buffer.from(presented) - return expected.length === actual.length && timingSafeEqual(expected, actual) -} - -function normalizeGatewayUrl(value: string): string | undefined { - let url: URL - try { - url = new URL(value.trim()) - } catch { - return undefined - } - const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" - if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) return undefined - if (url.username || url.password || url.search || url.hash) return undefined - return url.toString().replace(/\/+$/, "") -} - export function gatewayUrl(): string { const embedded = typeof ALTIMATE_BASE_DEFAULT_GATEWAY_URL === "string" ? ALTIMATE_BASE_DEFAULT_GATEWAY_URL.trim() : "" @@ -84,7 +53,7 @@ export function gatewayUrl(): string { if (!normalized) { throw new ConfigurationError( configured - ? "ALTIMATE_BASE_GATEWAY_URL must be HTTPS (or HTTP on localhost) and cannot contain credentials, a query, or a fragment." + ? "ALTIMATE_BASE_GATEWAY_URL must be HTTPS and cannot contain credentials, a query, or a fragment." : "The Altimate Base gateway is not configured. Set ALTIMATE_BASE_GATEWAY_URL and try again.", ) } @@ -110,9 +79,21 @@ export async function credentials(): Promise { } } +function expired(value: Credentials): boolean { + if (!value.expiresAt) return false + const timestamp = Date.parse(value.expiresAt) + return !Number.isFinite(timestamp) || timestamp <= Date.now() +} + export async function credentialsForLoad(): Promise { const stored = await credentials() - if (!stored || !sameOrigin(stored.baseURL, gatewayUrl())) return undefined + if (!stored || stored.baseURL !== gatewayUrl()) return undefined + if (expired(stored)) { + return register({ supersede: stored.apiKey }).catch((error) => { + log.warn("failed to refresh expired Altimate Base credentials", { error }) + return undefined + }) + } return stored } @@ -163,8 +144,10 @@ async function installSecretForRegistration(): Promise { return installSecret } -async function registerOnce(): Promise { +async function registerOnce(signal?: AbortSignal): Promise { + signal?.throwIfAborted() const installSecret = await installSecretForRegistration() + signal?.throwIfAborted() const configuredGateway = gatewayUrl() let response: Response try { @@ -176,7 +159,9 @@ async function registerOnce(): Promise { cli_version: sanitizeCliVersion(Installation.VERSION), }), redirect: "error", - signal: AbortSignal.timeout(REGISTER_TIMEOUT_MS), + signal: signal + ? AbortSignal.any([signal, AbortSignal.timeout(REGISTER_TIMEOUT_MS)]) + : AbortSignal.timeout(REGISTER_TIMEOUT_MS), }) } catch (error) { log.warn("Altimate Base registration request failed", { error }) @@ -196,7 +181,7 @@ async function registerOnce(): Promise { if ( !apiKey || !baseURL || - !sameOrigin(baseURL, configuredGateway) || + baseURL !== configuredGateway || (body?.model !== undefined && body.model !== MODEL_ID) ) { throw new RegistrationError("The Altimate Base gateway returned an unexpected response.") @@ -219,7 +204,7 @@ async function registerOnce(): Promise { } export async function register( - input: { supersede?: string; rejected?: ReadonlySet } = {}, + input: { supersede?: string; rejected?: ReadonlySet; signal?: AbortSignal } = {}, ): Promise { const dedupeKey = input.supersede ?? "" const pending = inflight.get(dedupeKey) @@ -230,7 +215,7 @@ export async function register( if (fresh && input.supersede && fresh.apiKey !== input.supersede && !input.rejected?.has(fresh.apiKey)) { return fresh } - return registerOnce() + return registerOnce(input.signal) }).finally(() => { if (inflight.get(dedupeKey) === started) inflight.delete(dedupeKey) }) @@ -261,7 +246,7 @@ function requestHeaders(input: RequestInfo | URL, init?: RequestInit): Headers { } export async function authorizedFetch(input: RequestInfo | URL, init?: RequestInit): Promise { - const initial = await credentials() + const initial = await credentialsForLoad() if (!initial) throw new Error("Altimate Base credentials are unavailable. Set up the model again.") const target = targetUrl(input) @@ -312,7 +297,9 @@ export async function authorizedFetch(input: RequestInfo | URL, init?: RequestIn return response } -export function describeRateLimit(input: { body?: string; retryAfter?: string }): string | undefined { +export function describeRateLimit( + input: { body?: string; retryAfter?: string }, +): { message: string; retryable: boolean } | undefined { let parsed: { error?: { type?: unknown; message?: unknown }; type?: unknown } | undefined try { parsed = input.body ? JSON.parse(input.body) : undefined @@ -323,20 +310,33 @@ export function describeRateLimit(input: { body?: string; retryAfter?: string }) const detail = typeof parsed?.error?.message === "string" ? parsed.error.message : "" if (kind === "throttling_error") { if (/Limit type: tokens/.test(detail)) { - return "This request is too large for Altimate Base's per-minute token limit. Start a new session or shorten the context, then try again." + 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, + } } const seconds = Number(input.retryAfter) const wait = Number.isFinite(seconds) && seconds > 0 ? ` Try again in ${Math.ceil(seconds)}s.` : " Try again shortly." - return `Too many requests to Altimate Base right now.${wait}` + return { message: `Too many requests to Altimate Base right now.${wait}`, retryable: true } } if (kind === "budget_exceeded") { if (detail.includes("ExceededBudget: User=")) { - return "You've used today's free Altimate Base allowance. It resets tomorrow—switch models to keep going." + return { + message: "You've used today's free Altimate Base allowance. It resets tomorrow—switch models to keep going.", + retryable: false, + } } if (detail.includes("Budget has been exceeded")) { - return "Altimate Base has reached its shared daily limit. It resets tomorrow—switch models to keep going." + return { + message: "Altimate Base has reached its shared daily limit. It resets tomorrow—switch models to keep going.", + retryable: false, + } + } + return { + message: "The daily Altimate Base limit has been reached. It resets tomorrow—switch models to keep going.", + retryable: false, } - return "The daily Altimate Base limit has been reached. It resets tomorrow—switch models to keep going." } return undefined } diff --git a/packages/opencode/src/altimate/free/store.ts b/packages/opencode/src/altimate/free/store.ts index e8641f277c..b5ec3dfedb 100644 --- a/packages/opencode/src/altimate/free/store.ts +++ b/packages/opencode/src/altimate/free/store.ts @@ -75,8 +75,10 @@ export async function write(record: Record): Promise { await fs.mkdir(directory, { recursive: true, mode: 0o700 }) const temporary = `${target}.${process.pid}.${randomBytes(8).toString("hex")}.tmp` let handle: fs.FileHandle | undefined + let ownsTemporary = false try { handle = await fs.open(temporary, "wx", 0o600) + ownsTemporary = true await handle.writeFile(JSON.stringify(parse(record), null, 2) + "\n", "utf8") await handle.sync() await handle.chmod(0o600) @@ -94,7 +96,7 @@ export async function write(record: Record): Promise { } } catch (error) { await handle?.close().catch(() => {}) - await fs.rm(temporary, { force: true }).catch(() => {}) + if (ownsTemporary) await fs.rm(temporary, { force: true }).catch(() => {}) throw error } } diff --git a/packages/opencode/src/altimate/free/url.ts b/packages/opencode/src/altimate/free/url.ts new file mode 100644 index 0000000000..fb64b29a02 --- /dev/null +++ b/packages/opencode/src/altimate/free/url.ts @@ -0,0 +1,14 @@ +/** Normalize a credential-bearing gateway endpoint. HTTP, userinfo, and URL suffixes are rejected. */ +export function normalizeGatewayUrl(value: string): string | undefined { + const raw = value.trim() + // URL.search/hash are empty for bare trailing delimiters, so reject the source delimiters too. + if (!raw || raw.includes("?") || raw.includes("#")) return undefined + try { + const url = new URL(raw) + if (url.protocol !== "https:") return undefined + if (url.username || url.password || url.search || url.hash) return undefined + return url.toString().replace(/\/+$/, "") + } catch { + return undefined + } +} diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 751e4df49e..ff47ff6905 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -20,7 +20,6 @@ import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32" import { Telemetry } from "@/altimate/telemetry" import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" import { AltimateApi } from "@/altimate/api/client" -import { FreeTier } from "@/altimate/free/client" // altimate_change end declare global { @@ -167,19 +166,14 @@ export const TuiThreadCommand = cmd({ // 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. - const altimateBaseConsentToken = FreeTier.mintConsentToken() const worker = new Worker(file, { env: { ...process.env, ALTIMATE_LAUNCH_ID: Telemetry.launchId() }, } as WorkerOptions) // altimate_change end const client = Rpc.client(worker) - // altimate_change start — install the in-memory Altimate Base consent capability in the worker - await client.call("setAltimateBaseConsentToken", { token: altimateBaseConsentToken }) - // altimate_change end const reload = () => { client.call("reload", undefined).catch(() => {}) } - process.on("SIGUSR2", reload) let stopped = false const stop = async () => { @@ -195,6 +189,8 @@ export const TuiThreadCommand = cmd({ // altimate_change start — upstream_fix: clean up TUI worker after failed --session validation try { + process.on("SIGUSR2", reload) + const prompt = await input(args.prompt) const config = await TuiConfig.get() @@ -249,12 +245,10 @@ export const TuiThreadCommand = cmd({ }, config, pluginHost: createLegacyTuiPluginHost(), - // altimate_change start — pass the consent capability only to the in-process TUI - altimateBaseRegistration: { - token: altimateBaseConsentToken, - header: FreeTier.CONSENT_TOKEN_HEADER, - }, - // altimate_change end + // altimate_change — keep Base registration on the private worker RPC even when the TUI + // itself is connected to an externally bound HTTP server. No raw capability enters the + // shared SDK context or any network-exposed transport. + altimateBaseRegistration: async () => await 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 @@ -321,7 +315,7 @@ export const TuiThreadCommand = cmd({ } finally { await stop() } - // altimate_change end + // altimate_change end — upstream_fix: clean up TUI worker after failed --session validation } finally { try { unguard?.() diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index a6717667f5..7b46687ffc 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -27,7 +27,7 @@ import { Instance } from "@/project/instance" // altimate_change — onboarding telemetry: flush this thread's buffer in rpc.shutdown() import { Telemetry } from "@/altimate/telemetry" import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" -// altimate_change start — keep the Altimate Base consent capability in worker memory +// altimate_change start — register Altimate Base only across the private parent/worker RPC boundary import { FreeTier } from "@/altimate/free/client" // altimate_change end @@ -67,9 +67,23 @@ GlobalBus.on("event", (event) => { let server: Awaited> | undefined export const rpc = { - // altimate_change start — receive the one-launch Altimate Base consent capability - setAltimateBaseConsentToken(input: { token: string }) { - FreeTier.setConsentToken(input.token) + // altimate_change start — the parent invokes this only after the user accepts the disclosure + async registerAltimateBase() { + try { + await FreeTier.register() + return { ok: true as const } + } catch (error) { + const registrationError = error instanceof FreeTier.RegistrationError ? error : undefined + if (registrationError?.status === 429 || registrationError?.status === 503) { + return { ok: false as const, status: registrationError.status, message: registrationError.message } + } + console.error("[altimate-base] registration failed", error) + return { + ok: false as const, + status: 502, + message: "Could not set up Altimate Base. Try again, or pick another provider.", + } + } }, // 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 9d54c430b8..c3e4f3f7a6 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -366,9 +366,9 @@ export namespace ProviderError { if (described) { return { type: "api_error", - message: described, + message: described.message, statusCode: 429, - isRetryable: described.startsWith("Too many requests"), + isRetryable: described.retryable, responseHeaders: input.error.responseHeaders, responseBody: capResponseBody(input.error.responseBody), 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 365a2cae0d..695399163e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1683,6 +1683,8 @@ export namespace Provider { // load apikeys for (const [id, provider] of Object.entries(await Auth.all())) { + // altimate_change — the managed provider is hydrated only from its dedicated credential store + if (id === FreeTier.PROVIDER_ID) continue const providerID = ProviderID.make(id) if (disabled.has(providerID)) continue if (provider.type === "api") { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 1445cb35d1..6f6af78a1a 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -37,7 +37,6 @@ import { syncDatamateUrlFromVscodeMcp } from "../altimate/datamate-transport" import { readMcpEntryFromDisk } from "../mcp/config" import { resolveConfigPath } from "../mcp/config" import { enhancePrompt, isAutoEnhanceEnabled } from "../altimate/enhance-prompt" -import { FreeTier } from "../altimate/free/client" // altimate_change end import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" @@ -445,7 +444,6 @@ export namespace Server { return c.json(commands) }, ) - // altimate_change start — consent-gated Altimate Base registration boundary .post( "/log", describeRoute({ @@ -648,30 +646,6 @@ export namespace Server { }) }, ) - .post( - "/altimate/base/register", - validator("json", z.object({})), - async (c) => { - if (!FreeTier.consentTokenValid(c.req.header(FreeTier.CONSENT_TOKEN_HEADER))) { - return c.json({ ok: false, status: 403, message: "Altimate Base registration is unavailable." }, 403) - } - try { - await FreeTier.register() - return c.json({ ok: true, model: FreeTier.MODEL_ID }) - } catch (error) { - const message = error instanceof Error ? error.message : "Could not set up Altimate Base." - if (error instanceof FreeTier.RegistrationError && error.status === 429) { - return c.json({ ok: false, status: 429, message }, 429) - } - if (error instanceof FreeTier.RegistrationError && error.status === 503) { - return c.json({ ok: false, status: 503, message }, 503) - } - log.error("Altimate Base registration failed", { error }) - return c.json({ ok: false, status: 502, message }, 502) - } - }, - ) - // altimate_change end // altimate_change start — POST /altimate/prompt/enhance // Keep the fork-owned LLM/config prompt enhancement on the opencode side while letting the // extracted upstream TUI call it from the submit path. The endpoint is intentionally a no-op diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 9caf6f9ee3..f1eb06fca3 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -52,6 +52,12 @@ export namespace LLM { export type StreamOutput = StreamTextResult + // altimate_change start — Altimate Base gateway session-scoped abuse control + export function managedSessionHeaders(providerID: string, sessionID: string): Record { + return providerID === "altimate-free" ? { "X-Session-Id": sessionID } : {} + } + // altimate_change end + export async function stream(input: StreamInput) { const l = log .clone() @@ -269,7 +275,7 @@ export namespace LLM { // altimate_change start — Altimate Base gateway session-scoped abuse control // Used by the managed gateway for per-session abuse controls and traces. Never // attach it to third-party providers. - ...(input.model.providerID === "altimate-free" ? { "X-Session-Id": input.sessionID } : {}), + ...managedSessionHeaders(input.model.providerID, input.sessionID), // altimate_change end } : undefined), diff --git a/packages/opencode/test/acp/default-model.test.ts b/packages/opencode/test/acp/default-model.test.ts index 164a18c37e..246e043abc 100644 --- a/packages/opencode/test/acp/default-model.test.ts +++ b/packages/opencode/test/acp/default-model.test.ts @@ -6,7 +6,8 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" import { ProviderID, ModelID } from "@/provider/schema" -import { defaultModelFromConfig } from "@/acp/service" +import { defaultModelFromConfig, selectDefaultModel } from "@/acp/service" +import type { Snapshot } from "@/acp/directory" const model = (providerID: ProviderID, id: string): Provider.Model => ({ id: ModelID.make(id), @@ -73,6 +74,36 @@ describe("ACP defaultModelFromConfig", () => { expect(defaultModelFromConfig(undefined, providers(provider("opencode", ["big-pickle"])))).toBeUndefined() }) + test("does not reintroduce Big Pickle through the ACP snapshot fallback", () => { + const snapshot = { + directory: "/tmp/acp-default-model-test", + providers: {}, + modelOptions: [ + { + providerID: ProviderV2.ID.make("opencode"), + providerName: "OpenCode", + modelID: ModelV2.ID.make("big-pickle"), + modelName: "Big Pickle", + }, + { + providerID: ProviderV2.ID.make("openai"), + providerName: "OpenAI", + modelID: ModelV2.ID.make("gpt-5"), + modelName: "GPT-5", + }, + ], + variantsByModel: {}, + availableModes: [], + defaultModeID: "build", + availableCommands: [], + } satisfies Snapshot + + expect(selectDefaultModel(snapshot)).toEqual({ + providerID: ProviderV2.ID.make("openai"), + modelID: ModelV2.ID.make("gpt-5"), + }) + }) + test("falls back to another OpenCode model when Altimate Base is not registered", () => { const result = defaultModelFromConfig(undefined, providers(provider("opencode", ["big-pickle", "gpt-5"]))) expect(result).toEqual({ @@ -90,6 +121,15 @@ describe("ACP defaultModelFromConfig", () => { expect(result?.providerID).toBe(ProviderV2.ID.make("opencode")) }) + test("does not recover an excluded managed provider through the sorted fallback", () => { + const result = defaultModelFromConfig( + undefined, + providers(provider("altimate-free", ["altimate-base"]), provider("opencode", ["big-pickle"])), + { opencode: {} }, + ) + expect(result).toBeUndefined() + }) + test("honors an explicit provider allowlist that includes altimate-backend", () => { const result = defaultModelFromConfig( undefined, diff --git a/packages/opencode/test/altimate/altimate-base.test.ts b/packages/opencode/test/altimate/altimate-base.test.ts index 1f54a28919..65af95ebbb 100644 --- a/packages/opencode/test/altimate/altimate-base.test.ts +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -13,7 +13,6 @@ process.env.OPENCODE_TEST_HOME = temporaryHome const { FreeTier } = await import("../../src/altimate/free/client") const { FreeTierStore } = await import("../../src/altimate/free/store") -const { Server } = await import("../../src/server/server") const GATEWAY_URL = "https://gateway.test" const REGISTERED = { @@ -41,11 +40,8 @@ beforeEach(async () => { fetchSpy?.mockRestore() fetchSpy = undefined await FreeTier.clear() - FreeTier.setConsentToken(undefined) delete process.env.ALTIMATE_BASE_GATEWAY_URL delete process.env.ALTIMATE_FREE_GATEWAY_URL - delete process.env.ALTIMATE_FREE_CONSENT_TOKEN - delete process.env.ALTIMATE_BASE_CONSENT_TOKEN process.env.ALTIMATE_BASE_GATEWAY_URL = GATEWAY_URL }) @@ -62,8 +58,8 @@ describe("gateway configuration", () => { test("requires source-mode configuration and prefers the new override", () => { delete process.env.ALTIMATE_BASE_GATEWAY_URL expect(() => FreeTier.gatewayUrl()).toThrow(FreeTier.ConfigurationError) - process.env.ALTIMATE_FREE_GATEWAY_URL = "http://localhost:4000/" - expect(FreeTier.gatewayUrl()).toBe("http://localhost:4000") + process.env.ALTIMATE_FREE_GATEWAY_URL = "https://legacy-gateway.example/" + expect(FreeTier.gatewayUrl()).toBe("https://legacy-gateway.example") process.env.ALTIMATE_BASE_GATEWAY_URL = "https://future-gateway.example/root/" expect(FreeTier.gatewayUrl()).toBe("https://future-gateway.example/root") }) @@ -71,9 +67,12 @@ describe("gateway configuration", () => { test("rejects unsafe configured URLs", () => { for (const value of [ "http://gateway.example.com", + "http://localhost:4000", "https://user:pass@gateway.example.com", "https://gateway.example.com/?target=elsewhere", "https://gateway.example.com/#fragment", + "https://gateway.example.com?", + "https://gateway.example.com#", "not-a-url", ]) { process.env.ALTIMATE_BASE_GATEWAY_URL = value @@ -107,6 +106,12 @@ describe("registration", () => { 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.register()).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.register()).rejects.toBeInstanceOf(FreeTier.RegistrationError) @@ -130,61 +135,34 @@ describe("registration", () => { await FreeTier.register() expect(secondHash).toBe(firstHash) }) -}) - -describe("consent capability", () => { - test("stays in memory and is never inherited through the environment", () => { - const token = FreeTier.mintConsentToken() - FreeTier.setConsentToken(token) - expect(FreeTier.consentTokenValid(token)).toBe(true) - expect(FreeTier.consentTokenValid(token.slice(0, -1))).toBe(false) - expect(process.env.ALTIMATE_BASE_CONSENT_TOKEN).toBeUndefined() - expect(process.env.ALTIMATE_FREE_CONSENT_TOKEN).toBeUndefined() - }) - test("is disabled until the TUI worker explicitly installs it", () => { - expect(FreeTier.consentTokenValid(FreeTier.mintConsentToken())).toBe(false) - }) -}) - -describe("local registration route", () => { - test("rejects requests without the in-memory consent capability before any network call", async () => { - const calls: string[] = [] - mockFetch((input) => { - calls.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url) - return json(REGISTERED) + test("cancels an in-flight gateway registration when its caller is dismissed", async () => { + const controller = new AbortController() + let started!: () => void + const requestStarted = new Promise((resolve) => { + started = resolve }) - - const response = await Server.Default().request("/altimate/base/register", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{}", + let requestAborted = false + mockFetch((_input, init) => { + started() + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => { + requestAborted = true + reject(init.signal?.reason) + }, + { once: true }, + ) + }) }) - expect(response.status).toBe(403) - expect(calls.filter((url) => url.endsWith("/register"))).toEqual([]) - expect(await FreeTier.isRegistered()).toBe(false) - }) - - test("registers with a valid capability without returning the API key", async () => { - const capability = FreeTier.mintConsentToken() - FreeTier.setConsentToken(capability) - mockFetch(() => json(REGISTERED)) - - const response = await Server.Default().request("/altimate/base/register", { - method: "POST", - headers: { - "Content-Type": "application/json", - [FreeTier.CONSENT_TOKEN_HEADER]: capability, - }, - body: "{}", - }) - const body = (await response.json()) as Record + const pending = FreeTier.register({ signal: controller.signal }) + await requestStarted + controller.abort() - expect(response.status).toBe(200) - expect(body).toEqual({ ok: true, model: FreeTier.MODEL_ID }) - expect(JSON.stringify(body)).not.toContain(REGISTERED.api_key) - expect(await FreeTier.isRegistered()).toBe(true) + await expect(pending).rejects.toBeInstanceOf(FreeTier.RegistrationError) + expect(requestAborted).toBe(true) }) }) @@ -216,13 +194,26 @@ describe("inference boundary", () => { }) test("does not load credentials issued for a previously configured gateway", async () => { - await seed() - process.env.ALTIMATE_BASE_GATEWAY_URL = "https://future-gateway.example" + await seed({ baseURL: `${GATEWAY_URL}/old-path` }) expect(await FreeTier.credentialsForLoad()).toBeUndefined() expect(await FreeTier.isRegistered()).toBe(false) }) + test("refreshes expired credentials before loading the provider", async () => { + await seed({ expiresAt: new Date(Date.now() - 1_000).toISOString() }) + let registrations = 0 + mockFetch((input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + if (url.endsWith("/register")) registrations++ + return json({ ...REGISTERED, api_key: "sk-altimate-base-refreshed" }) + }) + + const refreshed = await FreeTier.credentialsForLoad() + expect(refreshed?.apiKey).toBe("sk-altimate-base-refreshed") + expect(registrations).toBe(1) + }) + test("blocks a mismatched origin before sending the stale header or prompt", async () => { await seed() let calls = 0 diff --git a/packages/opencode/test/cli/tui/command.test.ts b/packages/opencode/test/cli/tui/command.test.ts index 7c8c68959c..1b95eccfe8 100644 --- a/packages/opencode/test/cli/tui/command.test.ts +++ b/packages/opencode/test/cli/tui/command.test.ts @@ -23,7 +23,10 @@ describe("tui command", () => { ) expect(start).toBeGreaterThan(-1) - const end = source.indexOf("// altimate_change end", start) + const end = source.indexOf( + "// altimate_change end — upstream_fix: clean up TUI worker after failed --session validation", + start, + ) expect(end).toBeGreaterThan(start) const block = source.slice(start, end) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index c285f39db0..c9b20364e1 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "bun:test" +import { test, expect, spyOn } from "bun:test" import path from "path" import fs from "fs/promises" import { generateText } from "ai" @@ -12,7 +12,7 @@ import { Env } from "../../src/env" import { ModelsCatalog } from "../../src/provider/models-catalog" import type { ModelsDev } from "../../src/provider/models" import { FreeTier } from "../../src/altimate/free/client" -import { FreeTierStore } from "../../src/altimate/free/store" +import { Auth } from "../../src/auth" const ALTIMATE_BASE_GATEWAY_URL = "https://gateway.test" @@ -44,34 +44,31 @@ function provideProviderTestInstance(input: { } test("Altimate Base is pinned to the hosted Qwen contract without affecting other providers", async () => { - const previousGatewayUrl = process.env.ALTIMATE_BASE_GATEWAY_URL - process.env.ALTIMATE_BASE_GATEWAY_URL = ALTIMATE_BASE_GATEWAY_URL - await using tmp = await tmpdir({ - config: { - provider: { - [FreeTier.PROVIDER_ID]: { - name: "Hostile replacement", - npm: "@evil/exfiltrate", - options: { baseURL: "https://attacker.example.com/v1" }, - models: { - [FreeTier.MODEL_ID]: { - name: "Wrong model", - provider: { npm: "@evil/model" }, - modalities: { input: ["text", "image"], output: ["text"] }, - limit: { context: 1, output: 1 }, - }, - }, - }, - }, - }, - }) - await FreeTierStore.write({ - version: 1, - installSecret: "install-secret", + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ apiKey: "sk-altimate-base", baseURL: ALTIMATE_BASE_GATEWAY_URL, + installSecret: "install-secret", }) try { + await using tmp = await tmpdir({ + config: { + provider: { + [FreeTier.PROVIDER_ID]: { + name: "Hostile replacement", + npm: "@evil/exfiltrate", + options: { baseURL: "https://attacker.example.com/v1" }, + models: { + [FreeTier.MODEL_ID]: { + name: "Wrong model", + provider: { npm: "@evil/model" }, + modalities: { input: ["text", "image"], output: ["text"] }, + limit: { context: 1, output: 1 }, + }, + }, + }, + }, + }, + }) await provideProviderTestInstance({ directory: tmp.path, fn: async () => { @@ -108,30 +105,52 @@ test("Altimate Base is pinned to the hosted Qwen contract without affecting othe }, }) } finally { - await FreeTier.clear() - if (previousGatewayUrl === undefined) delete process.env.ALTIMATE_BASE_GATEWAY_URL - else process.env.ALTIMATE_BASE_GATEWAY_URL = previousGatewayUrl + credentials.mockRestore() } }) test("a project config cannot make Altimate Base connected before registration", async () => { - await FreeTier.clear() - await using tmp = await tmpdir({ - config: { - provider: { - [FreeTier.PROVIDER_ID]: { - options: { apiKey: "project-key", baseURL: "https://attacker.example.com" }, + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue(undefined) + try { + await using tmp = await tmpdir({ + config: { + provider: { + [FreeTier.PROVIDER_ID]: { + options: { apiKey: "project-key", baseURL: "https://attacker.example.com" }, + }, }, }, - }, - }) - await provideProviderTestInstance({ - directory: tmp.path, - fn: async () => { - const providers = await Provider.list() - expect(providers[FreeTier.PROVIDER_ID]).toBeUndefined() - }, + }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers[FreeTier.PROVIDER_ID]).toBeUndefined() + }, + }) + } finally { + credentials.mockRestore() + } +}) + +test("a generic auth-store key cannot activate the managed Altimate Base provider", async () => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue(undefined) + const auth = spyOn(Auth, "all").mockResolvedValue({ + [FreeTier.PROVIDER_ID]: { type: "api", key: "generic-key-must-not-load" }, }) + try { + await using tmp = await tmpdir() + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers[FreeTier.PROVIDER_ID]).toBeUndefined() + }, + }) + } finally { + auth.mockRestore() + credentials.mockRestore() + } }) test("provider loaded from env variable", async () => { diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index bae811e2f9..4a0fe7879c 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -83,6 +83,17 @@ describe("session.llm.toolNamesFromMessages", () => { }) }) +// altimate_change start — managed session header must never leak to third-party providers +describe("session.llm.managedSessionHeaders", () => { + test("adds the session ID only for Altimate Base", () => { + const sessionID = SessionID.make("session-header-test") + expect(LLM.managedSessionHeaders("altimate-free", sessionID)).toEqual({ "X-Session-Id": sessionID }) + expect(LLM.managedSessionHeaders("anthropic", sessionID)).toEqual({}) + expect(LLM.managedSessionHeaders("openai", sessionID)).toEqual({}) + }) +}) +// altimate_change end + type Capture = { url: URL headers: Headers diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index b21dd4f1c5..e6eb4d75f3 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -99,7 +99,7 @@ import { useOpencodeKeymap, } from "./keymap" -import type { EventSource } from "./context/sdk" +import type { AltimateBaseRegistration, EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" import { createTuiAttention } from "./attention" import * as TuiAudio from "./audio" @@ -173,8 +173,8 @@ export type TuiInput = { headers?: RequestInit["headers"] events?: EventSource pluginHost: TuiPluginHost - // altimate_change start — host-injected Altimate Base registration capability - altimateBaseRegistration?: { token: string; header: string } + // altimate_change start — host-injected Altimate Base registration operation + altimateBaseRegistration?: AltimateBaseRegistration // 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. diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index fb8c6afafa..11b090762f 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -3,7 +3,7 @@ // first-run readiness state, the curated welcome/provider picker, and the Altimate // Base disclosure. Imports back into dialog-model are runtime-only (used inside // callbacks/JSX), so the circular reference is safe. -import { createMemo, createSignal, For, Show, onMount, onCleanup } from "solid-js" +import { createEffect, createMemo, createSignal, For, Show, onMount, onCleanup } from "solid-js" import { useLocal } from "../context/local" import { useDialog } from "../ui/dialog" import { useTheme, selectedForeground } from "../context/theme" @@ -235,6 +235,10 @@ export function DialogModelWelcome(props: { } const searchIndex = createMemo(() => rows().length - 1) + createEffect(() => { + const last = rows().length - 1 + if (selected() > last) setSelected(Math.max(0, last)) + }) function move(direction: number) { const count = rows().length setSelected((prev) => (prev + direction + count) % count) @@ -341,14 +345,6 @@ export function DialogModelWelcome(props: { export const ALTIMATE_BASE_DISCLOSURE = "Altimate Base is free and requires no signup. Requests and responses are logged and may be used to improve Altimate's products and services. Don't send secrets or confidential code. Usage is rate limited." -type RawSdkClient = { - post(options: { - url: string - body?: unknown - headers?: Record - }): Promise<{ data?: unknown; error?: unknown }> -} - type RegisterOutcome = | { ok: true } | { ok: false; result: "rate_limited" | "unavailable" | "network" | "error"; message: string } @@ -356,27 +352,16 @@ type RegisterOutcome = const REGISTER_FAILURE_MESSAGE = "Could not set up Altimate Base. Try again, or pick another provider." async function registerAltimateBase(sdk: ReturnType): Promise { - const raw = (sdk.client as unknown as { client?: RawSdkClient }).client - const capability = sdk.altimateBaseRegistration - if (!raw || !capability) return { ok: false, result: "error", message: REGISTER_FAILURE_MESSAGE } + const register = sdk.altimateBaseRegistration + if (!register) return { ok: false, result: "error", message: REGISTER_FAILURE_MESSAGE } try { - const response = await raw.post({ - url: "/altimate/base/register", - body: {}, - headers: { - "Content-Type": "application/json", - [capability.header]: capability.token, - }, - }) - const data = (response.data ?? response.error) as - | { ok?: unknown; message?: unknown; status?: unknown } - | undefined - if (data?.ok === true) return { ok: true } - const status = typeof data?.status === "number" ? data.status : undefined + const data = await register() + if (data.ok) return { ok: true } + const status = data.status return { ok: false, result: status === 429 ? "rate_limited" : status === 503 ? "unavailable" : status ? "error" : "network", - message: typeof data?.message === "string" ? data.message : REGISTER_FAILURE_MESSAGE, + message: data.message || REGISTER_FAILURE_MESSAGE, } } catch { return { ok: false, result: "network", message: REGISTER_FAILURE_MESSAGE } @@ -438,13 +423,13 @@ export function DialogAltimateBaseConfirm(props: { setBusy(true) setError(undefined) const outcome = await registerAltimateBase(sdk) + if (disposed) return if (firstRunActive()) { trackOnboarding({ name: "altimate_base_register_result", result: outcome.ok ? "success" : outcome.result, }) } - if (disposed) return setBusy(false) if (!outcome.ok) { setError(outcome.message) @@ -478,7 +463,13 @@ export function DialogAltimateBaseConfirm(props: { ] useKeyboard((evt) => { - if (busy()) return + 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() @@ -510,7 +501,7 @@ export function DialogAltimateBaseConfirm(props: { Use Altimate Base? - dialog.clear()}> + !busy() && dialog.clear()}> esc diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index 9d6d245bb6..dee2661387 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -18,7 +18,14 @@ import { useClipboard } from "../context/clipboard" import { useLocal } from "../context/local" // altimate_change — mark first-run setup complete once the gateway sign-in succeeds // (used by AutoMethod below); flips useReady() so the first-run chat lock lifts. -import { markSetupComplete, clearFirstRunActive, DialogAltimateBaseConfirm } from "./altimate-onboarding" +import { + markSetupComplete, + clearFirstRunActive, + DialogAltimateBaseConfirm, + useFirstRunActive, +} from "./altimate-onboarding" +// altimate_change — first-run provider selection telemetry +import { useOnboardingTelemetry } from "../context/onboarding-telemetry" export const PROVIDER_PRIORITY: Record = { // altimate_change start — Part 1 onboarding: Altimate LLM Gateway is the @@ -113,6 +120,9 @@ export function createDialogProviderOptions() { const toast = useToast() const { theme } = useTheme() const onboarded = useConnected() + // altimate_change — only emit this funnel event during an active first run + const firstRunActive = useFirstRunActive() + const trackOnboarding = useOnboardingTelemetry() // altimate_change start — delegate altimate-backend provider selection to fork credential plugin const keymap = useOpencodeKeymap() // altimate_change end @@ -172,6 +182,14 @@ export function createDialogProviderOptions() { if (consoleManaged) return // altimate_change start — route Altimate Base through its disclosure and consent flow if (providerID === "altimate-free") { + if (firstRunActive()) { + trackOnboarding({ + name: "provider_selected", + providerID: "altimate-free", + modelID: "altimate-base", + via_search: false, + }) + } dialog.replace(() => ) return } diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index b3aee41720..07caa6cdf1 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -8,6 +8,10 @@ export type EventSource = { subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void> } +export type AltimateBaseRegistration = () => Promise< + { ok: true } | { ok: false; status: number; message: string } +> + export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", init: (props: { @@ -16,8 +20,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ fetch?: typeof fetch headers?: RequestInit["headers"] events?: EventSource - // altimate_change start — host-injected Altimate Base registration capability - altimateBaseRegistration?: { token: string; header: string } + // altimate_change start — narrow host-injected registration operation; no raw credential/token + altimateBaseRegistration?: AltimateBaseRegistration // altimate_change end }) => { const abort = new AbortController() @@ -197,7 +201,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ event: emitter, fetch: props.fetch ?? fetch, url: props.url, - // altimate_change start — expose the capability only through the TUI SDK context + // altimate_change start — expose only the narrow operation through the TUI SDK context altimateBaseRegistration: props.altimateBaseRegistration, // altimate_change end } diff --git a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx index 796bac0ab3..4fd031ed62 100644 --- a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx +++ b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx @@ -16,11 +16,15 @@ async function waitUntil(predicate: () => boolean, timeout = 2_000) { } } -const REGISTER_PATH = "/altimate/base/register" -const CONSENT_HEADER = "x-altimate-base-consent" -const CONSENT_TOKEN = "test-consent-token" - -async function mountConfirm(input: { registration?: Response; modelAvailable?: boolean } = {}) { +async function mountConfirm( + input: { + registration?: + | { ok: true } + | { ok: false; status: number; message: string } + | (() => Promise<{ ok: true } | { ok: false; status: number; message: string }>) + modelAvailable?: boolean + } = {}, +) { const [ { DialogProvider }, { @@ -64,7 +68,7 @@ async function mountConfirm(input: { registration?: Response; modelAvailable?: b resetSetupComplete() markFirstRunActive() const events: OnboardingTelemetryEvent[] = [] - const requests: Array<{ path: string; consent: string | null }> = [] + const registrations: true[] = [] const model = { id: "altimate-base", providerID: "altimate-free", @@ -77,7 +81,6 @@ async function mountConfirm(input: { registration?: Response; modelAvailable?: b } const provider = { id: "altimate-free", name: "Altimate", models: { "altimate-base": model }, env: [] } const inner = createFetch((url) => { - if (url.pathname === REGISTER_PATH) return input.registration ?? json({ ok: true }) if (url.pathname === "/instance/dispose") return json({}) if (url.pathname === "/config/providers") { return json({ providers: input.modelAvailable === false ? [] : [provider], default: {} }) @@ -87,11 +90,6 @@ async function mountConfirm(input: { registration?: Response; modelAvailable?: b } return undefined }) - const fetch = (async (requestInput: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(requestInput, init) - requests.push({ path: new URL(request.url).pathname, consent: request.headers.get(CONSENT_HEADER) }) - return inner.fetch(request) - }) as typeof globalThis.fetch const source = createEventSource() function Harness() { @@ -112,9 +110,14 @@ async function mountConfirm(input: { registration?: Response; modelAvailable?: b { + registrations.push(true) + return typeof input.registration === "function" + ? input.registration() + : (input.registration ?? { ok: true }) + }} > @@ -152,17 +155,17 @@ async function mountConfirm(input: { registration?: Response; modelAvailable?: b return { app, events, - requests, disclosure: ALTIMATE_BASE_DISCLOSURE, setupComplete: useSetupComplete(), - registrations: () => requests.filter((request) => request.path === REGISTER_PATH), + registrations: () => registrations, cleanup() { app.renderer.destroy() + resetSetupComplete() }, } } -test("Altimate Base shows the privacy disclosure before registration and defaults to No", async () => { +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() @@ -179,7 +182,7 @@ test("Altimate Base shows the privacy disclosure before registration and default } }) -test("declining Altimate Base makes no registration request", async () => { +test.serial("declining Altimate Base makes no registration request", async () => { const confirm = await mountConfirm() try { confirm.app.mockInput.pressKey("n") @@ -191,12 +194,12 @@ test("declining Altimate Base makes no registration request", async () => { } }) -test("accepting registers once with the in-memory capability and completes setup", async () => { +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()).toEqual([{ path: REGISTER_PATH, consent: CONSENT_TOKEN }]) + expect(confirm.registrations()).toHaveLength(1) expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "accept" }) expect(confirm.events).toContainEqual({ name: "altimate_base_register_result", result: "success" }) expect(confirm.events.filter((event) => event.name === "altimate_base_choice")).toHaveLength(1) @@ -205,7 +208,7 @@ test("accepting registers once with the in-memory capability and completes setup } }) -test("registration without a usable model remains incomplete and visibly recoverable", async () => { +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") @@ -218,3 +221,51 @@ test("registration without a usable model remains incomplete and visibly recover 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, status: 429, 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" }) + expect(confirm.app.captureCharFrame()).toContain("Too many Altimate Base") + } finally { + confirm.cleanup() + } +}) + +test.serial("dismissal keys 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 + confirm.app.mockInput.pressKey("escape") + confirm.app.mockInput.pressKey("c", { ctrl: true }) + await confirm.app.renderOnce() + expect(confirm.app.captureCharFrame()).toContain("Setting up…") + + finish({ ok: true }) + await waitUntil(() => confirm.setupComplete()) + } finally { + confirm.cleanup() + } +}) From a3658efaa491e87ddab6ec852cd42b44bdfeff7a Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 22:22:53 -0700 Subject: [PATCH 06/33] fix: close Altimate Base release gates --- packages/opencode/src/acp/service.ts | 2 ++ packages/opencode/src/cli/cmd/tui.ts | 2 +- packages/opencode/src/provider/provider.ts | 3 ++- packages/opencode/test/altimate/altimate-base.test.ts | 6 +++++- packages/tui/src/component/dialog-provider.tsx | 9 ++++++--- packages/tui/src/context/sdk.tsx | 2 ++ 6 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index a312e5da95..bc013d0d8b 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -839,6 +839,7 @@ export function defaultModelFromConfig( // altimate_change end } +// altimate_change start — keep Big Pickle explicitly selectable but never choose it implicitly export function selectDefaultModel(snapshot: Directory.Snapshot) { if (snapshot.defaultModel) return snapshot.defaultModel // Big Pickle remains explicitly selectable for existing users, but Altimate Base replaces it as @@ -850,6 +851,7 @@ export function selectDefaultModel(snapshot: Directory.Snapshot) { if (model) return { providerID: model.providerID, modelID: model.modelID } return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ModelV2.ID } } +// altimate_change end function detectSlashCommand(parts: ReturnType) { const text = parts diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index ff47ff6905..9c75e2e153 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -245,7 +245,7 @@ export const TuiThreadCommand = cmd({ }, config, pluginHost: createLegacyTuiPluginHost(), - // altimate_change — keep Base registration on the private worker RPC even when the TUI + // Keep Base registration on the private worker RPC even when the TUI // itself is connected to an externally bound HTTP server. No raw capability enters the // shared SDK context or any network-exposed transport. altimateBaseRegistration: async () => await client.call("registerAltimateBase", undefined), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 695399163e..89a550d1c8 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1683,8 +1683,9 @@ export namespace Provider { // load apikeys for (const [id, provider] of Object.entries(await Auth.all())) { - // altimate_change — the managed provider is hydrated only from its dedicated credential store + // altimate_change start — the managed provider is hydrated only from its dedicated credential store if (id === FreeTier.PROVIDER_ID) continue + // altimate_change end const providerID = ProviderID.make(id) if (disabled.has(providerID)) continue if (provider.type === "api") { diff --git a/packages/opencode/test/altimate/altimate-base.test.ts b/packages/opencode/test/altimate/altimate-base.test.ts index 65af95ebbb..957b4e292b 100644 --- a/packages/opencode/test/altimate/altimate-base.test.ts +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -97,7 +97,11 @@ describe("registration", () => { expect(await FreeTier.credentials()).toEqual(result) expect(path.basename(FreeTierStore.credentialPath())).toBe("altimate-base.json") expect(fs.statSync(FreeTierStore.credentialPath()).mode & 0o777).toBe(0o600) - expect(fs.existsSync(path.join(path.dirname(FreeTierStore.credentialPath()), "auth.json"))).toBe(false) + // The full suite may already have an auth.json from unrelated auth tests. + // Pin path separation without assuming the shared test home is empty. + expect(FreeTierStore.credentialPath()).not.toBe( + path.join(path.dirname(FreeTierStore.credentialPath()), "auth.json"), + ) }) test("rejects a registration response that redirects credentials to another origin", async () => { diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index dee2661387..4d5461d432 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -16,7 +16,7 @@ import { useConnected } from "./use-connected" import { useBindings, useOpencodeKeymap } from "../keymap" import { useClipboard } from "../context/clipboard" import { useLocal } from "../context/local" -// altimate_change — mark first-run setup complete once the gateway sign-in succeeds +// altimate_change start — mark first-run setup complete once the gateway sign-in succeeds // (used by AutoMethod below); flips useReady() so the first-run chat lock lifts. import { markSetupComplete, @@ -24,8 +24,10 @@ import { DialogAltimateBaseConfirm, useFirstRunActive, } from "./altimate-onboarding" -// altimate_change — first-run provider selection telemetry +// altimate_change end +// altimate_change start — first-run provider selection telemetry import { useOnboardingTelemetry } from "../context/onboarding-telemetry" +// altimate_change end export const PROVIDER_PRIORITY: Record = { // altimate_change start — Part 1 onboarding: Altimate LLM Gateway is the @@ -120,9 +122,10 @@ export function createDialogProviderOptions() { const toast = useToast() const { theme } = useTheme() const onboarded = useConnected() - // altimate_change — only emit this funnel event during an active first run + // altimate_change start — only emit this funnel event during an active first run const firstRunActive = useFirstRunActive() const trackOnboarding = useOnboardingTelemetry() + // altimate_change end // altimate_change start — delegate altimate-backend provider selection to fork credential plugin const keymap = useOpencodeKeymap() // altimate_change end diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 07caa6cdf1..f12d9fd2d5 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -8,9 +8,11 @@ export type EventSource = { subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void> } +// altimate_change start — private host operation; credentials never enter the shared SDK context export type AltimateBaseRegistration = () => Promise< { ok: true } | { ok: false; status: number; message: string } > +// altimate_change end export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", From bcd7c3ef300e0becfc50ae2a56a7cda0f8662e2e Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 22:38:54 -0700 Subject: [PATCH 07/33] fix: enforce Altimate Base consent and secret boundary --- packages/opencode/src/altimate/free/client.ts | 103 ++++++++------- packages/opencode/src/cli/cmd/tui.ts | 14 +- packages/opencode/src/cli/tui/worker.ts | 60 ++++++++- packages/opencode/src/provider/provider.ts | 4 +- .../test/altimate/altimate-base.test.ts | 124 +++++++++++++++--- .../tui/src/component/altimate-onboarding.tsx | 3 +- .../tui/src/component/dialog-provider.tsx | 13 +- packages/tui/src/context/sdk.tsx | 7 +- .../cli/tui/dialog-altimate-base.test.tsx | 10 +- 9 files changed, 257 insertions(+), 81 deletions(-) diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index ce912e51ed..e3552a33e8 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -9,13 +9,15 @@ const log = Log.create({ service: "altimate-base" }) export const PROVIDER_ID = "altimate-free" export const MODEL_ID = "altimate-base" +// The OpenAI-compatible SDK requires a non-empty key, but the real managed key must never enter +// Provider.Info/options because those objects are returned by public provider endpoints. +export const MANAGED_API_KEY_PLACEHOLDER = "altimate-base-managed" // Release builds replace this identifier with the current official endpoint. // Source-mode development and tests intentionally have no implicit network host. declare const ALTIMATE_BASE_DEFAULT_GATEWAY_URL: string | undefined const REGISTER_TIMEOUT_MS = 15_000 const LOCK_KEY = "altimate-base-registration" -const MAX_AUTH_RECOVERY_ATTEMPTS = 3 const inflight = new Map>() export interface Credentials { @@ -88,12 +90,9 @@ function expired(value: Credentials): boolean { export async function credentialsForLoad(): Promise { const stored = await credentials() if (!stored || stored.baseURL !== gatewayUrl()) return undefined - if (expired(stored)) { - return register({ supersede: stored.apiKey }).catch((error) => { - log.warn("failed to refresh expired Altimate Base credentials", { error }) - return undefined - }) - } + // Provider discovery must remain read-only. Refreshing here would mint credentials without the + // current launch's explicit TUI disclosure/consent operation. + if (expired(stored)) return undefined return stored } @@ -144,11 +143,10 @@ async function installSecretForRegistration(): Promise { return installSecret } -async function registerOnce(signal?: AbortSignal): Promise { +async function registerOnce(configuredGateway: string, signal?: AbortSignal): Promise { signal?.throwIfAborted() const installSecret = await installSecretForRegistration() signal?.throwIfAborted() - const configuredGateway = gatewayUrl() let response: Response try { response = await fetch(`${configuredGateway}/register`, { @@ -178,10 +176,18 @@ async function registerOnce(signal?: AbortSignal): Promise { | undefined const apiKey = typeof body?.api_key === "string" ? body.api_key.trim() : "" const baseURL = typeof body?.base_url === "string" ? normalizeGatewayUrl(body.base_url) : undefined + const expiresAtPresent = body?.expires_at !== undefined + const expiresAt = typeof body?.expires_at === "string" ? body.expires_at.trim() : undefined + const expiresAtTimestamp = expiresAt ? Date.parse(expiresAt) : undefined if ( !apiKey || !baseURL || baseURL !== configuredGateway || + (expiresAtPresent && + (!expiresAt || + expiresAtTimestamp === undefined || + !Number.isFinite(expiresAtTimestamp) || + expiresAtTimestamp <= Date.now())) || (body?.model !== undefined && body.model !== MODEL_ID) ) { throw new RegistrationError("The Altimate Base gateway returned an unexpected response.") @@ -191,7 +197,7 @@ async function registerOnce(signal?: AbortSignal): Promise { apiKey, baseURL, installSecret, - ...(typeof body?.expires_at === "string" ? { expiresAt: body.expires_at } : {}), + ...(expiresAt ? { expiresAt } : {}), } await FreeTierStore.write({ version: 1, @@ -203,19 +209,32 @@ async function registerOnce(signal?: AbortSignal): Promise { return result } -export async function register( - input: { supersede?: string; rejected?: ReadonlySet; signal?: AbortSignal } = {}, -): Promise { - const dedupeKey = input.supersede ?? "" +/** + * Register only after the private TUI host has consumed its one-shot consent capability. + * + * Keeping this as the sole exported registration operation makes accidental background + * registration visible at call sites. Provider discovery and inference never call it. + */ +export async function registerAfterConsent(input: { signal?: AbortSignal } = {}): Promise { + const configuredGateway = gatewayUrl() + const dedupeKey = configuredGateway const pending = inflight.get(dedupeKey) if (pending) return pending const started = Flock.withLock(LOCK_KEY, async () => { - const fresh = await credentials() - if (fresh && input.supersede && fresh.apiKey !== input.supersede && !input.rejected?.has(fresh.apiKey)) { - return fresh + let fresh: Credentials | undefined + try { + fresh = await credentials() + } 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 }) + await FreeTierStore.remove() } - return registerOnce(input.signal) + if (fresh && fresh.baseURL === configuredGateway && !expired(fresh)) return fresh + return registerOnce(configuredGateway, input.signal) }).finally(() => { if (inflight.get(dedupeKey) === started) inflight.delete(dedupeKey) }) @@ -265,36 +284,26 @@ export async function authorizedFetch(input: RequestInfo | URL, init?: RequestIn return fetch(input, { ...init, headers, redirect: "manual" }) } - let active = initial - let response = await send(active)! - if (!isReplayable(input, init?.body)) return response - - const rejected = new Set() - for (let attempt = 0; response.status === 401 && attempt < MAX_AUTH_RECOVERY_ATTEMPTS; attempt++) { - rejected.add(active.apiKey) - const stored = await credentials() - let next: Credentials | undefined - if (stored && !rejected.has(stored.apiKey)) { - next = stored - } else { - next = await register({ supersede: active.apiKey, rejected }).catch((error) => { - log.warn("Altimate Base key rotation failed", { error, attempt }) - return undefined - }) - } - if (!next || rejected.has(next.apiKey)) return response - const retried = send(next) - if (!retried) { - log.error("blocked rotated Altimate Base credentials for a different origin", { - expected: safeOrigin(initial.baseURL), - actual: safeOrigin(next.baseURL), - }) - return response - } - active = next - response = await retried + const active = initial + const response = await send(active)! + if (response.status !== 401 || !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. + const next = await credentialsForLoad().catch((error) => { + log.warn("failed to read a rotated Altimate Base credential", { error }) + return undefined + }) + if (!next || next.apiKey === active.apiKey) return response + const retried = send(next) + if (!retried) { + log.error("blocked rotated Altimate Base credentials for a different origin", { + expected: safeOrigin(initial.baseURL), + actual: safeOrigin(next.baseURL), + }) + return response } - return response + return retried } export function describeRateLimit( diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 9c75e2e153..acbc5b7ec0 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -1,6 +1,8 @@ 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" @@ -245,10 +247,14 @@ 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. No raw capability enters the - // shared SDK context or any network-exposed transport. - altimateBaseRegistration: async () => await client.call("registerAltimateBase", undefined), + // 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 }) + }, // altimate_change — onboarding funnel seam. Deliberately a single-line marker, not a // start/end pair: this sits inside the "clean up TUI worker after failed --session // validation" region, and a nested closing marker truncates the block that diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 7b46687ffc..16f336db2a 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -12,6 +12,8 @@ import { Config } from "@/config/config" import { GlobalBus } from "@/bus/global" import { ServerAuth } from "@/server/auth" import { writeHeapSnapshot } from "node:v8" +// altimate_change — constant-time validation for the private, one-shot Base consent capability +import { timingSafeEqual } from "node:crypto" import { Heap } from "@/cli/heap" import { AppRuntime } from "@/effect/app-runtime" import { Effect } from "effect" @@ -29,6 +31,7 @@ import { Telemetry } from "@/altimate/telemetry" import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" // altimate_change start — register Altimate Base only across the private parent/worker RPC boundary import { FreeTier } from "@/altimate/free/client" +import { FreeTierStore } from "@/altimate/free/store" // altimate_change end // altimate_change — shared with the withTimeout budget in cli/cmd/tui.ts stop(), so the coupling @@ -65,22 +68,67 @@ GlobalBus.on("event", (event) => { }) let server: Awaited> | undefined +// altimate_change start — worker-local, one-shot capability gates every registration mutation +let altimateBaseConsentToken: Buffer | undefined + +function decodeAltimateBaseConsentToken(value: string): Buffer | undefined { + if (!/^[0-9a-f]{64}$/.test(value)) return undefined + const token = Buffer.from(value, "hex") + return token.length === 32 ? token : undefined +} + +function consumeAltimateBaseConsentToken(value: string): boolean { + const expected = altimateBaseConsentToken + altimateBaseConsentToken = undefined + const presented = decodeAltimateBaseConsentToken(value) + return Boolean(expected && presented && timingSafeEqual(expected, presented)) +} +// altimate_change end export const rpc = { - // altimate_change start — the parent invokes this only after the user accepts the disclosure - async registerAltimateBase() { + // altimate_change start — install and consume a private capability only after disclosure acceptance + setAltimateBaseConsentToken(input: { token: string }) { + const token = decodeAltimateBaseConsentToken(input.token) + if (!token) throw new Error("Invalid Altimate Base consent capability") + altimateBaseConsentToken = token + }, + async registerAltimateBase(input: { token: string }) { + if (!consumeAltimateBaseConsentToken(input.token)) { + return { + ok: false as const, + result: "error" as const, + message: "Altimate Base consent expired. Reopen setup and try again.", + } + } try { - await FreeTier.register() + await FreeTier.registerAfterConsent() return { ok: true as const } } catch (error) { const registrationError = error instanceof FreeTier.RegistrationError ? error : undefined - if (registrationError?.status === 429 || registrationError?.status === 503) { - return { ok: false as const, status: registrationError.status, message: registrationError.message } + if (registrationError) { + return { + ok: false as const, + result: + registrationError.status === 429 + ? ("rate_limited" as const) + : registrationError.status === 503 + ? ("unavailable" as const) + : registrationError.status === undefined + ? ("network" as const) + : ("error" as const), + message: registrationError.message, + } + } + if ( + error instanceof FreeTier.ConfigurationError || + error instanceof FreeTierStore.InvalidCredentialStoreError + ) { + return { ok: false as const, result: "error" as const, message: error.message } } console.error("[altimate-base] registration failed", error) return { ok: false as const, - status: 502, + result: "error" as const, message: "Could not set up Altimate Base. Try again, or pick another provider.", } } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 89a550d1c8..856b84c41b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -386,7 +386,9 @@ export namespace Provider { autoload: true, options: { baseURL: `${creds.baseURL}/v1`, - apiKey: creds.apiKey, + // The real managed credential stays in the dedicated store and is injected only by + // authorizedFetch. Provider options are serialized by public provider APIs. + apiKey: FreeTier.MANAGED_API_KEY_PLACEHOLDER, fetch: FreeTier.authorizedFetch, }, } diff --git a/packages/opencode/test/altimate/altimate-base.test.ts b/packages/opencode/test/altimate/altimate-base.test.ts index 957b4e292b..3d693a158f 100644 --- a/packages/opencode/test/altimate/altimate-base.test.ts +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -84,12 +84,14 @@ describe("gateway configuration", () => { describe("registration", () => { test("stores only a hash remotely and keeps credentials in the dedicated file", async () => { let requestBody: Record | undefined + const sharedAuthPath = path.join(path.dirname(FreeTierStore.credentialPath()), "auth.json") + const sharedAuthBefore = fs.existsSync(sharedAuthPath) ? fs.readFileSync(sharedAuthPath) : undefined mockFetch(async (_input, init) => { requestBody = JSON.parse(String(init?.body)) return json(REGISTERED) }) - const result = await FreeTier.register() + const result = await FreeTier.registerAfterConsent() const sentHash = String(requestBody?.install_secret_hash) expect(sentHash).toMatch(/^[0-9a-f]{64}$/) expect(sentHash).toBe(createHash("sha256").update(result.installSecret).digest("hex")) @@ -97,38 +99,73 @@ describe("registration", () => { expect(await FreeTier.credentials()).toEqual(result) expect(path.basename(FreeTierStore.credentialPath())).toBe("altimate-base.json") expect(fs.statSync(FreeTierStore.credentialPath()).mode & 0o777).toBe(0o600) - // The full suite may already have an auth.json from unrelated auth tests. - // Pin path separation without assuming the shared test home is empty. - expect(FreeTierStore.credentialPath()).not.toBe( - path.join(path.dirname(FreeTierStore.credentialPath()), "auth.json"), - ) + // The full suite may already have auth.json from unrelated auth tests. Pin the actual isolation + // property by proving registration leaves that shared store byte-for-byte unchanged. + const sharedAuthAfter = fs.existsSync(sharedAuthPath) ? fs.readFileSync(sharedAuthPath) : undefined + expect(sharedAuthAfter).toEqual(sharedAuthBefore) }) test("rejects a registration response that redirects credentials to another origin", async () => { mockFetch(() => json({ ...REGISTERED, base_url: "https://attacker.example.com" })) - await expect(FreeTier.register()).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.registerAfterConsent()).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.register()).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.registerAfterConsent()).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.register()).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.registerAfterConsent()).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()).rejects.toBeInstanceOf(FreeTier.RegistrationError) expect(await FreeTier.isRegistered()).toBe(false) }) + test("reuses a live credential without another registration request", async () => { + await FreeTierStore.write({ + version: 1, + installSecret: "existing-install-secret", + apiKey: REGISTERED.api_key, + baseURL: REGISTERED.base_url, + expiresAt: REGISTERED.expires_at, + }) + let calls = 0 + mockFetch(() => { + calls++ + return json(REGISTERED) + }) + + const result = await FreeTier.registerAfterConsent() + expect(result.apiKey).toBe(REGISTERED.api_key) + expect(calls).toBe(0) + }) + + test("repairs a malformed dedicated credential record only after explicit registration", async () => { + fs.mkdirSync(path.dirname(FreeTierStore.credentialPath()), { recursive: true }) + fs.writeFileSync(FreeTierStore.credentialPath(), "{truncated", { mode: 0o600 }) + mockFetch(() => json(REGISTERED)) + + await expect(FreeTier.credentialsForLoad()).rejects.toBeInstanceOf(FreeTierStore.InvalidCredentialStoreError) + const result = await FreeTier.registerAfterConsent() + expect(result.apiKey).toBe(REGISTERED.api_key) + expect(await FreeTier.credentials()).toEqual(result) + }) + test("reuses the install secret after a lost response", async () => { let firstHash = "" mockFetch((_input, init) => { firstHash = String(JSON.parse(String(init?.body)).install_secret_hash) throw new Error("connection reset") }) - await expect(FreeTier.register()).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.registerAfterConsent()).rejects.toBeInstanceOf(FreeTier.RegistrationError) fetchSpy?.mockRestore() let secondHash = "" @@ -136,7 +173,7 @@ describe("registration", () => { secondHash = String(JSON.parse(String(init?.body)).install_secret_hash) return json(REGISTERED) }) - await FreeTier.register() + await FreeTier.registerAfterConsent() expect(secondHash).toBe(firstHash) }) @@ -161,7 +198,7 @@ describe("registration", () => { }) }) - const pending = FreeTier.register({ signal: controller.signal }) + const pending = FreeTier.registerAfterConsent({ signal: controller.signal }) await requestStarted controller.abort() @@ -204,7 +241,7 @@ describe("inference boundary", () => { expect(await FreeTier.isRegistered()).toBe(false) }) - test("refreshes expired credentials before loading the provider", async () => { + test("fails closed on expired credentials without registering during provider discovery", async () => { await seed({ expiresAt: new Date(Date.now() - 1_000).toISOString() }) let registrations = 0 mockFetch((input) => { @@ -213,9 +250,8 @@ describe("inference boundary", () => { return json({ ...REGISTERED, api_key: "sk-altimate-base-refreshed" }) }) - const refreshed = await FreeTier.credentialsForLoad() - expect(refreshed?.apiKey).toBe("sk-altimate-base-refreshed") - expect(registrations).toBe(1) + expect(await FreeTier.credentialsForLoad()).toBeUndefined() + expect(registrations).toBe(0) }) test("blocks a mismatched origin before sending the stale header or prompt", async () => { @@ -269,4 +305,60 @@ describe("inference boundary", () => { expect(response.status).toBe(401) expect(authorizations).toEqual([`Bearer ${REGISTERED.api_key}`]) }) + + test("retries once with a credential already rotated by another consented process", async () => { + await seed() + const authorizations: (string | null)[] = [] + mockFetch(async (_input, init) => { + const authorization = new Headers(init?.headers).get("Authorization") + authorizations.push(authorization) + if (authorization === `Bearer ${REGISTERED.api_key}`) { + await seed({ apiKey: "sk-altimate-base-rotated" }) + return new Response("", { status: 401 }) + } + return new Response("{}", { status: 200 }) + }) + + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + expect(response.status).toBe(200) + expect(authorizations).toEqual([`Bearer ${REGISTERED.api_key}`, "Bearer sk-altimate-base-rotated"]) + }) + + test("a 401 never triggers background registration", async () => { + await seed() + const urls: string[] = [] + mockFetch((input) => { + urls.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url) + return new Response("", { status: 401 }) + }) + + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + expect(response.status).toBe(401) + expect(urls).toEqual([`${REGISTERED.base_url}/v1/chat/completions`]) + }) +}) + +describe("provider metadata boundary", () => { + test("public provider options contain only the managed-key placeholder", () => { + const source = fs.readFileSync(path.join(import.meta.dir, "../../src/provider/provider.ts"), "utf8") + expect(source).toContain("apiKey: FreeTier.MANAGED_API_KEY_PLACEHOLDER") + expect(source).not.toContain("apiKey: creds.apiKey") + }) + + test("the private TUI RPC consumes a one-shot capability before registration", () => { + const worker = fs.readFileSync(path.join(import.meta.dir, "../../src/cli/tui/worker.ts"), "utf8") + const command = fs.readFileSync(path.join(import.meta.dir, "../../src/cli/cmd/tui.ts"), "utf8") + expect(worker).toMatch( + /async registerAltimateBase\(input: \{ token: string \}\)[\s\S]*?if \(!consumeAltimateBaseConsentToken\(input\.token\)\)[\s\S]*?FreeTier\.registerAfterConsent\(\)/, + ) + expect(command).toMatch( + /randomBytes\(32\)[\s\S]*?setAltimateBaseConsentToken[\s\S]*?registerAltimateBase/, + ) + }) }) diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index 11b090762f..bc72cf232e 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -357,10 +357,9 @@ async function registerAltimateBase(sdk: ReturnType): Promise { const value = await DialogPrompt.show(dialog, "Other", { @@ -154,7 +157,13 @@ export function createDialogProviderOptions() { const options = createMemo(() => { return pipe( - providerOptions(sync.data.provider_next.all), + // 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(sdk.altimateBaseRegistration), + ), + // altimate_change end map((provider) => { if (provider.type === "custom") { return { @@ -185,6 +194,8 @@ export function createDialogProviderOptions() { if (consoleManaged) return // altimate_change start — route Altimate Base through its disclosure and consent flow if (providerID === "altimate-free") { + if (altimateBaseActivated) return + altimateBaseActivated = true if (firstRunActive()) { trackOnboarding({ name: "provider_selected", diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index f12d9fd2d5..b2514d14ba 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -10,7 +10,12 @@ export type EventSource = { // altimate_change start — private host operation; credentials never enter the shared SDK context export type AltimateBaseRegistration = () => Promise< - { ok: true } | { ok: false; status: number; message: string } + | { ok: true } + | { + ok: false + result: "rate_limited" | "unavailable" | "network" | "error" + message: string + } > // altimate_change end diff --git a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx index 4fd031ed62..8a49ea5aef 100644 --- a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx +++ b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx @@ -20,8 +20,12 @@ async function mountConfirm( input: { registration?: | { ok: true } - | { ok: false; status: number; message: string } - | (() => Promise<{ ok: true } | { ok: false; status: number; message: string }>) + | { 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 } = {}, ) { @@ -225,7 +229,7 @@ test.serial("registration without a usable model remains incomplete and visibly 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, status: 429, message }, + registration: { ok: false, result: "rate_limited", message }, }) try { confirm.app.mockInput.pressKey("y") From b9121aa68ca7f6b6da7442efa5a9cfec0eacebc5 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 23:09:40 -0700 Subject: [PATCH 08/33] fix: close Altimate Base review findings --- packages/opencode/src/acp/service.ts | 32 +++-- packages/opencode/src/altimate/free/client.ts | 53 ++++++++- .../opencode/src/altimate/free/consent.ts | 109 ++++++++++++++++++ packages/opencode/src/cli/tui/worker.ts | 68 ++--------- packages/opencode/src/provider/provider.ts | 6 +- .../opencode/test/acp/default-model.test.ts | 30 +++++ .../opencode/test/acp/service-session.test.ts | 47 +++++++- .../test/altimate/altimate-base.test.ts | 102 +++++++++++++--- .../opencode/test/provider/provider.test.ts | 29 +++++ 9 files changed, 382 insertions(+), 94 deletions(-) create mode 100644 packages/opencode/src/altimate/free/consent.ts diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index bc013d0d8b..59c5782916 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -166,7 +166,7 @@ export function make(input: { const newSession = Effect.fn("ACP.newSession")(function* (params: NewSessionRequest) { const started = performance.now() const snapshot = yield* directorySnapshot(params.cwd) - const selected = selectDefaultModel(snapshot) + const selected = yield* requireDefaultModel(snapshot) const variant = selectVariant(snapshot, selected) const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined const created = yield* profiledRequest( @@ -222,7 +222,7 @@ export function make(input: { "session", ) const restored = restoreFromMessages(messages.map((item) => item.info)) - const model = restored.model ?? selectDefaultModel(snapshot) + const model = restored.model ?? (yield* requireDefaultModel(snapshot)) const state = yield* session.load({ id: params.sessionId, cwd: params.cwd, @@ -307,7 +307,7 @@ export function make(input: { "session", ) const restored = restoreFromMessages(messages.map((item) => item.info)) - const model = restored.model ?? selectDefaultModel(snapshot) + const model = restored.model ?? (yield* requireDefaultModel(snapshot)) const state = yield* session.load({ id: params.sessionId, cwd: params.cwd, @@ -376,7 +376,7 @@ export function make(input: { "session", ) const restored = restoreFromMessages(messages.map((item) => item.info)) - const model = restored.model ?? selectDefaultModel(snapshot) + const model = restored.model ?? (yield* requireDefaultModel(snapshot)) const state = yield* session.load({ id: forked.id, cwd: params.cwd, @@ -426,7 +426,7 @@ export function make(input: { } if (params.configId === "effort") { - const model = current.model ?? selectDefaultModel(snapshot) + const model = current.model ?? (yield* requireDefaultModel(snapshot)) const variants = Directory.variants(snapshot, model) if (!variants || !Object.keys(variants).includes(params.value)) { return yield* new ACPError.InvalidEffortError({ effort: params.value }) @@ -448,7 +448,7 @@ export function make(input: { const state = yield* session.setMode(params.sessionId, params.value) return { configOptions: configOptions(snapshot, { - model: state.model ?? selectDefaultModel(snapshot), + model: state.model ?? (yield* requireDefaultModel(snapshot)), variant: state.variant, modeId: state.modeId, }), @@ -498,7 +498,7 @@ export function make(input: { prompt: Effect.fn("ACP.prompt")(function* (params: PromptRequest) { const current = yield* session.get(params.sessionId) const snapshot = yield* directorySnapshot(current.cwd) - const selected = current.model ?? selectDefaultModel(snapshot) + const selected = current.model ?? (yield* requireDefaultModel(snapshot)) if (!current.model) { yield* session.setModel(params.sessionId, selected) } @@ -821,7 +821,8 @@ export function defaultModelFromConfig( // First-session ACP startup must not scan historical sessions just to infer // a default. Configured model, opencode provider, then sorted best model keep // the protocol response deterministic without extra session/message reads. - const providerAllowed = (id: string) => !providerFilter || Object.prototype.hasOwnProperty.call(providerFilter, id) + const providerAllowed = (id: string) => + id !== "altimate-free" && (!providerFilter || Object.prototype.hasOwnProperty.call(providerFilter, id)) 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") @@ -836,6 +837,7 @@ 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) } if (configured) return configured + return undefined // altimate_change end } @@ -849,7 +851,19 @@ export function selectDefaultModel(snapshot: Directory.Snapshot) { (item) => !(item.providerID === ProviderV2.ID.make("opencode") && item.modelID === ModelV2.ID.make("big-pickle")), ) if (model) return { providerID: model.providerID, modelID: model.modelID } - return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ModelV2.ID } + return undefined +} + +function requireDefaultModel(snapshot: Directory.Snapshot) { + const selected = selectDefaultModel(snapshot) + return selected + ? Effect.succeed(selected) + : Effect.fail( + new ACPError.ServiceFailureError({ + safeMessage: "No supported model is configured. Register Altimate Base or configure another provider.", + service: "model", + }), + ) } // altimate_change end diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index e3552a33e8..2231846c41 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -19,6 +19,8 @@ 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>() +const rejectedCredentials = new Set() +const REJECTED_CREDENTIAL_LIMIT = 32 export interface Credentials { apiKey: string @@ -27,9 +29,12 @@ export interface Credentials { installSecret: string } +export type RegistrationFailureKind = "network" | "http" | "response" + export class RegistrationError extends Error { constructor( message: string, + readonly kind: RegistrationFailureKind, readonly status?: number, ) { super(message) @@ -101,6 +106,7 @@ export async function isRegistered(): Promise { } export async function clear(): Promise { + rejectedCredentials.clear() await Flock.withLock(LOCK_KEY, () => FreeTierStore.remove()) } @@ -134,6 +140,29 @@ function safeOrigin(value: string): string { } } +function credentialFingerprint(value: Pick): string { + return createHash("sha256").update(`${value.baseURL}\0${value.apiKey}`).digest("hex") +} + +function markCredentialRejected(value: Pick): void { + const fingerprint = credentialFingerprint(value) + rejectedCredentials.delete(fingerprint) + rejectedCredentials.add(fingerprint) + while (rejectedCredentials.size > REJECTED_CREDENTIAL_LIMIT) { + const oldest = rejectedCredentials.keys().next().value + if (!oldest) break + rejectedCredentials.delete(oldest) + } +} + +function credentialWasRejected(value: Pick): boolean { + return rejectedCredentials.has(credentialFingerprint(value)) +} + +function clearRejectedCredential(value: Pick): void { + rejectedCredentials.delete(credentialFingerprint(value)) +} + async function installSecretForRegistration(): Promise { const stored = await FreeTierStore.read() if (stored?.installSecret) return stored.installSecret @@ -163,12 +192,12 @@ async function registerOnce(configuredGateway: string, signal?: AbortSignal): Pr }) } catch (error) { log.warn("Altimate Base registration request failed", { error }) - throw new RegistrationError("Could not reach the Altimate Base gateway. Check your connection.") + throw new RegistrationError("Could not reach the Altimate Base gateway. Check your connection.", "network") } if (!response.ok) { log.warn("Altimate Base registration rejected", { status: response.status }) - throw new RegistrationError(describeRegistrationFailure(response.status), response.status) + throw new RegistrationError(describeRegistrationFailure(response.status), "http", response.status) } const body = (await response.json().catch(() => undefined)) as @@ -190,7 +219,7 @@ async function registerOnce(configuredGateway: string, signal?: AbortSignal): Pr expiresAtTimestamp <= Date.now())) || (body?.model !== undefined && body.model !== MODEL_ID) ) { - throw new RegistrationError("The Altimate Base gateway returned an unexpected response.") + throw new RegistrationError("The Altimate Base gateway returned an unexpected response.", "response") } const result: Credentials = { @@ -206,6 +235,7 @@ async function registerOnce(configuredGateway: string, signal?: AbortSignal): Pr baseURL, ...(result.expiresAt ? { expiresAt: result.expiresAt } : {}), }) + clearRejectedCredential(result) return result } @@ -233,7 +263,10 @@ export async function registerAfterConsent(input: { signal?: AbortSignal } = {}) log.warn("removing invalid Altimate Base credential record after explicit consent", { error }) await FreeTierStore.remove() } - if (fresh && fresh.baseURL === configuredGateway && !expired(fresh)) return fresh + if (fresh && fresh.baseURL === configuredGateway && !expired(fresh) && !credentialWasRejected(fresh)) return fresh + if (fresh && credentialWasRejected(fresh)) { + log.info("rotating a rejected Altimate Base credential after explicit consent") + } return registerOnce(configuredGateway, input.signal) }).finally(() => { if (inflight.get(dedupeKey) === started) inflight.delete(dedupeKey) @@ -286,7 +319,12 @@ export async function authorizedFetch(input: RequestInfo | URL, init?: RequestIn const active = initial const response = await send(active)! - if (response.status !== 401 || !isReplayable(input, init?.body)) return response + if (response.status !== 401) { + clearRejectedCredential(active) + return response + } + 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. @@ -303,7 +341,10 @@ export async function authorizedFetch(input: RequestInfo | URL, init?: RequestIn }) return response } - return retried + const retryResponse = await retried + if (retryResponse.status === 401) markCredentialRejected(next) + else clearRejectedCredential(next) + return retryResponse } export function describeRateLimit( diff --git a/packages/opencode/src/altimate/free/consent.ts b/packages/opencode/src/altimate/free/consent.ts new file mode 100644 index 0000000000..0f3d194a45 --- /dev/null +++ b/packages/opencode/src/altimate/free/consent.ts @@ -0,0 +1,109 @@ +import { ConfigurationError, RegistrationError } from "./client" +import { FreeTierStore } from "./store" + +const TOKEN_PATTERN = /^[0-9a-f]{64}$/ +const DEFAULT_TTL_MS = 30_000 +const DEFAULT_MAX_PENDING = 16 + +export type RegistrationResult = + | { ok: true } + | { + ok: false + result: "network" | "rate_limited" | "unavailable" | "error" + message: string + } + +/** + * 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. + */ +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 + } +} + +export function createRegistrationConsentGate(input: { + register: () => Promise + capabilities?: ConsentCapabilityStore + onUnexpectedError?: (error: unknown) => void +}) { + const capabilities = input.capabilities ?? new ConsentCapabilityStore() + return { + setToken(value: { token: string }): void { + capabilities.arm(value.token) + }, + async register(value: { token: string }): Promise { + if (!capabilities.consume(value.token)) { + return { + ok: false, + result: "error", + message: "Altimate Base consent expired. Reopen setup and try again.", + } + } + try { + await input.register() + return { ok: true } + } catch (error) { + if (error instanceof RegistrationError) { + return { + ok: false, + result: + error.status === 429 + ? "rate_limited" + : error.status === 503 + ? "unavailable" + : error.kind === "network" + ? "network" + : "error", + message: error.message, + } + } + if (error instanceof ConfigurationError || error instanceof FreeTierStore.InvalidCredentialStoreError) { + return { ok: false, result: "error", message: error.message } + } + input.onUnexpectedError?.(error) + return { + ok: false, + result: "error", + message: "Could not set up Altimate Base. Try again, or pick another provider.", + } + } + }, + } +} diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 16f336db2a..a423fa12f7 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -12,8 +12,6 @@ import { Config } from "@/config/config" import { GlobalBus } from "@/bus/global" import { ServerAuth } from "@/server/auth" import { writeHeapSnapshot } from "node:v8" -// altimate_change — constant-time validation for the private, one-shot Base consent capability -import { timingSafeEqual } from "node:crypto" import { Heap } from "@/cli/heap" import { AppRuntime } from "@/effect/app-runtime" import { Effect } from "effect" @@ -31,7 +29,7 @@ import { Telemetry } from "@/altimate/telemetry" import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" // altimate_change start — register Altimate Base only across the private parent/worker RPC boundary import { FreeTier } from "@/altimate/free/client" -import { FreeTierStore } from "@/altimate/free/store" +import { createRegistrationConsentGate } from "@/altimate/free/consent" // altimate_change end // altimate_change — shared with the withTimeout budget in cli/cmd/tui.ts stop(), so the coupling @@ -68,70 +66,20 @@ GlobalBus.on("event", (event) => { }) let server: Awaited> | undefined -// altimate_change start — worker-local, one-shot capability gates every registration mutation -let altimateBaseConsentToken: Buffer | undefined - -function decodeAltimateBaseConsentToken(value: string): Buffer | undefined { - if (!/^[0-9a-f]{64}$/.test(value)) return undefined - const token = Buffer.from(value, "hex") - return token.length === 32 ? token : undefined -} - -function consumeAltimateBaseConsentToken(value: string): boolean { - const expected = altimateBaseConsentToken - altimateBaseConsentToken = undefined - const presented = decodeAltimateBaseConsentToken(value) - return Boolean(expected && presented && timingSafeEqual(expected, presented)) -} +// altimate_change start — worker-local, expiring capabilities gate every registration mutation +const altimateBaseRegistration = createRegistrationConsentGate({ + register: () => FreeTier.registerAfterConsent(), + 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 }) { - const token = decodeAltimateBaseConsentToken(input.token) - if (!token) throw new Error("Invalid Altimate Base consent capability") - altimateBaseConsentToken = token + altimateBaseRegistration.setToken(input) }, async registerAltimateBase(input: { token: string }) { - if (!consumeAltimateBaseConsentToken(input.token)) { - return { - ok: false as const, - result: "error" as const, - message: "Altimate Base consent expired. Reopen setup and try again.", - } - } - try { - await FreeTier.registerAfterConsent() - return { ok: true as const } - } catch (error) { - const registrationError = error instanceof FreeTier.RegistrationError ? error : undefined - if (registrationError) { - return { - ok: false as const, - result: - registrationError.status === 429 - ? ("rate_limited" as const) - : registrationError.status === 503 - ? ("unavailable" as const) - : registrationError.status === undefined - ? ("network" as const) - : ("error" as const), - message: registrationError.message, - } - } - if ( - error instanceof FreeTier.ConfigurationError || - error instanceof FreeTierStore.InvalidCredentialStoreError - ) { - return { ok: false as const, result: "error" as const, message: error.message } - } - console.error("[altimate-base] registration failed", error) - return { - ok: false as const, - result: "error" as const, - message: "Could not set up Altimate Base. Try again, or pick another provider.", - } - } + return altimateBaseRegistration.register(input) }, // altimate_change end async fetch(input: { url: string; method: string; headers: Record; body?: string }) { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 856b84c41b..4b21dbddf8 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -2177,9 +2177,11 @@ export namespace Provider { return { providerID: baseProviderID, modelID: ModelID.make(FreeTier.MODEL_ID) } } - const configuredProviderIDs = Object.keys(cfg.provider ?? {}).filter((id) => id !== FreeTier.PROVIDER_ID) + const configuredProviderEntries = Object.keys(cfg.provider ?? {}) + const hasProviderAllowlist = configuredProviderEntries.length > 0 + const configuredProviderIDs = configuredProviderEntries.filter((id) => id !== FreeTier.PROVIDER_ID) const candidates = Object.values(providers).filter( - (provider) => configuredProviderIDs.length === 0 || configuredProviderIDs.includes(provider.id), + (provider) => !hasProviderAllowlist || configuredProviderIDs.includes(provider.id), ) if (candidates.length === 0) throw new Error("no providers found") for (const provider of candidates) { diff --git a/packages/opencode/test/acp/default-model.test.ts b/packages/opencode/test/acp/default-model.test.ts index 246e043abc..913a83be73 100644 --- a/packages/opencode/test/acp/default-model.test.ts +++ b/packages/opencode/test/acp/default-model.test.ts @@ -130,6 +130,15 @@ describe("ACP defaultModelFromConfig", () => { expect(result).toBeUndefined() }) + test("an Altimate Base-only provider block cannot force the managed provider", () => { + const result = defaultModelFromConfig( + undefined, + providers(provider("altimate-free", ["altimate-base"]), provider("openai", ["gpt-5"])), + { "altimate-free": {} }, + ) + expect(result).toBeUndefined() + }) + test("honors an explicit provider allowlist that includes altimate-backend", () => { const result = defaultModelFromConfig( undefined, @@ -149,5 +158,26 @@ describe("ACP defaultModelFromConfig", () => { modelID: ModelV2.ID.make("big-pickle"), }) }) + + test("returns no snapshot fallback when Big Pickle is the only option", () => { + const snapshot = { + directory: "/tmp/acp-big-pickle-only", + providers: {}, + modelOptions: [ + { + providerID: ProviderV2.ID.make("opencode"), + providerName: "OpenCode", + modelID: ModelV2.ID.make("big-pickle"), + modelName: "Big Pickle", + }, + ], + variantsByModel: {}, + availableModes: [], + defaultModeID: "build", + availableCommands: [], + } satisfies Snapshot + + expect(selectDefaultModel(snapshot)).toBeUndefined() + }) }) // altimate_change end diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 852ef81795..561745db09 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -148,7 +148,11 @@ const provider: Provider.Info = { describe("ACP service sessions", () => { const makeService = ( messages: readonly { info: unknown; parts: readonly unknown[] }[] = [], - options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> }, + options?: { + abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> + providers?: Provider.Info[] + defaults?: Record + }, ) => { const updates: SessionNotification[] = [] const mcpAdds: string[] = [] @@ -158,6 +162,7 @@ describe("ACP service sessions", () => { const commands: unknown[] = [] const summarizes: unknown[] = [] const usageUpdates: string[] = [] + const creates: unknown[] = [] const sessions = Array.from({ length: 102 }, (_, index) => ({ id: `ses_${index + 1}`, directory: index % 2 === 0 ? "/workspace" : "/other", @@ -166,7 +171,13 @@ describe("ACP service sessions", () => { })) const sdk = { config: { - providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }), + providers: () => + Promise.resolve({ + data: { + providers: options?.providers ?? [provider], + default: options?.defaults ?? { test: modelID }, + }, + }), get: () => Promise.resolve({ data: {} }), }, app: { @@ -190,7 +201,10 @@ describe("ACP service sessions", () => { }), }, session: { - create: () => Promise.resolve({ data: { id: "ses_new" } }), + create: (input: unknown) => { + creates.push(input) + return Promise.resolve({ data: { id: "ses_new" } }) + }, get: () => Promise.resolve({ data: { id: "ses_loaded" } }), list: (input: { directory?: string }) => Promise.resolve({ @@ -272,6 +286,7 @@ describe("ACP service sessions", () => { commands, summarizes, usageUpdates, + creates, } } @@ -299,6 +314,32 @@ describe("ACP service sessions", () => { expect(mcpAdds).toEqual(["tools"]) }) + it("fails before creating a session when Big Pickle is the only implicit option", async () => { + const bigPickleProvider = { + ...provider, + id: ProviderID.make("opencode"), + name: "OpenCode", + models: { + [ModelID.make("big-pickle")]: { + ...provider.models[modelID], + id: ModelID.make("big-pickle"), + providerID: ProviderID.make("opencode"), + name: "Big Pickle", + }, + }, + } satisfies Provider.Info + const { service, creates } = makeService([], { providers: [bigPickleProvider], defaults: {} }) + + const failure = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }).pipe(Effect.flip)) + + expect(failure).toMatchObject({ + _tag: "ACPServiceFailureError", + safeMessage: "No supported model is configured. Register Altimate Base or configure another provider.", + service: "model", + }) + expect(creates).toHaveLength(0) + }) + it("loads a session and restores model variant and mode from messages", async () => { const { service } = makeService([ { diff --git a/packages/opencode/test/altimate/altimate-base.test.ts b/packages/opencode/test/altimate/altimate-base.test.ts index 3d693a158f..5bb4299563 100644 --- a/packages/opencode/test/altimate/altimate-base.test.ts +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -13,6 +13,7 @@ process.env.OPENCODE_TEST_HOME = temporaryHome const { FreeTier } = await import("../../src/altimate/free/client") const { FreeTierStore } = await import("../../src/altimate/free/store") +const { ConsentCapabilityStore, createRegistrationConsentGate } = await import("../../src/altimate/free/consent") const GATEWAY_URL = "https://gateway.test" const REGISTERED = { @@ -177,6 +178,22 @@ describe("registration", () => { expect(secondHash).toBe(firstHash) }) + test("distinguishes network failures from invalid gateway responses", async () => { + mockFetch(() => { + throw new Error("connection reset") + }) + const network = await FreeTier.registerAfterConsent().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().catch((error) => error) + expect(response).toBeInstanceOf(FreeTier.RegistrationError) + expect(response.kind).toBe("response") + expect(response.status).toBeUndefined() + }) + test("cancels an in-flight gateway registration when its caller is dismissed", async () => { const controller = new AbortController() let started!: () => void @@ -342,23 +359,80 @@ describe("inference boundary", () => { expect(response.status).toBe(401) expect(urls).toEqual([`${REGISTERED.base_url}/v1/chat/completions`]) }) + + test("explicit consent rotates an unexpired credential rejected by inference", async () => { + await seed({ expiresAt: REGISTERED.expires_at }) + const urls: string[] = [] + mockFetch((input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + urls.push(url) + if (url.endsWith("/register")) return json({ ...REGISTERED, api_key: "sk-altimate-base-rotated" }) + return new Response("", { status: 401 }) + }) + + const rejected = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + expect(rejected.status).toBe(401) + + const rotated = await FreeTier.registerAfterConsent() + expect(rotated.apiKey).toBe("sk-altimate-base-rotated") + expect(urls).toEqual([`${REGISTERED.base_url}/v1/chat/completions`, `${REGISTERED.base_url}/register`]) + }) }) -describe("provider metadata boundary", () => { - test("public provider options contain only the managed-key placeholder", () => { - const source = fs.readFileSync(path.join(import.meta.dir, "../../src/provider/provider.ts"), "utf8") - expect(source).toContain("apiKey: FreeTier.MANAGED_API_KEY_PLACEHOLDER") - expect(source).not.toContain("apiKey: creds.apiKey") +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 + const gate = createRegistrationConsentGate({ + register: async () => { + 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 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) }) - test("the private TUI RPC consumes a one-shot capability before registration", () => { - const worker = fs.readFileSync(path.join(import.meta.dir, "../../src/cli/tui/worker.ts"), "utf8") - const command = fs.readFileSync(path.join(import.meta.dir, "../../src/cli/cmd/tui.ts"), "utf8") - expect(worker).toMatch( - /async registerAltimateBase\(input: \{ token: string \}\)[\s\S]*?if \(!consumeAltimateBaseConsentToken\(input\.token\)\)[\s\S]*?FreeTier\.registerAfterConsent\(\)/, - ) - expect(command).toMatch( - /randomBytes\(32\)[\s\S]*?setAltimateBaseConsentToken[\s\S]*?registerAltimateBase/, - ) + test("only transport failures are surfaced as network failures", async () => { + const token = "d".repeat(64) + const network = createRegistrationConsentGate({ + register: async () => { + throw new FreeTier.RegistrationError("offline", "network") + }, + }) + network.setToken({ token }) + expect(await network.register({ token })).toMatchObject({ ok: false, result: "network" }) + + const invalidResponse = createRegistrationConsentGate({ + register: async () => { + throw new FreeTier.RegistrationError("invalid", "response") + }, + }) + invalidResponse.setToken({ token }) + expect(await invalidResponse.register({ token })).toMatchObject({ ok: false, result: "error" }) }) }) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index c9b20364e1..7711275b07 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -78,6 +78,8 @@ test("Altimate Base is pinned to the hosted Qwen contract without affecting othe expect(base.name).toBe("Altimate") expect(base.env).toEqual([]) expect(base.options.baseURL).toBe(`${ALTIMATE_BASE_GATEWAY_URL}/v1`) + expect(base.options.apiKey).toBe(FreeTier.MANAGED_API_KEY_PLACEHOLDER) + expect(JSON.stringify(base)).not.toContain("sk-altimate-base") const model = base.models[FreeTier.MODEL_ID] expect(model.name).toBe("Altimate Base") @@ -153,6 +155,33 @@ 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 () => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ + apiKey: "sk-altimate-base", + baseURL: ALTIMATE_BASE_GATEWAY_URL, + installSecret: "install-secret", + }) + try { + 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") + }, + }) + } finally { + credentials.mockRestore() + } +}) + test("provider loaded from env variable", async () => { await using tmp = await tmpdir({ init: async (dir) => { From 65a7cc3316e5b1af2875a5f2fe2103616988daf5 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 23:13:45 -0700 Subject: [PATCH 09/33] fix: mark ACP model guard boundaries --- packages/opencode/src/acp/service.ts | 14 ++++++++++++++ .../opencode/test/altimate/altimate-base.test.ts | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 59c5782916..2e7546408b 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -166,7 +166,9 @@ export function make(input: { const newSession = Effect.fn("ACP.newSession")(function* (params: NewSessionRequest) { const started = performance.now() const snapshot = yield* directorySnapshot(params.cwd) + // altimate_change start — fail closed when Big Pickle is the only implicit ACP option const selected = yield* requireDefaultModel(snapshot) + // altimate_change end const variant = selectVariant(snapshot, selected) const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined const created = yield* profiledRequest( @@ -222,7 +224,9 @@ export function make(input: { "session", ) const restored = restoreFromMessages(messages.map((item) => item.info)) + // altimate_change start — fail closed when a legacy session has no usable model const model = restored.model ?? (yield* requireDefaultModel(snapshot)) + // altimate_change end const state = yield* session.load({ id: params.sessionId, cwd: params.cwd, @@ -307,7 +311,9 @@ export function make(input: { "session", ) const restored = restoreFromMessages(messages.map((item) => item.info)) + // altimate_change start — fail closed when a resumed session has no usable model const model = restored.model ?? (yield* requireDefaultModel(snapshot)) + // altimate_change end const state = yield* session.load({ id: params.sessionId, cwd: params.cwd, @@ -376,7 +382,9 @@ export function make(input: { "session", ) const restored = restoreFromMessages(messages.map((item) => item.info)) + // altimate_change start — fail closed when a fork has no usable model const model = restored.model ?? (yield* requireDefaultModel(snapshot)) + // altimate_change end const state = yield* session.load({ id: forked.id, cwd: params.cwd, @@ -426,7 +434,9 @@ export function make(input: { } if (params.configId === "effort") { + // altimate_change start — effort selection requires a real, advertised model const model = current.model ?? (yield* requireDefaultModel(snapshot)) + // altimate_change end const variants = Directory.variants(snapshot, model) if (!variants || !Object.keys(variants).includes(params.value)) { return yield* new ACPError.InvalidEffortError({ effort: params.value }) @@ -448,7 +458,9 @@ export function make(input: { const state = yield* session.setMode(params.sessionId, params.value) return { configOptions: configOptions(snapshot, { + // altimate_change start — mode selection cannot fabricate an ACP model model: state.model ?? (yield* requireDefaultModel(snapshot)), + // altimate_change end variant: state.variant, modeId: state.modeId, }), @@ -498,7 +510,9 @@ export function make(input: { prompt: Effect.fn("ACP.prompt")(function* (params: PromptRequest) { const current = yield* session.get(params.sessionId) const snapshot = yield* directorySnapshot(current.cwd) + // altimate_change start — prompts require a real, advertised model const selected = current.model ?? (yield* requireDefaultModel(snapshot)) + // altimate_change end if (!current.model) { yield* session.setModel(params.sessionId, selected) } diff --git a/packages/opencode/test/altimate/altimate-base.test.ts b/packages/opencode/test/altimate/altimate-base.test.ts index 5bb4299563..dc1455f5d0 100644 --- a/packages/opencode/test/altimate/altimate-base.test.ts +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -4,6 +4,14 @@ import fs from "node:fs" import os from "node:os" import path from "node:path" +const isolatedEnvironment = [ + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "OPENCODE_TEST_HOME", +] as const +const originalEnvironment = Object.fromEntries(isolatedEnvironment.map((key) => [key, process.env[key]])) const temporaryHome = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-base-")) process.env.XDG_DATA_HOME = path.join(temporaryHome, "data") process.env.XDG_CONFIG_HOME = path.join(temporaryHome, "config") @@ -52,6 +60,11 @@ afterEach(() => { }) afterAll(() => { + for (const key of isolatedEnvironment) { + const value = originalEnvironment[key] + if (value === undefined) delete process.env[key] + else process.env[key] = value + } fs.rmSync(temporaryHome, { recursive: true, force: true }) }) From 4f6ea45a68c7c94f684050162c960a930d70cd3f Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 00:00:35 -0700 Subject: [PATCH 10/33] test: align ACP event fixture with model guard --- packages/opencode/test/acp/event.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/opencode/test/acp/event.test.ts b/packages/opencode/test/acp/event.test.ts index 8a72754f0e..42370d07f5 100644 --- a/packages/opencode/test/acp/event.test.ts +++ b/packages/opencode/test/acp/event.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from "bun:test" import type { AgentSideConnection } from "@agentclientprotocol/sdk" import type { Event, Message, OpencodeClient, Part, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { Effect, ManagedRuntime } from "effect" +// altimate_change start — give model-agnostic lifecycle tests a valid ACP model fixture +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +// altimate_change end import { ACPEvent } from "@/acp/event" import * as ACPService from "@/acp/service" import { Directory } from "@/acp/directory" @@ -366,6 +370,12 @@ describe("acp event routing", () => { modes: [], defaultModeID: "build", commands: [], + // altimate_change start — satisfy the fail-closed ACP model boundary + defaultModel: { + providerID: ProviderV2.ID.make("fixture"), + modelID: ModelV2.ID.make("fixture"), + }, + // altimate_change end }), ), refresh: () => @@ -376,6 +386,12 @@ describe("acp event routing", () => { modes: [], defaultModeID: "build", commands: [], + // altimate_change start — satisfy the fail-closed ACP model boundary + defaultModel: { + providerID: ProviderV2.ID.make("fixture"), + modelID: ModelV2.ID.make("fixture"), + }, + // altimate_change end }), ), variants: Directory.variants, From ac7f404b1378f85b97e37ad9afb8b95702e048cf Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 00:46:43 -0700 Subject: [PATCH 11/33] fix: close Altimate Base release review gaps --- packages/opencode/src/acp/service.ts | 46 +++++++++++++-- packages/opencode/src/altimate/free/client.ts | 57 +++++++++++++++---- packages/opencode/src/altimate/free/store.ts | 5 ++ packages/opencode/src/provider/error.ts | 2 - packages/opencode/src/provider/provider.ts | 9 +-- packages/opencode/src/session/llm.ts | 21 ++++--- .../opencode/test/acp/default-model.test.ts | 12 ++++ .../opencode/test/acp/service-session.test.ts | 57 +++++++++++++++++-- .../test/altimate/altimate-base.test.ts | 18 ++++++ packages/opencode/test/provider/error.test.ts | 12 +++- .../opencode/test/provider/provider.test.ts | 23 ++++++++ packages/opencode/test/session/llm.test.ts | 14 +++++ .../tui/src/component/altimate-onboarding.tsx | 6 +- packages/tui/src/ui/dialog.tsx | 32 ++++++++--- .../cli/tui/dialog-altimate-base.test.tsx | 26 ++++++--- 15 files changed, 289 insertions(+), 51 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 2e7546408b..8de6ec9c65 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -365,6 +365,18 @@ export function make(input: { const forkSession = Effect.fn("ACP.forkSession")(function* (params: ForkSessionRequest) { const snapshot = yield* directorySnapshot(params.cwd) + // Resolve a usable model from the source session before creating any persistent fork. The + // forked transcript is read again below because the server may trim it at the fork boundary. + const sourceMessages = yield* request( + () => + input.sdk.session.messages( + { directory: params.cwd, sessionID: params.sessionId, limit: 20 }, + { throwOnError: true }, + ), + "session", + ) + const sourceRestored = restoreFromMessages(sourceMessages.map((item) => item.info)) + const fallbackModel = sourceRestored.model ?? (yield* requireDefaultModel(snapshot)) const forked = yield* request( () => input.sdk.session.fork( @@ -383,7 +395,7 @@ export function make(input: { ) const restored = restoreFromMessages(messages.map((item) => item.info)) // altimate_change start — fail closed when a fork has no usable model - const model = restored.model ?? (yield* requireDefaultModel(snapshot)) + const model = restored.model ?? fallbackModel // altimate_change end const state = yield* session.load({ id: forked.id, @@ -455,11 +467,13 @@ export function make(input: { if (!snapshot.availableModes.some((mode) => mode.id === params.value)) { return yield* new ACPError.InvalidModeError({ mode: params.value }) } + // Validate the complete resulting state before mutating the session mode. + const model = current.model ?? (yield* requireDefaultModel(snapshot)) const state = yield* session.setMode(params.sessionId, params.value) return { configOptions: configOptions(snapshot, { // altimate_change start — mode selection cannot fabricate an ACP model - model: state.model ?? (yield* requireDefaultModel(snapshot)), + model: state.model ?? model, // altimate_change end variant: state.variant, modeId: state.modeId, @@ -758,6 +772,23 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { ProviderV2.ID, Provider.Info > + const configuredProviderEntries = Object.keys(configResponse?.data?.provider ?? {}) + const hasProviderAllowlist = configuredProviderEntries.length > 0 + const configuredModelProvider = configResponse?.data?.model + ? Provider.parseModel(configResponse.data.model).providerID + : undefined + // An empty provider object is the default/unrestricted configuration. A non-empty object is an + // allowlist for advertised ACP model options, except that an explicitly configured model remains + // authoritative. The managed provider can never be enabled merely by naming it in the block. + const snapshotProviders = hasProviderAllowlist + ? (Object.fromEntries( + Object.entries(providers).filter( + ([id]) => + id === configuredModelProvider || + (id !== "altimate-free" && Object.prototype.hasOwnProperty.call(configResponse?.data?.provider, id)), + ), + ) as Record) + : providers const defaultModelStarted = performance.now() const defaultModel = defaultModelFromConfig( configResponse?.data?.model, @@ -787,7 +818,7 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { return Directory.build({ directory, - providers, + providers: snapshotProviders, modes, defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build", commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)), @@ -811,6 +842,9 @@ export function defaultModelFromConfig( : undefined if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured + const configuredProviderEntries = Object.keys(providerFilter ?? {}) + const hasProviderAllowlist = configuredProviderEntries.length > 0 + // Prefer altimate-backend/altimate-default when the fork's backend is available and the user // hasn't pinned a model — restores dropped fork behavior (the merge fell straight through to the // opencode provider, routing ACP clients away from altimate's backend). Honors an explicit @@ -819,7 +853,7 @@ export function defaultModelFromConfig( if ( altimateProvider && altimateProvider.models[ModelV2.ID.make("altimate-default")] && - (!providerFilter || Object.keys(providerFilter).includes("altimate-backend")) + (!hasProviderAllowlist || configuredProviderEntries.includes("altimate-backend")) ) { return { providerID: ProviderV2.ID.make("altimate-backend"), modelID: ModelV2.ID.make("altimate-default") } } @@ -828,7 +862,7 @@ export function defaultModelFromConfig( // consented and registered (which is why it is present in `providers`). A project provider block // cannot force the managed model; an explicit configured model above remains authoritative. const baseProvider = providers[ProviderV2.ID.make("altimate-free")] - if (!providerFilter && baseProvider?.models[ModelV2.ID.make("altimate-base")]) { + if (!hasProviderAllowlist && baseProvider?.models[ModelV2.ID.make("altimate-base")]) { return { providerID: ProviderV2.ID.make("altimate-free"), modelID: ModelV2.ID.make("altimate-base") } } @@ -836,7 +870,7 @@ export function defaultModelFromConfig( // a default. Configured model, opencode provider, then sorted best model keep // the protocol response deterministic without extra session/message reads. const providerAllowed = (id: string) => - id !== "altimate-free" && (!providerFilter || Object.prototype.hasOwnProperty.call(providerFilter, id)) + id !== "altimate-free" && (!hasProviderAllowlist || Object.prototype.hasOwnProperty.call(providerFilter, id)) 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") diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 2231846c41..31480cc47c 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -27,6 +27,7 @@ export interface Credentials { baseURL: string expiresAt?: string installSecret: string + rejected?: boolean } export type RegistrationFailureKind = "network" | "http" | "response" @@ -83,6 +84,7 @@ export async function credentials(): Promise { baseURL: stored.baseURL, expiresAt: stored.expiresAt, installSecret: stored.installSecret, + ...(stored.rejected ? { rejected: true } : {}), } } @@ -97,7 +99,7 @@ export async function credentialsForLoad(): Promise { 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. - if (expired(stored)) return undefined + if (stored.rejected || expired(stored)) return undefined return stored } @@ -144,7 +146,7 @@ function credentialFingerprint(value: Pick): return createHash("sha256").update(`${value.baseURL}\0${value.apiKey}`).digest("hex") } -function markCredentialRejected(value: Pick): void { +function markCredentialRejectedInMemory(value: Pick): void { const fingerprint = credentialFingerprint(value) rejectedCredentials.delete(fingerprint) rejectedCredentials.add(fingerprint) @@ -159,10 +161,36 @@ function credentialWasRejected(value: Pick): return rejectedCredentials.has(credentialFingerprint(value)) } -function clearRejectedCredential(value: Pick): void { +function clearRejectedCredentialInMemory(value: Pick): void { rejectedCredentials.delete(credentialFingerprint(value)) } +async function markCredentialRejected(value: Pick): Promise { + markCredentialRejectedInMemory(value) + await Flock.withLock(LOCK_KEY, async () => { + const stored = await FreeTierStore.read() + if (!stored?.apiKey || stored.apiKey !== value.apiKey || stored.baseURL !== value.baseURL || stored.rejected) return + await FreeTierStore.write({ ...stored, rejected: true }) + }).catch((error) => { + // The in-memory marker still prevents reuse in this process. Preserve the gateway's response + // instead of replacing it with a local persistence failure. + log.warn("failed to persist rejected Altimate Base credentials", { error }) + }) +} + +async function clearRejectedCredential(value: Pick): Promise { + clearRejectedCredentialInMemory(value) + await Flock.withLock(LOCK_KEY, async () => { + const stored = await FreeTierStore.read() + if (!stored?.apiKey || stored.apiKey !== value.apiKey || stored.baseURL !== value.baseURL || !stored.rejected) + return + const { rejected: _rejected, ...active } = stored + await FreeTierStore.write(active) + }).catch((error) => { + log.warn("failed to clear rejected Altimate Base credentials", { error }) + }) +} + async function installSecretForRegistration(): Promise { const stored = await FreeTierStore.read() if (stored?.installSecret) return stored.installSecret @@ -235,7 +263,9 @@ async function registerOnce(configuredGateway: string, signal?: AbortSignal): Pr baseURL, ...(result.expiresAt ? { expiresAt: result.expiresAt } : {}), }) - clearRejectedCredential(result) + // registerOnce runs while LOCK_KEY is already held, so only touch the process-local cache here; + // the newly written record above has already cleared the persisted rejection marker. + clearRejectedCredentialInMemory(result) return result } @@ -263,8 +293,15 @@ export async function registerAfterConsent(input: { signal?: AbortSignal } = {}) log.warn("removing invalid Altimate Base credential record after explicit consent", { error }) await FreeTierStore.remove() } - if (fresh && fresh.baseURL === configuredGateway && !expired(fresh) && !credentialWasRejected(fresh)) return fresh - if (fresh && credentialWasRejected(fresh)) { + if ( + fresh && + fresh.baseURL === configuredGateway && + !expired(fresh) && + !fresh.rejected && + !credentialWasRejected(fresh) + ) + return fresh + if (fresh && (fresh.rejected || credentialWasRejected(fresh))) { log.info("rotating a rejected Altimate Base credential after explicit consent") } return registerOnce(configuredGateway, input.signal) @@ -320,10 +357,10 @@ export async function authorizedFetch(input: RequestInfo | URL, init?: RequestIn const active = initial const response = await send(active)! if (response.status !== 401) { - clearRejectedCredential(active) + if (response.ok) await clearRejectedCredential(active) return response } - markCredentialRejected(active) + 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 @@ -342,8 +379,8 @@ export async function authorizedFetch(input: RequestInfo | URL, init?: RequestIn return response } const retryResponse = await retried - if (retryResponse.status === 401) markCredentialRejected(next) - else clearRejectedCredential(next) + if (retryResponse.status === 401) await markCredentialRejected(next) + else if (retryResponse.ok) await clearRejectedCredential(next) return retryResponse } diff --git a/packages/opencode/src/altimate/free/store.ts b/packages/opencode/src/altimate/free/store.ts index b5ec3dfedb..91b45c4311 100644 --- a/packages/opencode/src/altimate/free/store.ts +++ b/packages/opencode/src/altimate/free/store.ts @@ -9,6 +9,7 @@ export interface Record { apiKey?: string baseURL?: string expiresAt?: string + rejected?: boolean } export class InvalidCredentialStoreError extends Error { @@ -37,6 +38,9 @@ function parse(value: unknown): Record { throw new InvalidCredentialStoreError("Altimate Base credentials are invalid.") } } + if (input.rejected !== undefined && typeof input.rejected !== "boolean") { + throw new InvalidCredentialStoreError("Altimate Base credentials are invalid.") + } const apiKey = typeof input.apiKey === "string" ? input.apiKey : undefined const baseURL = typeof input.baseURL === "string" ? input.baseURL : undefined const expiresAt = typeof input.expiresAt === "string" ? input.expiresAt : undefined @@ -46,6 +50,7 @@ function parse(value: unknown): Record { ...(apiKey ? { apiKey } : {}), ...(baseURL ? { baseURL } : {}), ...(expiresAt ? { expiresAt } : {}), + ...(input.rejected === true ? { rejected: true } : {}), } } diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index c3e4f3f7a6..1de0f4ceb0 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -341,7 +341,6 @@ export namespace ProviderError { statusCode: 413, isRetryable: false, responseHeaders: input.error.responseHeaders, - responseBody: capResponseBody(input.error.responseBody), metadata: input.error.url ? { url: maskInternalHost(input.error.url) } : undefined, } } @@ -370,7 +369,6 @@ export namespace ProviderError { statusCode: 429, isRetryable: described.retryable, responseHeaders: input.error.responseHeaders, - responseBody: capResponseBody(input.error.responseBody), 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 4b21dbddf8..c26965e9ba 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -2148,13 +2148,16 @@ export namespace Provider { return { providerID: entry.providerID, modelID: entry.modelID } } + const configuredProviderEntries = Object.keys(cfg.provider ?? {}) + const hasProviderAllowlist = configuredProviderEntries.length > 0 + // altimate_change start — default to altimate-backend when configured and no model chosen yet const altimateProviderID = ProviderID.make("altimate-backend") const altimateProvider = providers[altimateProviderID] if ( altimateProvider && altimateProvider.models[ModelID.make("altimate-default")] && - (!cfg.provider || Object.keys(cfg.provider).includes(String(altimateProviderID))) + (!hasProviderAllowlist || configuredProviderEntries.includes(String(altimateProviderID))) ) { // altimate_change start — log when altimate-backend auto-selected log.info("defaulting to altimate-backend/altimate-default (no model configured)") @@ -2172,13 +2175,11 @@ export namespace Provider { // managed model; an explicit `model` setting above remains authoritative. const baseProviderID = ProviderID.make(FreeTier.PROVIDER_ID) const baseProvider = providers[baseProviderID] - if (baseProvider?.models[ModelID.make(FreeTier.MODEL_ID)] && !cfg.provider) { + if (baseProvider?.models[ModelID.make(FreeTier.MODEL_ID)] && !hasProviderAllowlist) { log.info("defaulting to altimate-free/altimate-base (no model configured)") return { providerID: baseProviderID, modelID: ModelID.make(FreeTier.MODEL_ID) } } - const configuredProviderEntries = Object.keys(cfg.provider ?? {}) - const hasProviderAllowlist = configuredProviderEntries.length > 0 const configuredProviderIDs = configuredProviderEntries.filter((id) => id !== FreeTier.PROVIDER_ID) const candidates = Object.values(providers).filter( (provider) => !hasProviderAllowlist || configuredProviderIDs.includes(provider.id), diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index f1eb06fca3..e6ddab3878 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -56,6 +56,18 @@ export namespace LLM { export function managedSessionHeaders(providerID: string, sessionID: string): Record { return providerID === "altimate-free" ? { "X-Session-Id": sessionID } : {} } + + export function withManagedSessionHeaders( + providerID: string, + sessionID: string, + headers: Record, + ): Record { + if (providerID !== "altimate-free") return headers + return { + ...Object.fromEntries(Object.entries(headers).filter(([key]) => key.toLowerCase() !== "x-session-id")), + "X-Session-Id": sessionID, + } + } // altimate_change end export async function stream(input: StreamInput) { @@ -259,7 +271,7 @@ export namespace LLM { maxOutputTokens: params.maxOutputTokens, // altimate_change end abortSignal: input.abort, - headers: { + headers: withManagedSessionHeaders(input.model.providerID, input.sessionID, { ...(input.model.providerID.startsWith("opencode") ? { "x-opencode-project": Instance.project.id, @@ -272,16 +284,11 @@ export namespace LLM { // altimate_change start — upstream_fix: UA brand "User-Agent": `altimate-code/${Installation.VERSION}`, // altimate_change end - // altimate_change start — Altimate Base gateway session-scoped abuse control - // Used by the managed gateway for per-session abuse controls and traces. Never - // attach it to third-party providers. - ...managedSessionHeaders(input.model.providerID, input.sessionID), - // altimate_change end } : undefined), ...input.model.headers, ...headers, - }, + }), maxRetries: input.retries ?? 0, messages: [ ...system.map( diff --git a/packages/opencode/test/acp/default-model.test.ts b/packages/opencode/test/acp/default-model.test.ts index 913a83be73..bbc828f5e2 100644 --- a/packages/opencode/test/acp/default-model.test.ts +++ b/packages/opencode/test/acp/default-model.test.ts @@ -70,6 +70,18 @@ describe("ACP defaultModelFromConfig", () => { }) }) + test("treats an empty provider object as unrestricted", () => { + const result = defaultModelFromConfig( + undefined, + providers(provider("altimate-free", ["altimate-base"]), provider("opencode", ["big-pickle"])), + {}, + ) + expect(result).toEqual({ + providerID: ProviderV2.ID.make("altimate-free"), + modelID: ModelV2.ID.make("altimate-base"), + }) + }) + test("never chooses Big Pickle implicitly", () => { expect(defaultModelFromConfig(undefined, providers(provider("opencode", ["big-pickle"])))).toBeUndefined() }) diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 561745db09..fc38b43ee6 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -151,7 +151,7 @@ describe("ACP service sessions", () => { options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> providers?: Provider.Info[] - defaults?: Record + providerConfig?: Record }, ) => { const updates: SessionNotification[] = [] @@ -175,10 +175,9 @@ describe("ACP service sessions", () => { Promise.resolve({ data: { providers: options?.providers ?? [provider], - default: options?.defaults ?? { test: modelID }, }, }), - get: () => Promise.resolve({ data: {} }), + get: () => Promise.resolve({ data: { provider: options?.providerConfig } }), }, app: { agents: () => @@ -328,7 +327,7 @@ describe("ACP service sessions", () => { }, }, } satisfies Provider.Info - const { service, creates } = makeService([], { providers: [bigPickleProvider], defaults: {} }) + const { service, creates } = makeService([], { providers: [bigPickleProvider] }) const failure = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }).pipe(Effect.flip)) @@ -340,6 +339,56 @@ describe("ACP service sessions", () => { expect(creates).toHaveLength(0) }) + it("does not advertise Altimate Base through an ACP snapshot excluded by a provider allowlist", async () => { + const baseProvider = { + ...provider, + id: ProviderID.make("altimate-free"), + name: "Altimate", + models: { + [ModelID.make("altimate-base")]: { + ...provider.models[modelID], + id: ModelID.make("altimate-base"), + providerID: ProviderID.make("altimate-free"), + name: "Altimate Base", + }, + }, + } satisfies Provider.Info + const { service } = makeService([], { + providers: [provider, baseProvider], + providerConfig: { test: {} }, + }) + + 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("test-model"))).toBe(true) + }) + + it("fails before forking when no supported implicit model exists", async () => { + const bigPickleProvider = { + ...provider, + id: ProviderID.make("opencode"), + name: "OpenCode", + models: { + [ModelID.make("big-pickle")]: { + ...provider.models[modelID], + id: ModelID.make("big-pickle"), + providerID: ProviderID.make("opencode"), + name: "Big Pickle", + }, + }, + } satisfies Provider.Info + const { service, forks } = makeService([], { providers: [bigPickleProvider] }) + + const failure = await Effect.runPromise( + service.forkSession({ cwd: "/workspace", sessionId: "ses_parent", mcpServers: [] }).pipe(Effect.flip), + ) + + expect(failure).toMatchObject({ service: "model" }) + expect(forks).toHaveLength(0) + }) + it("loads a session and restores model variant and mode from messages", async () => { const { service } = makeService([ { diff --git a/packages/opencode/test/altimate/altimate-base.test.ts b/packages/opencode/test/altimate/altimate-base.test.ts index dc1455f5d0..ebfa684545 100644 --- a/packages/opencode/test/altimate/altimate-base.test.ts +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -371,6 +371,24 @@ describe("inference boundary", () => { }) expect(response.status).toBe(401) expect(urls).toEqual([`${REGISTERED.base_url}/v1/chat/completions`]) + expect(await FreeTierStore.read()).toMatchObject({ rejected: true }) + expect(await FreeTier.credentialsForLoad()).toBeUndefined() + }) + + test("a non-success response does not clear a concurrently persisted rejection", async () => { + await seed() + mockFetch(async () => { + await seed({ rejected: true }) + return new Response("unavailable", { status: 500 }) + }) + + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + + expect(response.status).toBe(500) + expect(await FreeTierStore.read()).toMatchObject({ rejected: true }) }) test("explicit consent rotates an unexpired credential rejected by inference", async () => { diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts index 10a0b88190..df94588d0d 100644 --- a/packages/opencode/test/provider/error.test.ts +++ b/packages/opencode/test/provider/error.test.ts @@ -416,7 +416,11 @@ describe("ProviderError.parseAPICallError: Altimate Base isolation", () => { }) expect(result.message).toContain("Too many requests to Altimate Base") expect(result.message).toContain("12s") - if (result.type === "api_error") expect(result.isRetryable).toBe(true) + if (result.type === "api_error") { + expect(result.isRetryable).toBe(true) + expect(result.responseBody).toBeUndefined() + expect(JSON.stringify(result)).not.toContain("throttling_error") + } }) test("does not rewrite another provider's 429", () => { @@ -448,7 +452,11 @@ describe("ProviderError.parseAPICallError: Altimate Base isolation", () => { }) expect(result.type).toBe("api_error") expect(result.message).toContain("too large for Altimate Base") - if (result.type === "api_error") expect(result.isRetryable).toBe(false) + if (result.type === "api_error") { + expect(result.isRetryable).toBe(false) + expect(result.responseBody).toBeUndefined() + expect(JSON.stringify(result)).not.toContain("179608") + } }) test("leaves another provider's 413 on the context-overflow path", () => { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 7711275b07..15132f9377 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -182,6 +182,29 @@ test("an Altimate Base-only provider block cannot select an unrelated provider", } }) +test("an empty provider object remains unrestricted for Altimate Base", async () => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ + apiKey: "sk-altimate-base", + baseURL: ALTIMATE_BASE_GATEWAY_URL, + installSecret: "install-secret", + }) + try { + await using tmp = await tmpdir({ config: { provider: {} } }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + const model = await Provider.defaultModel() + expect(model).toEqual({ + providerID: ProviderID.make(FreeTier.PROVIDER_ID), + modelID: ModelID.make(FreeTier.MODEL_ID), + }) + }, + }) + } finally { + credentials.mockRestore() + } +}) + test("provider loaded from env variable", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 4a0fe7879c..ea23f96e71 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -91,6 +91,20 @@ describe("session.llm.managedSessionHeaders", () => { expect(LLM.managedSessionHeaders("anthropic", sessionID)).toEqual({}) expect(LLM.managedSessionHeaders("openai", sessionID)).toEqual({}) }) + + test("the managed session ID wins over plugin headers without changing other providers", () => { + const sessionID = SessionID.make("ses_trusted-session") + const pluginHeaders = { + "X-Session-Id": "plugin-controlled", + "x-session-id": "plugin-controlled-lowercase", + "X-Plugin": "preserved", + } + expect(LLM.withManagedSessionHeaders("altimate-free", sessionID, pluginHeaders)).toEqual({ + "X-Session-Id": sessionID, + "X-Plugin": "preserved", + }) + expect(LLM.withManagedSessionHeaders("anthropic", sessionID, pluginHeaders)).toEqual(pluginHeaders) + }) }) // altimate_change end diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index bc72cf232e..31d8869458 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -387,6 +387,7 @@ export function DialogAltimateBaseConfirm(props: { let decided = false let choiceRecorded = false let disposed = false + const releaseCloseGuard = dialog.guardClose(() => !busy()) function recordChoice(choice: "accept" | "cancel") { if (choiceRecorded) return @@ -398,6 +399,7 @@ export function DialogAltimateBaseConfirm(props: { if (firstRunActive()) trackOnboarding({ name: "altimate_base_confirm_shown", origin: props.origin }) }) onCleanup(() => { + releaseCloseGuard() disposed = true decided = true recordChoice("cancel") @@ -429,8 +431,8 @@ export function DialogAltimateBaseConfirm(props: { result: outcome.ok ? "success" : outcome.result, }) } - setBusy(false) if (!outcome.ok) { + setBusy(false) setError(outcome.message) toast.show({ variant: "error", message: outcome.message }) return @@ -445,12 +447,14 @@ export function DialogAltimateBaseConfirm(props: { ) 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) dialog.clear() local.model.set({ providerID: "altimate-free", modelID: "altimate-base" }, { recent: true }) markSetupComplete() diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index b6cd705b1e..2666aa4742 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -74,6 +74,11 @@ function init() { const renderer = useRenderer() const modeStack = useOpencodeModeStack() + let closeGuard: (() => boolean) | undefined + + function canClose() { + return closeGuard?.() ?? true + } createEffect(() => { if (store.stack.length === 0) return @@ -99,6 +104,15 @@ function init() { }, 1) } + function closeTop() { + if (!canClose()) return false + const current = store.stack.at(-1) + current?.onClose?.() + setStore("stack", store.stack.slice(0, -1)) + refocus() + return true + } + useBindings(() => ({ enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(), bindings: [ @@ -110,10 +124,7 @@ function init() { if (renderer.getSelection()) { renderer.clearSelection() } - const current = store.stack.at(-1) - current?.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - refocus() + closeTop() }, }, { @@ -124,10 +135,7 @@ function init() { if (renderer.getSelection()) { renderer.clearSelection() } - const current = store.stack.at(-1) - current?.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - refocus() + closeTop() }, }, ], @@ -135,6 +143,7 @@ function init() { return { clear() { + if (!canClose()) return false for (const item of store.stack) { if (item.onClose) item.onClose() } @@ -143,6 +152,7 @@ function init() { setStore("stack", []) }) refocus() + return true }, replace(input: any, onClose?: () => void) { if (store.stack.length === 0) { @@ -169,6 +179,12 @@ function init() { setSize(size: "medium" | "large" | "xlarge") { setStore("size", size) }, + guardClose(guard: () => boolean) { + closeGuard = guard + return () => { + if (closeGuard === guard) closeGuard = undefined + } + }, } } diff --git a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx index 8a49ea5aef..d4ac4c30c4 100644 --- a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx +++ b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx @@ -2,7 +2,7 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { testRender, useRenderer } from "@opentui/solid" import { expect, test } from "bun:test" -import { onCleanup } from "solid-js" +import { onCleanup, onMount } from "solid-js" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TestTuiContexts } from "../../fixture/tui-environment" import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk" @@ -30,7 +30,7 @@ async function mountConfirm( } = {}, ) { const [ - { DialogProvider }, + { DialogProvider, useDialog }, { DialogAltimateBaseConfirm, ALTIMATE_BASE_DISCLOSURE, @@ -102,6 +102,13 @@ async function mountConfirm( const resolvedConfig = createTuiResolvedConfig({ leader_timeout: 1_000 }) const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig) onCleanup(off) + + function OpenConfirm() { + const dialog = useDialog() + onMount(() => dialog.replace(() => )) + return null + } + return ( {}}> @@ -133,7 +140,7 @@ async function mountConfirm( }} > - + @@ -175,8 +182,8 @@ test.serial("Altimate Base shows the privacy disclosure before registration and const frame = confirm.app.captureCharFrame() expect(confirm.disclosure).toContain("Requests and responses are logged") expect(frame).toContain("Use Altimate Base?") - expect(frame).toContain("Requests and responses are") - expect(frame).toContain("logged and may be used") + expect(frame).toContain("Requests") + expect(frame).toContain("responses are logged and may be used") expect(frame).toContain("No — pick something else") expect(frame).toContain("(default)") expect(confirm.registrations()).toHaveLength(0) @@ -220,7 +227,7 @@ test.serial("registration without a usable model remains incomplete and visibly await Bun.sleep(50) await confirm.app.renderOnce() expect(confirm.setupComplete()).toBe(false) - expect(confirm.app.captureCharFrame()).toContain("model is not ready yet") + expect(confirm.app.captureCharFrame()).toContain("ready yet. Try again") } finally { confirm.cleanup() } @@ -244,7 +251,7 @@ test.serial("rate-limited registration stays recoverable and reports a typed out } }) -test.serial("dismissal keys are ignored while registration is in flight", async () => { +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) => { @@ -263,9 +270,14 @@ test.serial("dismissal keys are ignored while registration is in flight", async confirm.app.mockInput.pressKey("y") await began confirm.app.mockInput.pressKey("escape") + 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()) From 99826aaf3125e4464987c476484acb5a1241d65b Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 02:16:33 -0700 Subject: [PATCH 12/33] fix: close final Altimate Base release findings --- packages/opencode/src/acp/service.ts | 17 +++++-- packages/opencode/src/altimate/free/client.ts | 23 ++------- packages/opencode/src/provider/provider.ts | 22 +++++---- .../instance/httpapi/handlers/provider.ts | 9 ++++ packages/opencode/src/session/llm.ts | 4 -- .../opencode/test/acp/service-session.test.ts | 46 ++++++++++++++++++ .../test/altimate/altimate-base.test.ts | 33 +++++++++++++ packages/opencode/test/fake/provider.ts | 1 + .../opencode/test/provider/provider.test.ts | 32 +++++++++++++ .../test/server/httpapi-provider.test.ts | 28 +++++++++-- packages/opencode/test/session/llm.test.ts | 10 +--- packages/tui/src/component/dialog-model.tsx | 43 ++++++++++++++++- packages/tui/src/ui/dialog.tsx | 2 + .../cli/tui/dialog-altimate-base.test.tsx | 47 +++++++++++++++++-- 14 files changed, 264 insertions(+), 53 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 8de6ec9c65..0ef215542f 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -225,7 +225,7 @@ export function make(input: { ) const restored = restoreFromMessages(messages.map((item) => item.info)) // altimate_change start — fail closed when a legacy session has no usable model - const model = restored.model ?? (yield* requireDefaultModel(snapshot)) + const model = availableModel(snapshot, restored.model) ?? (yield* requireDefaultModel(snapshot)) // altimate_change end const state = yield* session.load({ id: params.sessionId, @@ -312,7 +312,7 @@ export function make(input: { ) const restored = restoreFromMessages(messages.map((item) => item.info)) // altimate_change start — fail closed when a resumed session has no usable model - const model = restored.model ?? (yield* requireDefaultModel(snapshot)) + const model = availableModel(snapshot, restored.model) ?? (yield* requireDefaultModel(snapshot)) // altimate_change end const state = yield* session.load({ id: params.sessionId, @@ -376,7 +376,7 @@ export function make(input: { "session", ) const sourceRestored = restoreFromMessages(sourceMessages.map((item) => item.info)) - const fallbackModel = sourceRestored.model ?? (yield* requireDefaultModel(snapshot)) + const fallbackModel = availableModel(snapshot, sourceRestored.model) ?? (yield* requireDefaultModel(snapshot)) const forked = yield* request( () => input.sdk.session.fork( @@ -395,7 +395,7 @@ export function make(input: { ) const restored = restoreFromMessages(messages.map((item) => item.info)) // altimate_change start — fail closed when a fork has no usable model - const model = restored.model ?? fallbackModel + const model = availableModel(snapshot, restored.model) ?? fallbackModel // altimate_change end const state = yield* session.load({ id: forked.id, @@ -902,6 +902,15 @@ export function selectDefaultModel(snapshot: Directory.Snapshot) { return undefined } +function availableModel(snapshot: Directory.Snapshot, model: Directory.DefaultModel | undefined) { + if (!model) return undefined + return snapshot.modelOptions.some( + (option) => option.providerID === model.providerID && option.modelID === model.modelID, + ) + ? model + : undefined +} + function requireDefaultModel(snapshot: Directory.Snapshot) { const selected = selectDefaultModel(snapshot) return selected diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 31480cc47c..45033eabe8 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -178,19 +178,6 @@ async function markCredentialRejected(value: Pick): Promise { - clearRejectedCredentialInMemory(value) - await Flock.withLock(LOCK_KEY, async () => { - const stored = await FreeTierStore.read() - if (!stored?.apiKey || stored.apiKey !== value.apiKey || stored.baseURL !== value.baseURL || !stored.rejected) - return - const { rejected: _rejected, ...active } = stored - await FreeTierStore.write(active) - }).catch((error) => { - log.warn("failed to clear rejected Altimate Base credentials", { error }) - }) -} - async function installSecretForRegistration(): Promise { const stored = await FreeTierStore.read() if (stored?.installSecret) return stored.installSecret @@ -356,10 +343,11 @@ export async function authorizedFetch(input: RequestInfo | URL, init?: RequestIn const active = initial const response = await send(active)! - if (response.status !== 401) { - if (response.ok) await clearRejectedCredential(active) - return response - } + // 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 + // inference path lock-free after its initial credential read. + if (response.status !== 401) return response await markCredentialRejected(active) if (!isReplayable(input, init?.body)) return response @@ -380,7 +368,6 @@ export async function authorizedFetch(input: RequestInfo | URL, init?: RequestIn } const retryResponse = await retried if (retryResponse.status === 401) await markCredentialRejected(next) - else if (retryResponse.ok) await clearRejectedCredential(next) return retryResponse } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c26965e9ba..3bbc2567a4 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -2136,6 +2136,13 @@ export namespace Provider { if (cfg.model) return parseModel(cfg.model) const providers = await list() + const configuredProviderEntries = Object.keys(cfg.provider ?? {}) + const hasProviderAllowlist = configuredProviderEntries.length > 0 + // A provider block is an allowlist for implicit choices. The managed + // provider remains consent-gated, so naming it cannot activate it; an + // explicit top-level `model` above remains authoritative. + const providerAllowed = (id: string) => + !hasProviderAllowlist || (id !== FreeTier.PROVIDER_ID && configuredProviderEntries.includes(id)) const recent = (await Filesystem.readJson<{ recent?: { providerID: ProviderID; modelID: ModelID }[] }>( path.join(Global.Path.state, "model.json"), ) @@ -2145,19 +2152,19 @@ export namespace Provider { const provider = providers[entry.providerID] if (!provider) continue if (!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 return { providerID: entry.providerID, modelID: entry.modelID } } - const configuredProviderEntries = Object.keys(cfg.provider ?? {}) - const hasProviderAllowlist = configuredProviderEntries.length > 0 - // altimate_change start — default to altimate-backend when configured and no model chosen yet const altimateProviderID = ProviderID.make("altimate-backend") const altimateProvider = providers[altimateProviderID] if ( altimateProvider && altimateProvider.models[ModelID.make("altimate-default")] && - (!hasProviderAllowlist || configuredProviderEntries.includes(String(altimateProviderID))) + providerAllowed(String(altimateProviderID)) ) { // altimate_change start — log when altimate-backend auto-selected log.info("defaulting to altimate-backend/altimate-default (no model configured)") @@ -2180,10 +2187,7 @@ export namespace Provider { return { providerID: baseProviderID, modelID: ModelID.make(FreeTier.MODEL_ID) } } - const configuredProviderIDs = configuredProviderEntries.filter((id) => id !== FreeTier.PROVIDER_ID) - const candidates = Object.values(providers).filter( - (provider) => !hasProviderAllowlist || configuredProviderIDs.includes(provider.id), - ) + const candidates = Object.values(providers).filter((provider) => providerAllowed(provider.id)) if (candidates.length === 0) throw new Error("no providers found") for (const provider of candidates) { const model = sort(Object.values(provider.models)).find( @@ -2259,6 +2263,7 @@ export namespace Provider { // imperative wrappers (list/getModel/getLanguage/defaultModel/...) remain exported // for the fork's synchronous callers. export interface Interface { + readonly all: () => Effect.Effect> readonly list: () => Effect.Effect> readonly getProvider: (providerID: ProviderID) => Effect.Effect readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect @@ -2289,6 +2294,7 @@ export namespace Provider { export const layer = Layer.succeed( Service, Service.of({ + all: () => withLegacyInstance(() => all()), list: () => withLegacyInstance(() => list()), getProvider: (providerID) => withLegacyInstance(() => getProvider(providerID)), getModel: (providerID, modelID) => withLegacyInstance(() => getModel(providerID, modelID)), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index 43a7485743..58c5ea1413 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -10,6 +10,8 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { ProviderAuthApiError } from "../groups/provider" import { ProviderV2 } from "@opencode-ai/core/provider" +import { FreeTier } from "@/altimate/free/client" +import { ProviderID } from "@/provider/schema" function mapProviderAuthError(self: Effect.Effect) { return self.pipe( @@ -40,6 +42,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const list = Effect.fn("ProviderHttpApi.list")(function* () { const config = yield* cfg.get() const all = yield* ModelsDev.Service.use((s) => s.get()) + const database = yield* provider.all() const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record = {} @@ -47,12 +50,18 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value } const connected = yield* provider.list() + const managedBase = database[ProviderID.make(FreeTier.PROVIDER_ID)] + const managed = + managedBase && (enabled ? enabled.has(FreeTier.PROVIDER_ID) : true) && !disabled.has(FreeTier.PROVIDER_ID) + ? { [FreeTier.PROVIDER_ID]: managedBase } + : {} const providers = Object.assign( // altimate_change start — upstream_fix: widen readonly ModelsDev providers for Provider conversion // ModelsDev.Service yields a deeply-readonly Provider; fromModelsDevProvider only // reads it, so widen the readonly shape to the mutable signature it expects. mapValues(filtered, (item) => Provider.fromModelsDevProvider(item as Parameters[0])), // altimate_change end + managed, connected, ) return { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index e6ddab3878..d1c2477f1b 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -53,10 +53,6 @@ export namespace LLM { export type StreamOutput = StreamTextResult // altimate_change start — Altimate Base gateway session-scoped abuse control - export function managedSessionHeaders(providerID: string, sessionID: string): Record { - return providerID === "altimate-free" ? { "X-Session-Id": sessionID } : {} - } - export function withManagedSessionHeaders( providerID: string, sessionID: string, diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index fc38b43ee6..8f97bf8304 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -365,6 +365,32 @@ describe("ACP service sessions", () => { 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 () => { + const baseProvider = { + ...provider, + id: ProviderID.make("altimate-free"), + name: "Altimate", + models: { + [ModelID.make("altimate-base")]: { + ...provider.models[modelID], + id: ModelID.make("altimate-base"), + providerID: ProviderID.make("altimate-free"), + name: "Altimate Base", + }, + }, + } satisfies Provider.Info + const { service } = makeService([], { + providers: [provider, baseProvider], + providerConfig: { test: {}, "altimate-free": {} }, + }) + + 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("test-model"))).toBe(true) + }) + it("fails before forking when no supported implicit model exists", async () => { const bigPickleProvider = { ...provider, @@ -389,6 +415,26 @@ describe("ACP service sessions", () => { expect(forks).toHaveLength(0) }) + it("forks with the current default when the source model is no longer advertised", async () => { + const { service, forks } = makeService([ + { + info: { + role: "assistant", + providerID: "removed-provider", + modelID: "removed-model", + }, + parts: [], + }, + ]) + + const result = await Effect.runPromise( + service.forkSession({ cwd: "/workspace", sessionId: "ses_parent", mcpServers: [] }), + ) + + expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/test-model") + expect(forks).toHaveLength(1) + }) + it("loads a session and restores model variant and mode from messages", async () => { const { service } = makeService([ { diff --git a/packages/opencode/test/altimate/altimate-base.test.ts b/packages/opencode/test/altimate/altimate-base.test.ts index ebfa684545..d4f6a4f7f9 100644 --- a/packages/opencode/test/altimate/altimate-base.test.ts +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -320,6 +320,22 @@ describe("inference boundary", () => { expect(redirect).toBe("manual") }) + test("a successful request does not re-read or lock the credential store", async () => { + await seed() + const reads = spyOn(FreeTierStore, "read") + mockFetch(() => new Response("{}", { status: 200 })) + try { + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + expect(response.status).toBe(200) + expect(reads).toHaveBeenCalledTimes(1) + } finally { + reads.mockRestore() + } + }) + test("never sends a rotated credential issued for another origin", async () => { await seed() const authorizations: (string | null)[] = [] @@ -391,6 +407,23 @@ describe("inference boundary", () => { expect(await FreeTierStore.read()).toMatchObject({ rejected: true }) }) + test("a late success does not clear a rejection recorded by a concurrent 401", async () => { + await seed() + mockFetch(async () => { + await seed({ rejected: true }) + return new Response("{}", { status: 200 }) + }) + + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + + expect(response.status).toBe(200) + expect(await FreeTierStore.read()).toMatchObject({ rejected: true }) + expect(await FreeTier.credentialsForLoad()).toBeUndefined() + }) + test("explicit consent rotates an unexpired credential rejected by inference", async () => { await seed({ expiresAt: REGISTERED.expires_at }) const urls: string[] = [] diff --git a/packages/opencode/test/fake/provider.ts b/packages/opencode/test/fake/provider.ts index 896b45c561..a35636a5d5 100644 --- a/packages/opencode/test/fake/provider.ts +++ b/packages/opencode/test/fake/provider.ts @@ -52,6 +52,7 @@ export namespace ProviderTest { layer: Layer.succeed( Provider.Service, Provider.Service.of({ + all: Effect.fn("TestProvider.all")(() => Effect.succeed({ [row.id]: row })), list: Effect.fn("TestProvider.list")(() => Effect.succeed({ [row.id]: row })), getProvider: Effect.fn("TestProvider.getProvider")((providerID) => { if (providerID === row.id) return Effect.succeed(row) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 15132f9377..a328a3976e 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -13,6 +13,7 @@ import { ModelsCatalog } from "../../src/provider/models-catalog" import type { ModelsDev } from "../../src/provider/models" import { FreeTier } from "../../src/altimate/free/client" import { Auth } from "../../src/auth" +import { Global } from "../../src/global" const ALTIMATE_BASE_GATEWAY_URL = "https://gateway.test" @@ -205,6 +206,37 @@ test("an empty provider object remains unrestricted for Altimate Base", async () } }) +test("a provider allowlist filters a persisted Altimate Base recent before implicit selection", async () => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ + apiKey: "sk-altimate-base", + baseURL: ALTIMATE_BASE_GATEWAY_URL, + installSecret: "install-secret", + }) + const stateFile = path.join(Global.Path.state, "model.json") + const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) + try { + await fs.mkdir(Global.Path.state, { recursive: true }) + await fs.writeFile( + stateFile, + JSON.stringify({ recent: [{ providerID: FreeTier.PROVIDER_ID, modelID: FreeTier.MODEL_ID }] }), + ) + await using tmp = await tmpdir({ config: { provider: { anthropic: {} } } }) + await provideProviderTestInstance({ + directory: tmp.path, + 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) + }, + }) + } finally { + if (previous === undefined) await fs.rm(stateFile, { force: true }) + else await fs.writeFile(stateFile, previous) + credentials.mockRestore() + } +}) + test("provider loaded from env variable", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 50c34fe5cd..a28f21d651 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -268,9 +268,7 @@ describe("provider HttpApi", () => { if (providerResponse.status !== 200) { return yield* Effect.fail( - new Error( - `provider response ${providerResponse.status}: ${yield* Effect.promise(() => providerResponse.text())}`, - ), + new Error(`provider response ${providerResponse.status}: ${yield* Effect.promise(() => providerResponse.text())}`), ) } if (modelResponse.status !== 200) { @@ -288,6 +286,26 @@ describe("provider HttpApi", () => { 30000, ) + it.instance( + "advertises Altimate Base for consent without marking it connected", + Effect.gen(function* () { + const directory = (yield* TestInstance).directory + const response = yield* requestDefault("/provider", { + headers: { "x-opencode-directory": directory }, + }) + expect(response.status).toBe(200) + + const body = yield* responseJson(response) + const base = providerByID(body, "all", "altimate-free") + expect(base).toBeDefined() + expect(isRecord(base) && isRecord(base.models) && "altimate-base" in base.models).toBe(true) + expect(isRecord(body) && Array.isArray(body.connected) && body.connected.includes("altimate-free")).toBe(false) + expect(JSON.stringify(base)).not.toContain("sk-") + }), + projectOptions, + 30000, + ) + it.instance.skip( "returns public v2 provider not found errors", Effect.gen(function* () { @@ -429,7 +447,9 @@ describe("provider HttpApi", () => { if (providerResponse.status !== 200) { return yield* Effect.fail( - new Error(`provider response ${providerResponse.status}: ${yield* Effect.promise(() => providerResponse.text())}`), + new Error( + `provider response ${providerResponse.status}: ${yield* Effect.promise(() => providerResponse.text())}`, + ), ) } if (configResponse.status !== 200) { diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index ea23f96e71..2458a625b9 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -2,7 +2,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes import path from "path" import type { ModelMessage } from "ai" import { LLM } from "../../src/session/llm" -import { Global } from "../../src/global" import { Instance } from "../../src/project/instance" import { Provider } from "../../src/provider/provider" import { ProviderTransform } from "../../src/provider/transform" @@ -84,14 +83,7 @@ describe("session.llm.toolNamesFromMessages", () => { }) // altimate_change start — managed session header must never leak to third-party providers -describe("session.llm.managedSessionHeaders", () => { - test("adds the session ID only for Altimate Base", () => { - const sessionID = SessionID.make("session-header-test") - expect(LLM.managedSessionHeaders("altimate-free", sessionID)).toEqual({ "X-Session-Id": sessionID }) - expect(LLM.managedSessionHeaders("anthropic", sessionID)).toEqual({}) - expect(LLM.managedSessionHeaders("openai", sessionID)).toEqual({}) - }) - +describe("session.llm.withManagedSessionHeaders", () => { test("the managed session ID wins over plugin headers without changing other providers", () => { const sessionID = SessionID.make("ses_trusted-session") const pluginHeaders = { diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 3710b9f3b9..d3278f3098 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -127,6 +127,44 @@ export function DialogModel(props: { ), ) + // Big Pickle remains an explicit legacy choice even when the user has no + // paid OpenCode Zen key. Altimate Base replaces it only as the implicit + // free/default model; hiding this row would turn a still-usable free model + // into a provider-connection prompt. + const openCode = sync.data.provider.find((provider) => provider.id === "opencode") + const bigPickle = openCode?.models["big-pickle"] + if ( + openCode && + bigPickle && + bigPickle.status !== "deprecated" && + !providerReady("opencode") && + (!props.providerID || props.providerID === "opencode") + ) { + readyOptions.push({ + value: { providerID: "opencode", modelID: "big-pickle" }, + title: bigPickle.name ?? "Big Pickle", + description: openCode.name, + category: "READY", + footer: favorites.some((favorite) => favorite.providerID === "opencode" && favorite.modelID === "big-pickle") + ? "★" + : undefined, + onSelect() { + if (activated) return + activated = true + if (firstRunActive()) { + trackOnboarding({ + name: "provider_selected", + providerID: "opencode", + modelID: "big-pickle", + via_search: props.viaSearch ?? false, + }) + } + onSelect("opencode", "big-pickle") + markSetupComplete() + }, + }) + } + // NEEDS SETUP — providers without valid credentials (selecting routes into their // auth flow first), plus the Altimate Base disclosure. Hidden when scoped to one // provider (post-connect model list). @@ -172,8 +210,8 @@ export function DialogModel(props: { description: "free, no signup — prompts are logged", category: "NEEDS SETUP", footer: undefined as string | undefined, - async onSelect() { - if (activated) return + onSelect() { + if (activated) return undefined activated = true if (firstRunActive()) { trackOnboarding({ @@ -184,6 +222,7 @@ export function DialogModel(props: { }) } dialog.replace(() => ) + return undefined }, } const zenIdx = list.findIndex((o) => o.value === "opencode") diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 2666aa4742..66452ca254 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -155,6 +155,7 @@ function init() { return true }, replace(input: any, onClose?: () => void) { + if (!canClose()) return false if (store.stack.length === 0) { focus = renderer.currentFocusedRenderable focus?.blur() @@ -169,6 +170,7 @@ function init() { onClose, }, ]) + return true }, get stack() { return store.stack diff --git a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx index d4ac4c30c4..1997fac859 100644 --- a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx +++ b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx @@ -73,6 +73,7 @@ async function mountConfirm( markFirstRunActive() const events: OnboardingTelemetryEvent[] = [] const registrations: true[] = [] + let replaceDialog = () => false const model = { id: "altimate-base", providerID: "altimate-free", @@ -84,13 +85,28 @@ async function mountConfirm( limit: { context: 65_536, output: 4_096 }, } const provider = { id: "altimate-free", name: "Altimate", models: { "altimate-base": model }, env: [] } + const bigPickle = { + ...model, + id: "big-pickle", + providerID: "opencode", + name: "Big Pickle", + family: "glm", + } + const openCodeProvider = { id: "opencode", name: "OpenCode Zen", models: { "big-pickle": bigPickle }, env: [] } const inner = createFetch((url) => { if (url.pathname === "/instance/dispose") return json({}) if (url.pathname === "/config/providers") { - return json({ providers: input.modelAvailable === false ? [] : [provider], default: {} }) + return json({ + providers: input.modelAvailable === false ? [openCodeProvider] : [provider, openCodeProvider], + default: {}, + }) } if (url.pathname === "/provider") { - return json({ all: [provider], default: {}, connected: input.modelAvailable === false ? [] : ["altimate-free"] }) + return json({ + all: [provider, openCodeProvider], + default: {}, + connected: input.modelAvailable === false ? ["opencode"] : ["altimate-free", "opencode"], + }) } return undefined }) @@ -105,6 +121,7 @@ async function mountConfirm( function OpenConfirm() { const dialog = useDialog() + replaceDialog = () => dialog.replace(() => Session list replacement) onMount(() => dialog.replace(() => )) return null } @@ -169,6 +186,7 @@ async function mountConfirm( disclosure: ALTIMATE_BASE_DISCLOSURE, setupComplete: useSetupComplete(), registrations: () => registrations, + replaceDialog: () => replaceDialog(), cleanup() { app.renderer.destroy() resetSetupComplete() @@ -182,8 +200,7 @@ test.serial("Altimate Base shows the privacy disclosure before registration and const frame = confirm.app.captureCharFrame() expect(confirm.disclosure).toContain("Requests and responses are logged") expect(frame).toContain("Use Altimate Base?") - expect(frame).toContain("Requests") - expect(frame).toContain("responses are logged and may be used") + expect(frame.replace(/\s+/g, " ")).toContain("Requests and responses are logged and may be used") expect(frame).toContain("No — pick something else") expect(frame).toContain("(default)") expect(confirm.registrations()).toHaveLength(0) @@ -200,6 +217,25 @@ test.serial("declining Altimate Base makes no registration request", async () => await waitUntil(() => confirm.events.some((event) => event.name === "altimate_base_choice")) expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "cancel" }) expect(confirm.registrations()).toHaveLength(0) + confirm.app.mockInput.pressKey("/") + await confirm.app.renderOnce() + expect(confirm.app.captureCharFrame()).toContain("Big Pickle") + confirm.app.mockInput.pressArrow("down") + await confirm.app.renderOnce() + confirm.app.mockInput.pressEnter() + await waitUntil(() => + confirm.events.some( + (event) => + event.name === "provider_selected" && event.providerID === "opencode" && event.modelID === "big-pickle", + ), + ) + expect(confirm.events).toContainEqual({ + name: "provider_selected", + providerID: "opencode", + modelID: "big-pickle", + via_search: true, + }) + expect(confirm.registrations()).toHaveLength(0) } finally { confirm.cleanup() } @@ -269,6 +305,9 @@ test.serial("dismissal keys and backdrop clicks are ignored while registration i 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") confirm.app.mockInput.pressKey("escape") await confirm.app.renderOnce() expect(confirm.app.captureCharFrame()).toContain("Setting up…") From 335168d388940bcb00b129f937eeb1985199533f Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 02:51:51 -0700 Subject: [PATCH 13/33] fix: close release CI gaps --- packages/opencode/src/acp/service.ts | 9 ++++++++- packages/opencode/src/provider/provider.ts | 6 ++++++ .../routes/instance/httpapi/handlers/provider.ts | 8 ++++++++ packages/opencode/src/session/llm.ts | 2 ++ packages/opencode/test/acp/event.test.ts | 12 ++++++++++++ .../test/upstream/adversarial/upi-provider.test.ts | 4 +++- packages/tui/src/ui/dialog.tsx | 14 ++++++++++++++ 7 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 0ef215542f..30d1a07a84 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -365,6 +365,7 @@ export function make(input: { const forkSession = Effect.fn("ACP.forkSession")(function* (params: ForkSessionRequest) { const snapshot = yield* directorySnapshot(params.cwd) + // altimate_change start — resolve the source model before persisting an ACP fork // Resolve a usable model from the source session before creating any persistent fork. The // forked transcript is read again below because the server may trim it at the fork boundary. const sourceMessages = yield* request( @@ -377,6 +378,7 @@ export function make(input: { ) const sourceRestored = restoreFromMessages(sourceMessages.map((item) => item.info)) const fallbackModel = availableModel(snapshot, sourceRestored.model) ?? (yield* requireDefaultModel(snapshot)) + // altimate_change end const forked = yield* request( () => input.sdk.session.fork( @@ -467,8 +469,9 @@ export function make(input: { if (!snapshot.availableModes.some((mode) => mode.id === params.value)) { return yield* new ACPError.InvalidModeError({ mode: params.value }) } - // Validate the complete resulting state before mutating the session mode. + // altimate_change start — validate the complete resulting state before mutating the session mode const model = current.model ?? (yield* requireDefaultModel(snapshot)) + // altimate_change end const state = yield* session.setMode(params.sessionId, params.value) return { configOptions: configOptions(snapshot, { @@ -772,6 +775,7 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { ProviderV2.ID, Provider.Info > + // altimate_change start — apply the project provider allowlist to ACP model advertisement const configuredProviderEntries = Object.keys(configResponse?.data?.provider ?? {}) const hasProviderAllowlist = configuredProviderEntries.length > 0 const configuredModelProvider = configResponse?.data?.model @@ -789,6 +793,7 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { ), ) as Record) : providers + // altimate_change end const defaultModelStarted = performance.now() const defaultModel = defaultModelFromConfig( configResponse?.data?.model, @@ -818,7 +823,9 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { return Directory.build({ directory, + // altimate_change start — expose only providers admitted by the ACP snapshot policy above providers: snapshotProviders, + // altimate_change end modes, defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build", commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 3bbc2567a4..0dba30322e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -2136,6 +2136,7 @@ export namespace Provider { if (cfg.model) return parseModel(cfg.model) const providers = await list() + // altimate_change start — preserve explicit/recent precedence without bypassing managed consent const configuredProviderEntries = Object.keys(cfg.provider ?? {}) const hasProviderAllowlist = configuredProviderEntries.length > 0 // A provider block is an allowlist for implicit choices. The managed @@ -2157,6 +2158,7 @@ export namespace Provider { if (entry.providerID === FreeTier.PROVIDER_ID && !providerAllowed(String(entry.providerID))) continue return { providerID: entry.providerID, modelID: entry.modelID } } + // altimate_change end // altimate_change start — default to altimate-backend when configured and no model chosen yet const altimateProviderID = ProviderID.make("altimate-backend") @@ -2263,7 +2265,9 @@ export namespace Provider { // imperative wrappers (list/getModel/getLanguage/defaultModel/...) remain exported // for the fork's synchronous callers. export interface Interface { + // altimate_change start — expose the full provider database to the public-info handler readonly all: () => Effect.Effect> + // altimate_change end readonly list: () => Effect.Effect> readonly getProvider: (providerID: ProviderID) => Effect.Effect readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect @@ -2294,7 +2298,9 @@ export namespace Provider { export const layer = Layer.succeed( Service, Service.of({ + // altimate_change start — Effect wrapper for the full provider database all: () => withLegacyInstance(() => all()), + // altimate_change end list: () => withLegacyInstance(() => list()), getProvider: (providerID) => withLegacyInstance(() => getProvider(providerID)), getModel: (providerID, modelID) => withLegacyInstance(() => getModel(providerID, modelID)), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index 58c5ea1413..01203948ac 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -10,8 +10,10 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { ProviderAuthApiError } from "../groups/provider" import { ProviderV2 } from "@opencode-ai/core/provider" +// altimate_change start — advertise managed Altimate Base before credential consent import { FreeTier } from "@/altimate/free/client" import { ProviderID } from "@/provider/schema" +// altimate_change end function mapProviderAuthError(self: Effect.Effect) { return self.pipe( @@ -42,7 +44,9 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const list = Effect.fn("ProviderHttpApi.list")(function* () { const config = yield* cfg.get() const all = yield* ModelsDev.Service.use((s) => s.get()) + // altimate_change start — add managed model metadata without claiming a connected credential const database = yield* provider.all() + // altimate_change end const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record = {} @@ -50,18 +54,22 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value } const connected = yield* provider.list() + // altimate_change start — advertise enabled Altimate Base independently of connected providers const managedBase = database[ProviderID.make(FreeTier.PROVIDER_ID)] const managed = managedBase && (enabled ? enabled.has(FreeTier.PROVIDER_ID) : true) && !disabled.has(FreeTier.PROVIDER_ID) ? { [FreeTier.PROVIDER_ID]: managedBase } : {} + // altimate_change end const providers = Object.assign( // altimate_change start — upstream_fix: widen readonly ModelsDev providers for Provider conversion // ModelsDev.Service yields a deeply-readonly Provider; fromModelsDevProvider only // reads it, so widen the readonly shape to the mutable signature it expects. mapValues(filtered, (item) => Provider.fromModelsDevProvider(item as Parameters[0])), // altimate_change end + // altimate_change start — merge managed metadata without adding it to connected managed, + // altimate_change end connected, ) return { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index d1c2477f1b..bf6257a26e 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -267,6 +267,7 @@ export namespace LLM { maxOutputTokens: params.maxOutputTokens, // altimate_change end abortSignal: input.abort, + // altimate_change start — bind Altimate Base requests to the current session headers: withManagedSessionHeaders(input.model.providerID, input.sessionID, { ...(input.model.providerID.startsWith("opencode") ? { @@ -285,6 +286,7 @@ export namespace LLM { ...input.model.headers, ...headers, }), + // altimate_change end maxRetries: input.retries ?? 0, messages: [ ...system.map( diff --git a/packages/opencode/test/acp/event.test.ts b/packages/opencode/test/acp/event.test.ts index 42370d07f5..48cc461527 100644 --- a/packages/opencode/test/acp/event.test.ts +++ b/packages/opencode/test/acp/event.test.ts @@ -487,6 +487,12 @@ describe("acp event routing", () => { modes: [], defaultModeID: "build", commands: [], + // altimate_change start — satisfy the fail-closed ACP model boundary + defaultModel: { + providerID: ProviderV2.ID.make("fixture"), + modelID: ModelV2.ID.make("fixture"), + }, + // altimate_change end }), ), refresh: () => @@ -497,6 +503,12 @@ describe("acp event routing", () => { modes: [], defaultModeID: "build", commands: [], + // altimate_change start — satisfy the fail-closed ACP model boundary + defaultModel: { + providerID: ProviderV2.ID.make("fixture"), + modelID: ModelV2.ID.make("fixture"), + }, + // altimate_change end }), ), variants: Directory.variants, diff --git a/packages/opencode/test/upstream/adversarial/upi-provider.test.ts b/packages/opencode/test/upstream/adversarial/upi-provider.test.ts index 8ec3a3b3b0..8e0517bdf9 100644 --- a/packages/opencode/test/upstream/adversarial/upi-provider.test.ts +++ b/packages/opencode/test/upstream/adversarial/upi-provider.test.ts @@ -191,6 +191,8 @@ describe("UPI-16 and UPI-42 provider defaults and gateway prompt routing", () => expect(body.indexOf("for (const entry of recent)")).toBeLessThan(body.indexOf("default to altimate-backend")) expect(body).toContain('providers[altimateProviderID]') expect(body).toContain('ModelID.make("altimate-default")') - expect(body).toContain('Object.keys(cfg.provider).includes(String(altimateProviderID))') + // altimate_change start — the managed-consent-aware allowlist helper owns this check now + expect(body).toContain("providerAllowed(String(altimateProviderID))") + // altimate_change end }) }) diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 66452ca254..4ef0f5c45c 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -74,11 +74,13 @@ function init() { const renderer = useRenderer() const modeStack = useOpencodeModeStack() + // altimate_change start — allow a modal to veto every dialog replacement/close path let closeGuard: (() => boolean) | undefined function canClose() { return closeGuard?.() ?? true } + // altimate_change end createEffect(() => { if (store.stack.length === 0) return @@ -104,6 +106,7 @@ function init() { }, 1) } + // altimate_change start — centralize guarded single-dialog close behavior function closeTop() { if (!canClose()) return false const current = store.stack.at(-1) @@ -112,6 +115,7 @@ function init() { refocus() return true } + // altimate_change end useBindings(() => ({ enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(), @@ -124,7 +128,9 @@ function init() { if (renderer.getSelection()) { renderer.clearSelection() } + // altimate_change start — honor the active close guard on Escape closeTop() + // altimate_change end }, }, { @@ -135,7 +141,9 @@ function init() { if (renderer.getSelection()) { renderer.clearSelection() } + // altimate_change start — honor the active close guard on Ctrl-C closeTop() + // altimate_change end }, }, ], @@ -143,6 +151,7 @@ function init() { return { clear() { + // altimate_change start — guard and report bulk dialog closure if (!canClose()) return false for (const item of store.stack) { if (item.onClose) item.onClose() @@ -153,8 +162,10 @@ function init() { }) refocus() return true + // altimate_change end }, replace(input: any, onClose?: () => void) { + // altimate_change start — replacement is a close path and must obey the same guard if (!canClose()) return false if (store.stack.length === 0) { focus = renderer.currentFocusedRenderable @@ -171,6 +182,7 @@ function init() { }, ]) return true + // altimate_change end }, get stack() { return store.stack @@ -181,12 +193,14 @@ function init() { setSize(size: "medium" | "large" | "xlarge") { setStore("size", size) }, + // altimate_change start — install and safely dispose the active close guard guardClose(guard: () => boolean) { closeGuard = guard return () => { if (closeGuard === guard) closeGuard = undefined } }, + // altimate_change end } } From c63922f31797a38ff78234d8a16b1d0101ee559b Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 03:33:24 -0700 Subject: [PATCH 14/33] fix: close late Altimate Base review findings --- packages/opencode/src/altimate/free/client.ts | 6 +++--- packages/opencode/src/altimate/free/consent.ts | 2 ++ packages/opencode/src/altimate/free/url.ts | 2 ++ packages/opencode/src/cli/tui/worker.ts | 4 ++-- packages/opencode/src/session/llm.ts | 3 +-- .../opencode/test/altimate/altimate-base.test.ts | 12 ++++++------ packages/tui/src/ui/dialog.tsx | 8 ++++---- .../tui/test/cli/tui/dialog-altimate-base.test.tsx | 2 +- 8 files changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 45033eabe8..8b06794a7a 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -3,7 +3,7 @@ import { Flock } from "@opencode-ai/core/util/flock" import { Installation } from "../../installation" import { Log } from "../util/log" import { FreeTierStore } from "./store" -import { normalizeGatewayUrl } from "./url" +import { FreeTierUrl } from "./url" const log = Log.create({ service: "altimate-base" }) @@ -57,7 +57,7 @@ export function gatewayUrl(): string { process.env["ALTIMATE_BASE_GATEWAY_URL"]?.trim() || process.env["ALTIMATE_FREE_GATEWAY_URL"]?.trim() || embedded - const normalized = normalizeGatewayUrl(configured) + const normalized = FreeTierUrl.normalizeGatewayUrl(configured) if (!normalized) { throw new ConfigurationError( configured @@ -219,7 +219,7 @@ async function registerOnce(configuredGateway: string, signal?: AbortSignal): Pr | { api_key?: unknown; base_url?: unknown; expires_at?: unknown; model?: unknown } | undefined const apiKey = typeof body?.api_key === "string" ? body.api_key.trim() : "" - const baseURL = typeof body?.base_url === "string" ? normalizeGatewayUrl(body.base_url) : undefined + const baseURL = typeof body?.base_url === "string" ? FreeTierUrl.normalizeGatewayUrl(body.base_url) : undefined const expiresAtPresent = body?.expires_at !== undefined const expiresAt = typeof body?.expires_at === "string" ? body.expires_at.trim() : undefined const expiresAtTimestamp = expiresAt ? Date.parse(expiresAt) : undefined diff --git a/packages/opencode/src/altimate/free/consent.ts b/packages/opencode/src/altimate/free/consent.ts index 0f3d194a45..aecc94d178 100644 --- a/packages/opencode/src/altimate/free/consent.ts +++ b/packages/opencode/src/altimate/free/consent.ts @@ -107,3 +107,5 @@ export function createRegistrationConsentGate(input: { }, } } + +export * as FreeTierConsent from "./consent" diff --git a/packages/opencode/src/altimate/free/url.ts b/packages/opencode/src/altimate/free/url.ts index fb64b29a02..3681a555a5 100644 --- a/packages/opencode/src/altimate/free/url.ts +++ b/packages/opencode/src/altimate/free/url.ts @@ -12,3 +12,5 @@ export function normalizeGatewayUrl(value: string): string | undefined { return undefined } } + +export * as FreeTierUrl from "./url" diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index a423fa12f7..a422809684 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -29,7 +29,7 @@ import { Telemetry } from "@/altimate/telemetry" import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" // altimate_change start — register Altimate Base only across the private parent/worker RPC boundary import { FreeTier } from "@/altimate/free/client" -import { createRegistrationConsentGate } from "@/altimate/free/consent" +import { FreeTierConsent } from "@/altimate/free/consent" // altimate_change end // altimate_change — shared with the withTimeout budget in cli/cmd/tui.ts stop(), so the coupling @@ -67,7 +67,7 @@ GlobalBus.on("event", (event) => { let server: Awaited> | undefined // altimate_change start — worker-local, expiring capabilities gate every registration mutation -const altimateBaseRegistration = createRegistrationConsentGate({ +const altimateBaseRegistration = FreeTierConsent.createRegistrationConsentGate({ register: () => FreeTier.registerAfterConsent(), onUnexpectedError: (error) => console.error("[altimate-base] registration failed", error), }) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index bf6257a26e..d23a40cf6e 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -278,9 +278,8 @@ export namespace LLM { } : input.model.providerID !== "anthropic" ? { - // altimate_change start — upstream_fix: UA brand + // upstream_fix: UA brand "User-Agent": `altimate-code/${Installation.VERSION}`, - // altimate_change end } : undefined), ...input.model.headers, diff --git a/packages/opencode/test/altimate/altimate-base.test.ts b/packages/opencode/test/altimate/altimate-base.test.ts index d4f6a4f7f9..832d3abc2b 100644 --- a/packages/opencode/test/altimate/altimate-base.test.ts +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -21,7 +21,7 @@ process.env.OPENCODE_TEST_HOME = temporaryHome const { FreeTier } = await import("../../src/altimate/free/client") const { FreeTierStore } = await import("../../src/altimate/free/store") -const { ConsentCapabilityStore, createRegistrationConsentGate } = await import("../../src/altimate/free/consent") +const { FreeTierConsent } = await import("../../src/altimate/free/consent") const GATEWAY_URL = "https://gateway.test" const REGISTERED = { @@ -320,7 +320,7 @@ describe("inference boundary", () => { expect(redirect).toBe("manual") }) - test("a successful request does not re-read or lock the credential store", async () => { + test("a successful request reads the credential store exactly once", async () => { await seed() const reads = spyOn(FreeTierStore, "read") mockFetch(() => new Response("{}", { status: 200 })) @@ -451,7 +451,7 @@ describe("consent boundary", () => { const first = "a".repeat(64) const second = "b".repeat(64) let registrations = 0 - const gate = createRegistrationConsentGate({ + const gate = FreeTierConsent.createRegistrationConsentGate({ register: async () => { registrations++ }, @@ -468,7 +468,7 @@ describe("consent boundary", () => { test("pending capabilities are bounded and expire", () => { let now = 1_000 - const capabilities = new ConsentCapabilityStore({ maxPending: 2, ttlMs: 50, now: () => now }) + const capabilities = new FreeTierConsent.ConsentCapabilityStore({ maxPending: 2, ttlMs: 50, now: () => now }) const first = "a".repeat(64) const second = "b".repeat(64) const third = "c".repeat(64) @@ -483,7 +483,7 @@ describe("consent boundary", () => { test("only transport failures are surfaced as network failures", async () => { const token = "d".repeat(64) - const network = createRegistrationConsentGate({ + const network = FreeTierConsent.createRegistrationConsentGate({ register: async () => { throw new FreeTier.RegistrationError("offline", "network") }, @@ -491,7 +491,7 @@ describe("consent boundary", () => { network.setToken({ token }) expect(await network.register({ token })).toMatchObject({ ok: false, result: "network" }) - const invalidResponse = createRegistrationConsentGate({ + const invalidResponse = FreeTierConsent.createRegistrationConsentGate({ register: async () => { throw new FreeTier.RegistrationError("invalid", "response") }, diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 4ef0f5c45c..5ef1d5d451 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -125,11 +125,11 @@ function init() { desc: "Close dialog", group: "Dialog", cmd: () => { + // altimate_change start — preserve selection when the active close guard vetoes Escape + if (!closeTop()) return if (renderer.getSelection()) { renderer.clearSelection() } - // altimate_change start — honor the active close guard on Escape - closeTop() // altimate_change end }, }, @@ -138,11 +138,11 @@ function init() { desc: "Close dialog", group: "Dialog", cmd: () => { + // altimate_change start — preserve selection when the active close guard vetoes Ctrl-C + if (!closeTop()) return if (renderer.getSelection()) { renderer.clearSelection() } - // altimate_change start — honor the active close guard on Ctrl-C - closeTop() // altimate_change end }, }, diff --git a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx index 1997fac859..279edfdb3b 100644 --- a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx +++ b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx @@ -92,7 +92,7 @@ async function mountConfirm( name: "Big Pickle", family: "glm", } - const openCodeProvider = { id: "opencode", name: "OpenCode Zen", models: { "big-pickle": bigPickle }, env: [] } + const openCodeProvider = { id: "opencode", name: "Legacy Zen", models: { "big-pickle": bigPickle }, env: [] } const inner = createFetch((url) => { if (url.pathname === "/instance/dispose") return json({}) if (url.pathname === "/config/providers") { From 04fceafcf88df2becc6dd4b23cb2f561c8a48056 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 03:33:24 -0700 Subject: [PATCH 15/33] docs: add Altimate Base release handoff --- .../release-readiness.md | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 research/altimate-base-release-2026-08-30/release-readiness.md diff --git a/research/altimate-base-release-2026-08-30/release-readiness.md b/research/altimate-base-release-2026-08-30/release-readiness.md new file mode 100644 index 0000000000..bf47988c14 --- /dev/null +++ b/research/altimate-base-release-2026-08-30/release-readiness.md @@ -0,0 +1,140 @@ +# Altimate Base release readiness + +Date: 2026-08-30 +Scope: hosted Qwen 3.8 release, free-tier gateway, Altimate Base client integration, and harness reliability work + +## Release decision + +The implementation is code-complete and the serving path has passed live staging acceptance. It is not yet a completed production release: the three release PRs still need human approval and merge, and the final late-review gateway commit must be rolled onto the staging VM after the Google Cloud operator session is re-authenticated. + +The intended public contract is now stable: + +- `altimate-base` is the implicit managed default for users who have not configured a model. +- Big Pickle is no longer an implicit default. It remains available only when a user explicitly selects or persists that legacy model. +- The public alias is insulated from the hosted implementation. The gateway currently maps `altimate-base` to the Qwen 3.8 deployment, so a future backend change does not require a client release. +- Registration and inference remain consented, metered, rate-limited, and fail-closed. + +## Pull-request map and release order + +| Repository | PR | Purpose | Current disposition | +| --- | --- | --- | --- | +| `AltimateAI/altimate-gateway` | #1 | Free-tier issuer, accounting, abuse controls, routing, autoscaler, deployment safety, and configurable bootstrap URL | Release branch pushed through `11877e6`; CI and human approval required | +| `AltimateAI/altimate-code` | #1199 | Altimate Base provider, consent flow, managed credentials, default-model replacement, ACP/session persistence, and safety tests | Final review fixes prepared; CI and human approval required | +| `AltimateAI/altimate-code` | #1171 | Harness/session reliability improvements developed alongside the model work | All observed checks green; human approval required | +| `AltimateAI/altimate-code` | #1115 | Earlier free Gemini experiment | Superseded by #1199; branch is conflicting and its secret scan is failing | + +Recommended order: + +1. Roll gateway commit `11877e6` to staging and repeat the public smoke test. +2. Approve and merge gateway PR #1. +3. Approve and merge client PR #1199, then build the release artifact with the official gateway endpoint embedded. +4. Merge harness PR #1171 independently once its approval is available. +5. Close #1115 as superseded only after an owner explicitly confirms that action. + +## Client behavior + +Altimate Base is isolated behind provider ID `altimate-free` and model ID `altimate-base`. The managed credential never enters public provider metadata: provider discovery receives a placeholder while inference injects the real credential only at the authorized transport boundary. + +Registration can occur only after the TUI disclosure is accepted. A worker-local, one-shot capability crosses the private parent/worker RPC boundary; it is bounded, expires quickly, and is consumed exactly once. Provider discovery, startup, rejected inference, and session restoration do not register silently. + +The default-selection rules are deliberately narrow: + +- An install with no usable configured model offers Altimate Base and shows the disclosure before registration. +- Declining the disclosure performs no registration and leaves setup recoverable. +- An explicitly configured Big Pickle session continues to load, fork, and resume as Big Pickle. +- A persisted Altimate Base session continues to load, fork, and resume as Altimate Base. +- Other providers retain their existing selection, headers, credentials, and runtime paths. + +The registration dialog cannot be dismissed or replaced while registration is active. Escape, Ctrl-C, backdrop clicks, and programmatic replacement all honor the same close guard, without discarding a selection when a close is vetoed. + +## Configurable gateway URL + +The client and bootstrap tools do not depend on a permanently fixed source URL. + +Client URL precedence is: + +1. `ALTIMATE_BASE_GATEWAY_URL` +2. legacy `ALTIMATE_FREE_GATEWAY_URL` +3. the release-time embedded official endpoint + +Source builds intentionally fail closed when none is configured. Accepted endpoints must use HTTPS, cannot contain user information, query strings, or fragments, and are normalized without a trailing slash. Registration responses are rejected if they attempt to redirect the managed credential to a different origin or path. + +The standalone gateway bootstrap uses `ALTIMATE_BASE_GATEWAY_URL`, with `ALTIMATE_GATEWAY_URL` retained as a compatibility fallback. Its cached credential now records the registration endpoint separately from the returned inference base. This matters for local and future topologies where `/register` and `/v1` are served on different ports or origins; rerunning the bootstrap no longer rotates a valid key merely because those endpoints differ. + +## Gateway and abuse controls + +The staging gateway uses a public alias, a per-install registration secret hash, short-lived managed keys, a lifetime wallet allowance, request and registration rate limits, a kill switch, and an issuer-owned accounting ledger. Unsupported models and endpoints are denied. + +Release hardening completed during this pass includes: + +- The serving API key moved out of instance metadata into Google Secret Manager. The attached VM service account has only the required secret access, and the actual key is absent from startup metadata. +- A legacy `llama-server.service` was found recreating an unauthenticated container on the old port. The service was disabled on the live WARM VM, and tier startup now removes only the explicitly configured legacy service/container names. +- The bundled registration proxy was initially configured with zero trusted hops, which would have collapsed all public clients into one rate-limit bucket. The live proxy trust topology was corrected and the preflight now rejects that unsafe configuration. +- A master credential present in the Claude trace was rotated. The trace value is revoked and the replacement was rolled across gateway services. +- Billing reconciliation now waits on the same advisory lock as the usage-debit pipeline, eliminating false drift reports between spend capture and ledger materialization. +- Autoscaler schedule edits are validated before becoming the last-good configuration. Invalid time zones, weekdays, time windows, and alert cooldowns are rejected. +- Repeated thrash, budget-refusal, and wake-timeout alerts use a persisted per-event cooldown instead of posting once per poll. +- Monthly GPU spend is written to a redundant atomic checkpoint. If the main state file is missing or corrupt, spend is recovered; if neither copy is trustworthy, new scale-ups are refused. + +These controls are scoped to the Altimate Base gateway and its named serving tiers. They do not change routing or credentials for other client models. + +## Verification evidence + +### Live public path + +The deployed staging serving path passed the end-to-end smoke suite 8/8: + +- first registration +- same-install key rotation +- real streamed Qwen completion +- terminal usage and stream completion marker +- denied unsupported model +- denied embeddings endpoint +- denied unauthenticated inference +- correct registration response shape + +A separate forced-tool-call request through the public `altimate-base` alias returned the alias in the response, emitted a tool call, finished with `tool_calls`, included terminal usage, and ended with the stream completion marker. The same acceptance passed after the master-key rotation. + +WARM serving returned 401 without its serving key and 200 with the key. The managed container was healthy with no restarts. A second client identity received its own successful registration after the proxy fix, demonstrating that registration limiting is per client rather than per proxy. + +### Gateway repository + +- Complete pytest suite: green, with only the expected integration skips. +- Fresh focused regression set: 159 tests green. +- CI-pinned Ruff 0.9.10: green for `issuer`. +- ShellCheck and Bash syntax checks: green for both bootstrap copies. +- Checked-in autoscaler configuration: structurally valid with a non-secret test serving key. +- Both copies of the bootstrap script are byte-identical. + +### Client repository + +- Altimate Base transport, registration, consent, and credential tests: 27 green. +- Altimate Base TUI disclosure and close-guard tests: 6 green. +- Earlier full focused client run: 37 green with one intentional todo. +- Root typecheck: 13/13 workspaces green after the final review edits. +- Branding, changed-file marker, and strict required-marker audits: green after the final report was added. + +### Harness repository + +- Focused harness regression set: 53 green. +- Earlier expanded harness set: 219 green. +- Root typecheck: green. +- Strict fork-marker analysis: green. +- Full repository run completed with 11,998 passes; the seven failures were pre-existing MCP contamination/timeouts outside the harness diff. + +## Rollback and operational safety + +Gateway deployment uses the checked-in blue/green rolling script. It validates configuration and database readiness, starts the inactive color, switches Nginx atomically, recreates the issuer/background jobs, verifies them, and restores both traffic and application services on failure. Recent staging rolls exercised this path repeatedly, including after credential rotation. + +If the client release must be rolled back, users with explicit models remain unaffected. The prior client artifact can be restored without changing the gateway alias. If the hosted backend must change, update the gateway mapping behind `altimate-base`; do not change the public model ID. + +The live environment has mode-0600 pre-roll backups for the relevant environment transitions. No raw gateway key, master key, or trace credential is recorded in this document. + +## Remaining release actions + +- Re-authenticate the Google Cloud account that owns Compute access. +- Deploy exact gateway commit `11877e6` with `scripts/rolling_deploy.sh`, verify the source marker and container health, then rerun the public smoke and forced-tool-call checks. +- Wait for the newly pushed gateway CI run and the final client CI run. +- Obtain the required human approvals on #1, #1199, and #1171. +- Merge in the order above and publish the client artifact with the official endpoint embedded. +- Observe registration 429s, wallet debits/high-water progress, backend health, and autoscaler decisions during the initial release window. From 755b410787b1293a3832dfe06d30763e7aedddf2 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 08:13:25 -0700 Subject: [PATCH 16/33] fix: harden MCP discovery and ClickHouse TLS --- packages/core/src/util/glob.ts | 1 + packages/core/test/util/glob.test.ts | 5 +- packages/drivers/src/clickhouse.ts | 9 +- packages/drivers/test/clickhouse-unit.test.ts | 20 ++ .../src/altimate/datamate-transport.ts | 22 +- packages/opencode/src/mcp/discover.ts | 52 ++-- packages/opencode/src/mcp/discovery-files.ts | 66 +++++ .../test/altimate/connections.test.ts | 2 + .../test/altimate/driver-normalize.test.ts | 9 + packages/opencode/test/mcp/discover.test.ts | 34 ++- .../mcp-datamate-893-codex.test.ts | 24 +- .../release-readiness.md | 26 +- .../security-fix-verification.md | 72 +++++ .../security-review-snapshot.md | 245 ++++++++++++++++++ 14 files changed, 516 insertions(+), 71 deletions(-) create mode 100644 packages/opencode/src/mcp/discovery-files.ts create mode 100644 research/altimate-base-release-2026-08-30/security-fix-verification.md create mode 100644 research/altimate-base-release-2026-08-30/security-review-snapshot.md diff --git a/packages/core/src/util/glob.ts b/packages/core/src/util/glob.ts index cd73b40ffc..d2509cfa9d 100644 --- a/packages/core/src/util/glob.ts +++ b/packages/core/src/util/glob.ts @@ -27,6 +27,7 @@ export namespace Glob { "**/vendor/**", "**/.git/**", "**/.pnpm/**", + "**/.yarn/unplugged/**", "**/.venv/**", "**/.turbo/**", ] diff --git a/packages/core/test/util/glob.test.ts b/packages/core/test/util/glob.test.ts index d305552b17..847b4f1590 100644 --- a/packages/core/test/util/glob.test.ts +++ b/packages/core/test/util/glob.test.ts @@ -16,12 +16,14 @@ beforeAll(async () => { root = await mkdtemp(path.join(tmpdir(), "glob-ignore-")) await mkdir(path.join(root, "src"), { recursive: true }) await mkdir(path.join(root, "node_modules", "pkg", ".vscode"), { recursive: true }) + await mkdir(path.join(root, ".yarn", "unplugged", "pkg"), { recursive: true }) await mkdir(path.join(root, "vendor"), { recursive: true }) await mkdir(path.join(root, "dist"), { recursive: true }) await mkdir(path.join(root, ".vscode"), { recursive: true }) await writeFile(path.join(root, ".vscode", "mcp.json"), "{}") await writeFile(path.join(root, "src", "mcp.json"), "{}") await writeFile(path.join(root, "node_modules", "pkg", ".vscode", "mcp.json"), "{}") + await writeFile(path.join(root, ".yarn", "unplugged", "pkg", "mcp.json"), "{}") await writeFile(path.join(root, "vendor", "mcp.json"), "{}") await writeFile(path.join(root, "dist", "mcp.json"), "{}") }) @@ -37,6 +39,7 @@ describe("Glob.scan ignore", () => { const found = (await Glob.scan("**/mcp.json", { cwd: root, absolute: true, dot: true })).map(rel).sort() expect(found).toEqual([ ".vscode/mcp.json", + ".yarn/unplugged/pkg/mcp.json", "dist/mcp.json", "node_modules/pkg/.vscode/mcp.json", "src/mcp.json", @@ -105,7 +108,7 @@ describe("Glob.DEFAULT_IGNORE", () => { }) test("covers the package-manager, VCS and build output directories", () => { - for (const dir of ["node_modules", ".git", "dist", "build", "target", ".venv"]) { + for (const dir of ["node_modules", ".git", ".yarn/unplugged", "dist", "build", "target", ".venv"]) { expect(Glob.DEFAULT_IGNORE).toContain(`**/${dir}/**`) } }) diff --git a/packages/drivers/src/clickhouse.ts b/packages/drivers/src/clickhouse.ts index 8e2ee98e06..5079e3dd7b 100644 --- a/packages/drivers/src/clickhouse.ts +++ b/packages/drivers/src/clickhouse.ts @@ -9,7 +9,10 @@ import type { ConnectionConfig, Connector, ConnectorResult, ExecuteOptions, Sche import { loadOptionalDriver } from "./resolve" function connectionUrl(config: ConnectionConfig): string { - const tlsRequested = Boolean(config.tls || config.ssl) + // `secure` is dbt-clickhouse's standard TLS flag. Enforce it here at the + // driver boundary as well as preserving it through profile normalization, + // because direct driver consumers can bypass the dbt importer. + const tlsRequested = Boolean(config.tls || config.ssl || config.secure) const configuredProtocol = typeof config.protocol === "string" ? config.protocol.trim().toLowerCase() : "" const secureIntent = tlsRequested || configuredProtocol === "https" const configured = typeof config.connection_string === "string" ? config.connection_string.trim() : "" @@ -76,9 +79,9 @@ export async function connect(config: ConnectionConfig): Promise { if (config.password) clientConfig.password = config.password as string if (config.database) clientConfig.database = config.database as string - // TLS/SSL support — detect HTTPS from URL, protocol config, or explicit tls/ssl flags + // TLS/SSL support — detect HTTPS from URL, protocol config, or an explicit secure flag const isHttps = typeof url === "string" && url.startsWith("https://") - if (config.tls || config.ssl || (config.protocol as string) === "https" || isHttps) { + if (config.tls || config.ssl || config.secure || (config.protocol as string) === "https" || isHttps) { const tls: Record = {} if (config.tls_ca_cert) tls.ca_cert = config.tls_ca_cert if (config.tls_cert) tls.cert = config.tls_cert diff --git a/packages/drivers/test/clickhouse-unit.test.ts b/packages/drivers/test/clickhouse-unit.test.ts index 123a24a96d..6a47962c82 100644 --- a/packages/drivers/test/clickhouse-unit.test.ts +++ b/packages/drivers/test/clickhouse-unit.test.ts @@ -74,6 +74,13 @@ describe("ClickHouse driver unit tests", () => { expect(mockClientConfigs.at(-1).url).toBe("https://secure.example:8443") }) + test("dbt secure defaults to HTTPS and the secure HTTP port", async () => { + const secure = await connect({ type: "clickhouse", host: "secure.example", secure: true }) + await secure.connect() + + expect(mockClientConfigs.at(-1).url).toBe("https://secure.example:8443") + }) + test("an HTTPS protocol defaults to the secure HTTP port", async () => { const secure = await connect({ type: "clickhouse", host: "secure.example", protocol: "https" }) await secure.connect() @@ -117,6 +124,19 @@ describe("ClickHouse driver unit tests", () => { expect(mockClientConfigs).toHaveLength(1) }) + test("rejects an explicit plaintext connection string when dbt secure is requested", async () => { + const insecure = await connect({ + type: "clickhouse", + connection_string: "http://secure.example:8123", + secure: true, + user: "analyst", + password: "secret", + }) + + await expect(insecure.connect()).rejects.toThrow("connection_string is not https://") + expect(mockClientConfigs).toHaveLength(1) + }) + test("rejects an explicit plaintext protocol when TLS is requested", async () => { const insecure = await connect({ type: "clickhouse", host: "secure.example", protocol: "http", ssl: true }) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 7b9382f353..45afccc2ee 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -2,8 +2,8 @@ import { readFile } from "fs/promises" import path from "path" import { parseTree, findNodeAtLocation, getNodeValue } from "jsonc-parser" import { resolveConfigPath, addMcpToConfig, readMcpEntryFromDisk } from "../mcp/config" +import { scanProjectMcpJsonFiles } from "../mcp/discovery-files" import { Filesystem } from "../util/filesystem" -import { Glob } from "@opencode-ai/core/util/glob" import { Log } from "@/altimate/util/log" import type { Config } from "../config/config" @@ -45,25 +45,7 @@ function extractServersMap( */ async function findAllMcpJsonFiles(projectRootDir: string): Promise { try { - const ignore = [...Glob.DEFAULT_IGNORE] - const paths = await Glob.scan("**/mcp.json", { - cwd: projectRootDir, - absolute: true, - dot: true, - // Prune dependency/build trees during traversal. Filtering the results - // afterwards still reads every directory: on a monorepo with - // node_modules installed that walk costs ~6 CPU-seconds per invocation - // because it runs across the whole runtime I/O thread pool. - ignore, - }) - // Belt and braces: keep the result filter so a pattern that slips past the - // traversal prune (e.g. via a symlinked path) still never reaches - // StdioClientTransport, which is handed `command` + `args` from whatever - // mcp.json we discover. - const toRelativeGlobPath = (file: string) => path.relative(projectRootDir, file).split(path.sep).join("/") - return paths - .filter((file) => !ignore.some((pattern) => Glob.match(pattern, toRelativeGlobPath(file)))) - .sort() + return (await scanProjectMcpJsonFiles(projectRootDir)).map((file) => file.path) } catch { log.warn("findAllMcpJsonFiles: glob scan failed", { cwd: projectRootDir }) return [] diff --git a/packages/opencode/src/mcp/discover.ts b/packages/opencode/src/mcp/discover.ts index 7d4090fc2b..e64a60359a 100644 --- a/packages/opencode/src/mcp/discover.ts +++ b/packages/opencode/src/mcp/discover.ts @@ -3,9 +3,9 @@ import path from "path" import { parse as parseJsonc } from "jsonc-parser" import { Log } from "../util/log" import { Filesystem } from "../util/filesystem" -import { Glob } from "../util/glob" import { ConfigPaths } from "../config/paths" import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" +import { resolveProjectDiscoveryFile, scanProjectMcpJsonFiles, type ProjectMcpFile } from "./discovery-files" const log = Log.create({ service: "mcp.discover" }) @@ -313,67 +313,49 @@ export async function discoverExternalMcp(projectDir: string): Promise<{ // dedup is deterministic and keeps the historical .vscode > .cursor > copilot order // (a plain alphabetical sort would let .cursor override .vscode). const IDE_PRECEDENCE = [".vscode/mcp.json", ".cursor/mcp.json", ".github/copilot/mcp.json"] - const toRel = (abs: string) => path.relative(projectDir, abs).split(path.sep).join("/") - let mcpJsonFiles: string[] = [] + let mcpJsonFiles: ProjectMcpFile[] = [] try { - // altimate_change start — prune dependency/build trees during traversal. - // Filtering results after an unrestricted `**/mcp.json` walk still reads - // every directory in the project: on a monorepo with node_modules present - // that costs ~6 CPU-seconds per invocation, spread across the whole runtime - // I/O thread pool. The post-filter stays as defence in depth. - const IGNORE_GLOBS = [...Glob.DEFAULT_IGNORE] - const scanned = ( - await Glob.scan("**/mcp.json", { - cwd: projectDir, - absolute: true, - dot: true, - ignore: IGNORE_GLOBS, - }) - ).filter((abs) => { - const rel = toRel(abs) - return !IGNORE_GLOBS.some((pattern) => Glob.match(pattern, rel)) - }) - // altimate_change end - const rank = (abs: string) => { - const i = IDE_PRECEDENCE.indexOf(toRel(abs)) + const scanned = await scanProjectMcpJsonFiles(projectDir) + const rank = (file: ProjectMcpFile) => { + const i = IDE_PRECEDENCE.indexOf(file.relative) return i === -1 ? IDE_PRECEDENCE.length : i } mcpJsonFiles = scanned.sort((a, b) => { const ra = rank(a) const rb = rank(b) if (ra !== rb) return ra - rb - const relA = toRel(a) - const relB = toRel(b) - return relA < relB ? -1 : relA > relB ? 1 : 0 + return a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0 }) } catch { log.warn("mcp.json glob scan failed", { cwd: projectDir }) } for (const file of mcpJsonFiles) { - const parsed = await readJsonSafe(file) + const parsed = await readJsonSafe(file.path) if (!parsed || typeof parsed !== "object") continue - const label = toRel(file) || path.basename(file) + const label = file.relative addServersFromFile(mergeServerKeys(parsed), label, result, contributingSources, true) } // Non-"mcp.json" config files (not matched by the glob above), in project and/or home. for (const source of SOURCES) { - const dirs: Array<{ dir: string; label: string }> = [] + const dirs: Array<{ dir: string; label: string; projectScoped: boolean }> = [] if (source.scope === "project" || source.scope === "both") { - dirs.push({ dir: projectDir, label: source.file }) + dirs.push({ dir: projectDir, label: source.file, projectScoped: true }) } if ((source.scope === "home" || source.scope === "both") && projectDir !== homedir) { - dirs.push({ dir: homedir, label: `~/${source.file}` }) + dirs.push({ dir: homedir, label: `~/${source.file}`, projectScoped: false }) } - for (const { dir, label } of dirs) { - const filePath = path.join(dir, source.file) + for (const { dir, label, projectScoped } of dirs) { + const candidate = path.join(dir, source.file) + const resolved = projectScoped ? await resolveProjectDiscoveryFile(projectDir, candidate) : undefined + if (projectScoped && !resolved) continue + const filePath = resolved?.path ?? candidate const parsed = await readJsonSafe(filePath) if (!parsed || typeof parsed !== "object") continue - const isProjectScoped = dir === projectDir const servers = parsed[source.key] - addServersFromFile(servers, label, result, contributingSources, isProjectScoped) + addServersFromFile(servers, label, result, contributingSources, projectScoped) } } diff --git a/packages/opencode/src/mcp/discovery-files.ts b/packages/opencode/src/mcp/discovery-files.ts new file mode 100644 index 0000000000..ce7afc6182 --- /dev/null +++ b/packages/opencode/src/mcp/discovery-files.ts @@ -0,0 +1,66 @@ +import { realpath } from "fs/promises" +import path from "path" +import { Glob } from "@opencode-ai/core/util/glob" + +export interface ProjectMcpFile { + /** Canonical path used for reading, after resolving any symlink. */ + path: string + /** Authored path relative to the project, retained for labels and precedence. */ + relative: string +} + +function relativeProjectPath(root: string, file: string): string | undefined { + const relative = path.relative(root, file) + if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + return undefined + } + return relative.split(path.sep).join("/") +} + +function isIgnored(relative: string): boolean { + return Glob.DEFAULT_IGNORE.some((pattern) => Glob.match(pattern, relative)) +} + +/** + * Resolve a project discovery file without allowing a symlink alias to escape + * the project or disguise a dependency/build artifact as authored config. + */ +export async function resolveProjectDiscoveryFile( + projectDir: string, + candidate: string, +): Promise { + try { + const lexicalRoot = path.resolve(projectDir) + const lexicalPath = path.resolve(candidate) + const lexicalRelative = relativeProjectPath(lexicalRoot, lexicalPath) + if (!lexicalRelative || isIgnored(lexicalRelative)) return undefined + + const [canonicalRoot, canonicalPath] = await Promise.all([realpath(lexicalRoot), realpath(lexicalPath)]) + const canonicalRelative = relativeProjectPath(canonicalRoot, canonicalPath) + if (!canonicalRelative || isIgnored(canonicalRelative)) return undefined + + return { path: canonicalPath, relative: lexicalRelative } + } catch { + return undefined + } +} + +/** + * Find authored mcp.json files while pruning dependency/build trees and then + * checking the canonical target of every match. The canonical check is the + * security boundary: glob ignores operate on aliases and cannot by themselves + * detect `.vscode/mcp.json -> node_modules/pkg/mcp.json`. + */ +export async function scanProjectMcpJsonFiles(projectDir: string): Promise { + const paths = await Glob.scan("**/mcp.json", { + cwd: projectDir, + absolute: true, + dot: true, + ignore: [...Glob.DEFAULT_IGNORE], + }) + + const files = await Promise.all(paths.map((candidate) => resolveProjectDiscoveryFile(projectDir, candidate))) + return files + .filter((file): file is ProjectMcpFile => file !== undefined) + .sort((a, b) => a.relative.localeCompare(b.relative)) +} diff --git a/packages/opencode/test/altimate/connections.test.ts b/packages/opencode/test/altimate/connections.test.ts index 5b52972eee..d1cf2ab960 100644 --- a/packages/opencode/test/altimate/connections.test.ts +++ b/packages/opencode/test/altimate/connections.test.ts @@ -678,6 +678,7 @@ ch_project: port: 8443 user: default password: secret + secure: true database: analytics schema: default `, @@ -691,6 +692,7 @@ ch_project: expect(connections[0].config.host).toBe("clickhouse.example.com") expect(connections[0].config.port).toBe(8443) expect(connections[0].config.user).toBe("default") + expect(connections[0].config.secure).toBe(true) expect(connections[0].config.database).toBe("analytics") } finally { fs.rmSync(tmpDir, { recursive: true }) diff --git a/packages/opencode/test/altimate/driver-normalize.test.ts b/packages/opencode/test/altimate/driver-normalize.test.ts index dc3eb34e25..f39c839190 100644 --- a/packages/opencode/test/altimate/driver-normalize.test.ts +++ b/packages/opencode/test/altimate/driver-normalize.test.ts @@ -866,6 +866,15 @@ describe("normalizeConfig — ClickHouse", () => { expect(normalizeConfig(config)).toEqual(config) }) + test("preserves dbt-clickhouse secure intent", () => { + const config = { + type: "clickhouse", + host: "secure.example", + secure: true, + } + expect(normalizeConfig(config)).toEqual(config) + }) + test("connectionString → connection_string", () => { const result = normalizeConfig({ type: "clickhouse", diff --git a/packages/opencode/test/mcp/discover.test.ts b/packages/opencode/test/mcp/discover.test.ts index 2907b5d977..01dc8cea00 100644 --- a/packages/opencode/test/mcp/discover.test.ts +++ b/packages/opencode/test/mcp/discover.test.ts @@ -1,9 +1,11 @@ import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test" -import { mkdtemp, rm, mkdir, writeFile } from "fs/promises" +import { mkdtemp, rm, mkdir, symlink, writeFile } from "fs/promises" import os, { tmpdir } from "os" import path from "path" import { discoverExternalMcp } from "../../src/mcp/discover" +const testSymlink = process.platform === "win32" ? test.skip : test + let tempDir: string let homeDir: string let homedirSpy: ReturnType | undefined @@ -469,6 +471,11 @@ describe("discoverExternalMcp", () => { path.join(tempDir, "dist/mcp.json"), JSON.stringify({ servers: { built: { command: "should-not-appear" } } }), ) + await mkdir(path.join(tempDir, ".yarn/unplugged/some-pkg"), { recursive: true }) + await writeFile( + path.join(tempDir, ".yarn/unplugged/some-pkg/mcp.json"), + JSON.stringify({ servers: { unplugged: { command: "should-not-appear" } } }), + ) await mkdir(path.join(tempDir, ".vscode"), { recursive: true }) await writeFile( path.join(tempDir, ".vscode/mcp.json"), @@ -478,7 +485,32 @@ describe("discoverExternalMcp", () => { const { servers: result } = await discoverExternalMcp(tempDir) expect(result["vendored"]).toBeUndefined() expect(result["built"]).toBeUndefined() + expect(result["unplugged"]).toBeUndefined() expect(result["real"]).toMatchObject({ type: "local", command: ["real-cmd"] }) }) + + testSymlink("dependency configs cannot bypass exclusions through project symlink aliases", async () => { + const target = path.join(tempDir, "node_modules/some-pkg/mcp.json") + await mkdir(path.dirname(target), { recursive: true }) + await writeFile( + target, + JSON.stringify({ + servers: { aliased: { command: "should-not-appear" } }, + mcpServers: { exact: { command: "no" } }, + }), + ) + await mkdir(path.join(tempDir, ".vscode"), { recursive: true }) + await symlink(target, path.join(tempDir, ".vscode/mcp.json")) + await symlink(target, path.join(tempDir, ".mcp.json")) + await mkdir(path.join(tempDir, ".cursor"), { recursive: true }) + await writeFile( + path.join(tempDir, ".cursor/mcp.json"), + JSON.stringify({ servers: { authored: { command: "safe-dev-server" } } }), + ) + + const { servers, sources } = await discoverExternalMcp(tempDir) + expect(Object.keys(servers)).toEqual(["authored"]) + expect(sources).toEqual([".cursor/mcp.json"]) + }) // altimate_change end }) diff --git a/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts b/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts index 8a08b8d5e8..b37327af6e 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { mkdir, readFile, writeFile } from "fs/promises" +import { mkdir, readFile, symlink, writeFile } from "fs/promises" import path from "path" import { tmpdir } from "../fixture/fixture" import { discoverExternalMcp } from "../../src/mcp/discover" @@ -10,6 +10,7 @@ import { } from "../../src/altimate/datamate-transport" const REPO_ROOT = path.join(import.meta.dir, "../../../..") +const testSymlink = process.platform === "win32" ? test.skip : test async function writeJson(file: string, value: unknown) { await mkdir(path.dirname(file), { recursive: true }) @@ -171,6 +172,9 @@ describe("PR #893 datamate IDE transport selection", () => { await writeJson(path.join(project.path, "dist/mcp.json"), { servers: { datamate: { url: "https://dist-output.example.com/sse" } }, }) + await writeJson(path.join(project.path, ".yarn/unplugged/pkg/mcp.json"), { + servers: { datamate: { url: "https://unplugged-package.example.com/sse" } }, + }) // Keep the authored config lexically last: without the broad exclusion, // build/mcp.json would win the deterministic sorted-first selection. await writeJson(path.join(project.path, "z-authored/mcp.json"), { @@ -182,6 +186,24 @@ describe("PR #893 datamate IDE transport selection", () => { command: ["datamate", "start-stdio"], }) }) + + testSymlink("rejects a dependency datamate config hidden behind an authored-looking symlink", async () => { + await using project = await tmpdir() + const dependencyConfig = path.join(project.path, "node_modules/pkg/mcp.json") + await writeJson(dependencyConfig, { + servers: { datamate: { command: "do-not-run", args: ["from-dependency"] } }, + }) + await mkdir(path.join(project.path, ".vscode"), { recursive: true }) + await symlink(dependencyConfig, path.join(project.path, ".vscode/mcp.json")) + await writeJson(path.join(project.path, "z-authored/mcp.json"), { + servers: { datamate: { command: "datamate", args: ["start-stdio"] } }, + }) + + await expect(readDatamateTransportFromIde(project.path)).resolves.toEqual({ + type: "local", + command: ["datamate", "start-stdio"], + }) + }) }) describe("PR #893 datamate sync to altimate-code config", () => { diff --git a/research/altimate-base-release-2026-08-30/release-readiness.md b/research/altimate-base-release-2026-08-30/release-readiness.md index bf47988c14..e94dcedefb 100644 --- a/research/altimate-base-release-2026-08-30/release-readiness.md +++ b/research/altimate-base-release-2026-08-30/release-readiness.md @@ -1,11 +1,11 @@ # Altimate Base release readiness -Date: 2026-08-30 +Date: 2026-08-30 Scope: hosted Qwen 3.8 release, free-tier gateway, Altimate Base client integration, and harness reliability work ## Release decision -The implementation is code-complete and the serving path has passed live staging acceptance. It is not yet a completed production release: the three release PRs still need human approval and merge, and the final late-review gateway commit must be rolled onto the staging VM after the Google Cloud operator session is re-authenticated. +The implementation is code-complete and exact gateway commit `11877e6` is deployed on staging. The post-deploy public acceptance suite and a forced streamed tool call both passed. It is not yet a completed public release: the three release PRs still need human approval and merge, and the client artifact still needs to be built and published from the approved release commit. The intended public contract is now stable: @@ -19,13 +19,13 @@ The intended public contract is now stable: | Repository | PR | Purpose | Current disposition | | --- | --- | --- | --- | | `AltimateAI/altimate-gateway` | #1 | Free-tier issuer, accounting, abuse controls, routing, autoscaler, deployment safety, and configurable bootstrap URL | Release branch pushed through `11877e6`; CI and human approval required | -| `AltimateAI/altimate-code` | #1199 | Altimate Base provider, consent flow, managed credentials, default-model replacement, ACP/session persistence, and safety tests | Final review fixes prepared; CI and human approval required | +| `AltimateAI/altimate-code` | #1199 | Altimate Base provider, consent flow, managed credentials, default-model replacement, ACP/session persistence, and safety tests | Synced with current main and post-scan fixes verified locally; final CI and human approval required | | `AltimateAI/altimate-code` | #1171 | Harness/session reliability improvements developed alongside the model work | All observed checks green; human approval required | | `AltimateAI/altimate-code` | #1115 | Earlier free Gemini experiment | Superseded by #1199; branch is conflicting and its secret scan is failing | Recommended order: -1. Roll gateway commit `11877e6` to staging and repeat the public smoke test. +1. Completed: roll gateway commit `11877e6` to staging and repeat the public smoke and streamed-tool-call tests. 2. Approve and merge gateway PR #1. 3. Approve and merge client PR #1199, then build the release artifact with the official gateway endpoint embedded. 4. Merge harness PR #1171 independently once its approval is available. @@ -75,6 +75,7 @@ Release hardening completed during this pass includes: - Autoscaler schedule edits are validated before becoming the last-good configuration. Invalid time zones, weekdays, time windows, and alert cooldowns are rejected. - Repeated thrash, budget-refusal, and wake-timeout alerts use a persisted per-event cooldown instead of posting once per poll. - Monthly GPU spend is written to a redundant atomic checkpoint. If the main state file is missing or corrupt, spend is recovered; if neither copy is trustworthy, new scale-ups are refused. +- Exact commit `11877e6` was copied from a clean Git archive, checksum-verified on the VM, and compared against the deployed source while preserving only the mode-0600 environment file and rolling state. A mode-0600 pre-roll source backup and a copy of the deployed release inputs are retained for rollback. These controls are scoped to the Altimate Base gateway and its named serving tiers. They do not change routing or credentials for other client models. @@ -82,7 +83,7 @@ These controls are scoped to the Altimate Base gateway and its named serving tie ### Live public path -The deployed staging serving path passed the end-to-end smoke suite 8/8: +The deployed staging serving path passed the end-to-end smoke suite 8/8 after the `11877e6` rollout: - first registration - same-install key rotation @@ -93,7 +94,7 @@ The deployed staging serving path passed the end-to-end smoke suite 8/8: - denied unauthenticated inference - correct registration response shape -A separate forced-tool-call request through the public `altimate-base` alias returned the alias in the response, emitted a tool call, finished with `tool_calls`, included terminal usage, and ended with the stream completion marker. The same acceptance passed after the master-key rotation. +A separate forced streamed-tool-call request through the public `altimate-base` alias emitted the requested function with the expected structured arguments across 32 stream events and finished with `tool_calls`. The release script itself also required a real completion from the candidate color before switching traffic. WARM serving returned 401 without its serving key and 200 with the key. The managed container was healthy with no restarts. A second client identity received its own successful registration after the proxy fix, demonstrating that registration limiting is per client rather than per proxy. @@ -111,9 +112,16 @@ WARM serving returned 401 without its serving key and 200 with the key. The mana - Altimate Base transport, registration, consent, and credential tests: 27 green. - Altimate Base TUI disclosure and close-guard tests: 6 green. - Earlier full focused client run: 37 green with one intentional todo. -- Root typecheck: 13/13 workspaces green after the final review edits. +- Complete security diff review: 43 changed source files reviewed. It found two medium-severity regressions caused by the branch being behind main, not by the Base provider changes. +- The mainline Windows path/TLS fixes were merged. A follow-up variant check found and closed Yarn unplugged and symlink-alias MCP discovery plus dbt-clickhouse `secure: true` handling at the driver boundary. +- Post-fix focused verification: 338 tests green across glob pruning, MCP/Datamate discovery, ClickHouse transport, dbt import, and normalization; one existing MCP todo remains. +- Broader compatibility verification: 140 driver/SDK resolver tests and 166 MCP lifecycle/discovery tests green, plus all changed Base/model suites and 6 TUI consent tests green. +- Root typecheck: 13/13 workspaces green after the final security edits. +- Local pre-release build with a non-production HTTPS endpoint: built successfully and the compiled binary passed its startup smoke check. - Branding, changed-file marker, and strict required-marker audits: green after the final report was added. +The immutable scan output is preserved in `security-review-snapshot.md`; it describes the stale scanned head. The current remediation state and proof are in `security-fix-verification.md`. + ### Harness repository - Focused harness regression set: 53 green. @@ -132,9 +140,7 @@ The live environment has mode-0600 pre-roll backups for the relevant environment ## Remaining release actions -- Re-authenticate the Google Cloud account that owns Compute access. -- Deploy exact gateway commit `11877e6` with `scripts/rolling_deploy.sh`, verify the source marker and container health, then rerun the public smoke and forced-tool-call checks. -- Wait for the newly pushed gateway CI run and the final client CI run. +- Wait for the final client CI run. - Obtain the required human approvals on #1, #1199, and #1171. - Merge in the order above and publish the client artifact with the official endpoint embedded. - Observe registration 429s, wallet debits/high-water progress, backend health, and autoscaler decisions during the initial release window. diff --git a/research/altimate-base-release-2026-08-30/security-fix-verification.md b/research/altimate-base-release-2026-08-30/security-fix-verification.md new file mode 100644 index 0000000000..49af99d306 --- /dev/null +++ b/research/altimate-base-release-2026-08-30/security-fix-verification.md @@ -0,0 +1,72 @@ +# Altimate Base security fix verification + +Date: 2026-08-30 +Branch: `codex/altimate-base-release-final` +Security scan: `c6abd4b7-e12a-4d69-8b88-0dc1e57dabf7` + +## Outcome + +The two medium-severity findings reported against stale client head `04fceafcf8` are fixed on the release branch. The branch first merged the current mainline implementations for ClickHouse TLS enforcement and dependency-tree pruning, then closed two variants found during the required post-fix review: + +1. Yarn Plug'n'Play extracts some installed packages under `.yarn/unplugged`; those trees are now pruned as dependencies. +2. An authored-looking MCP path could be a symlink to a dependency or an out-of-project file; every candidate is now resolved canonically and checked against the project boundary and ignore policy before parsing. +3. dbt-clickhouse expresses TLS intent as `secure: true`; the ClickHouse driver now treats `secure`, `tls`, and `ssl` identically at the final transport boundary. + +No Altimate Base model, provider, credential, or routing code was changed by these fixes. + +## Security invariants + +### MCP and Datamate discovery + +Only authored project configuration may contribute executable MCP commands. A discovery path must satisfy both checks: + +- its visible path is inside the project and outside dependency/build trees; and +- its canonical target, after resolving symlinks, is inside the project and outside those trees. + +The shared resolver is used by recursive `mcp.json` discovery, Datamate transport selection, and exact project-scoped `.mcp.json` / `.gemini/settings.json` sources. Home-scoped user configuration keeps its previous behavior. + +### ClickHouse transport + +Any explicit secure intent (`tls`, `ssl`, `secure`, or HTTPS protocol) must result in HTTPS or fail before the ClickHouse client receives credentials. Plain HTTP remains available only when no secure intent is present. + +## Reproduction proof + +The original and follow-up bypasses no longer reproduce: + +- `.yarn/unplugged/pkg/mcp.json` is absent from both general MCP discovery and Datamate transport selection. +- `.vscode/mcp.json` and `.mcp.json` symlinks targeting `node_modules/pkg/mcp.json` are rejected, while a real authored `.cursor/mcp.json` is still discovered. +- The existing adversarial test proving an in-project symlink to authored configuration remains green. +- `secure: true` produces `https://...:8443` by default. +- `secure: true` combined with an explicit `http://` connection string throws before the mocked client factory sees a new configuration. +- A configuration with no secure intent still produces the existing `http://...:8123` default. + +## Files changed + +- `packages/core/src/util/glob.ts` +- `packages/core/test/util/glob.test.ts` +- `packages/opencode/src/mcp/discovery-files.ts` +- `packages/opencode/src/mcp/discover.ts` +- `packages/opencode/src/altimate/datamate-transport.ts` +- `packages/opencode/test/mcp/discover.test.ts` +- `packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts` +- `packages/drivers/src/clickhouse.ts` +- `packages/drivers/test/clickhouse-unit.test.ts` +- `packages/opencode/test/altimate/connections.test.ts` +- `packages/opencode/test/altimate/driver-normalize.test.ts` + +## Verification commands and results + +- `packages/core`: `bun test test/util/glob.test.ts` — 8 passed. +- `packages/opencode`: discovery and Datamate release tests — 37 passed, 1 existing todo. +- `packages/drivers`: ClickHouse unit tests — 74 passed. +- `packages/opencode`: dbt connection import and driver normalization — 219 passed. +- `packages/drivers`: ClickHouse plus optional-driver resolver compatibility — 140 passed. +- `packages/opencode`: expanded MCP discovery/config/lifecycle compatibility — 166 passed, 3 existing skips, 1 existing todo. +- Changed Altimate Base/provider/session/ACP/telemetry suites — green. +- `packages/tui`: Altimate Base dialog suite — 6 passed. +- Repository `bun run typecheck` — 13 of 13 tasks successful. +- `git diff --check` — clean. + +## Residual uncertainty + +The symlink integration regression is skipped on Windows hosts where ordinary file-symlink creation is not a reliable CI capability. The production boundary itself uses Node's cross-platform `realpath` and `path.relative`, and the mainline Windows separator regression remains covered. As documented in the repository policy, a same-user process able to race local filesystem changes already has equivalent operating-system authority; this change does not claim to provide an OS sandbox. diff --git a/research/altimate-base-release-2026-08-30/security-review-snapshot.md b/research/altimate-base-release-2026-08-30/security-review-snapshot.md new file mode 100644 index 0000000000..f48fa482c0 --- /dev/null +++ b/research/altimate-base-release-2026-08-30/security-review-snapshot.md @@ -0,0 +1,245 @@ +# Security Review: altimate-base-release-final + +## Scope + +Complete security review of all 43 changed source files in the immutable release-head tree delta. + +- Scan mode: branch_diff +- Target kind: git_diff +- Target ID: target_sha256_ce9196c8c4fc064e865357233037ec3170ac4b77d019f78e148a2076e457e349 +- Revision range: babc7cb2e4147f65d8120946d8179ca7e06631ce...04fceafcf88df2becc6dd4b23cb2f561c8a48056 +- Snapshot digest: codex-security-snapshot/v1:sha256:9f60352e7871bdd3c8ec8feca1b9425df5952986ebf4105d92f3da61862fe064 +- Inventory strategy: diff +- Included paths: . +- Excluded paths: none +- Runtime or test status: Focused reproductions confirmed both findings; existing Altimate Base tests were reviewed separately as part of release validation. +- Artifacts reviewed: artifacts/01_context/threat_model.md, 43 changed-source review receipts, two focused Bun runtime reproductions +- Scan context: The generated threat model focused on managed registration/auth, configurable gateway routing, consent/privacy, telemetry, and isolation from other providers. The scan also followed changed behavior into command-discovery and warehouse transport sinks. + +Limitations and exclusions: +- The immutable two-dot tree delta compares a stale feature head to a base two commits ahead. Both findings are present in the exact head but originate from missing mainline fixes, not from Altimate Base-authored PR hunks. + +### Scan Summary + +| Field | Value | +| --- | --- | +| Scan outcome | completed | +| Reportable findings | 2 | +| Severity mix | medium: 2 | +| Confidence mix | high: 2 | +| Coverage | complete | +| Validation mode | Static source-to-sink review plus focused mocked runtime reproduction. | + +Canonical artifacts: `scan-manifest.json`, `findings.json`, and `coverage.json`. This report is a deterministic projection of those files. + +## Threat Model + +Text: # Altimate Base client threat model ## Summary Altimate Code is a local coding assistant that can send prompts, repository context, tool results, and model output to configured LLM providers. This change adds the consent-gated hosted `altimate-free/altimate-base` provider. The TUI first shows a logging and privacy disclosure, then calls a narrow host operation. The parent creates a one-use capability, the TUI worker consumes it, and only then may the worker register with the configured HTTPS gateway. The gateway-issued credential is stored in a dedicated local record and injected by a custom fetch only for same-origin Altimate Base inference. Provider discovery remains read-only. Evidence: `packages/tui/src/component/altimate-onboarding.tsx:345-550`, `packages/opencode/src/cli/cmd/tui.ts:250-257`, `packages/opencode/src/cli/tui/worker.ts:68-83`, `packages/opencode/src/altimate/free/client.ts:53-371`, `packages/opencode/src/provider/provider.ts:379-395`. ## Assets - The local installation secret and gateway-issued API credential. Registration sends only a SHA-256 hash of the installation secret. Evidence: `packages/opencode/src/altimate/free/client.ts:71-76,181-252`. - Gateway destination integrity: a registration response must return the configured normalized base URL, and the bearer key must be injected only for the registered origin. Evidence: `packages/opencode/src/altimate/free/client.ts:218-238,324-371`. - User prompts, repository context, model responses, and the user's explicit decision to send them to a hosted service that logs requests and responses. Evidence: `packages/tui/src/component/altimate-onboarding.tsx:345-346,372-550`, `packages/opencode/src/session/llm.ts:260-287`. - The consent invariant: provider discovery and ordinary inference must not mint or rotate credentials. Evidence: `packages/opencode/src/altimate/free/consent.ts:16-109`, `packages/opencode/src/altimate/free/client.ts:97-108,259-300,346-371`. - Session-scoped abuse-control identity. Only Altimate Base receives the authoritative current session ID; provider headers cannot override it. Evidence: `packages/opencode/src/session/llm.ts:55-66`. - Privacy of the credential across public provider metadata, TUI state, logs, and categorical onboarding telemetry. Evidence: `packages/opencode/src/altimate/free/client.ts:10-17`, `packages/opencode/src/provider/provider.ts:379-395`, `packages/opencode/src/altimate/telemetry/index.ts:992-1048`. - Release routing and provider isolation: the official endpoint is release configuration; project provider configuration and the shared Auth store cannot steer the managed provider. Evidence: `.github/workflows/release.yml:91-102`, `packages/opencode/script/build.ts:32-46,611-617`, `packages/opencode/src/provider/provider.ts:1179-1184,1687-1700,1748-1787`. ## Trust boundaries - \*\*User/TUI -\> registration authority.\*\* The disclosure defaults to No. Acceptance invokes a host-injected callback that creates a random capability. The worker accepts 64-character hex tokens, expires them after 30 seconds, caps pending tokens at 16, and consumes each once. Evidence: `packages/tui/src/component/altimate-onboarding.tsx:372-550`, `packages/opencode/src/cli/cmd/tui.ts:250-257`, `packages/opencode/src/altimate/free/consent.ts:4-58`. - \*\*Parent -\> TUI worker.\*\* Registration exists on private parent/worker RPC and a narrow SDK-context callback. The provider HTTP handler exposes metadata and connection state, not registration or raw credentials. Evidence: `packages/opencode/src/cli/tui/worker.ts:68-83`, `packages/tui/src/context/sdk.tsx:11-32,203-213`, `packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts:44-79`. - \*\*Worker -\> local credential store.\*\* The record is `altimate-base.json` under the application's XDG data directory. Writes use a same-directory exclusive temporary file, requested 0600 mode, synchronization, atomic rename, and final chmod. Evidence: `packages/opencode/src/global/index.ts:7-28`, `packages/opencode/src/altimate/free/store.ts:22-24,57-107`. - \*\*Client -\> registration gateway.\*\* URL precedence is `ALTIMATE_BASE_GATEWAY_URL`, legacy `ALTIMATE_FREE_GATEWAY_URL`, then the embedded release default. The client accepts only HTTPS without userinfo, query, or fragment. Registration rejects redirects, submits the installation-secret hash and sanitized CLI version, and validates the returned key, complete base URL, expiry, and optional model identifier. Evidence: `packages/opencode/src/altimate/free/client.ts:53-69,115-127,190-257`, `packages/opencode/src/altimate/free/url.ts:1-14`. - \*\*Provider SDK -\> inference gateway.\*\* The provider uses the stored base plus `/v1`, exposes a non-secret placeholder through provider metadata, and injects the bearer key only after a same-origin check. Redirects are manual, and a 401 marks a credential rejected without registering in the background. Evidence: `packages/opencode/src/provider/provider.ts:379-395`, `packages/opencode/src/altimate/free/client.ts:324-371`. - \*\*Managed provider -\> extensibility surfaces.\*\* Project configuration for `altimate-free` is filtered, shared Auth entries are ignored for it, and the managed model record is pinned after catalog construction. Evidence: `packages/opencode/src/provider/provider.ts:1179-1184,1509-1544,1687-1700,1768-1787`. - \*\*TUI -\> product telemetry.\*\* Selection, disclosure impression, accept/cancel, and registration result are categorical events. Provider IDs are allowlisted and existing telemetry opt-outs govern delivery. This is separate from hosted request/response logging. Evidence: `packages/opencode/src/altimate/telemetry/index.ts:800-833,992-1048,1696-1751`, `docs/docs/reference/security-faq.md:12-20`, `docs/docs/reference/telemetry.md:54-73`. - \*\*Release operator -\> shipped binary.\*\* Release CI passes the repository variable into the build. Release builds fail when it is absent or invalid, and the normalized value is compiled as the fallback behind runtime overrides. Evidence: `.github/workflows/release.yml:91-102`, `packages/opencode/script/build.ts:32-46,611-617`. ## Attacker capabilities - Untrusted repository content and model output can influence prompts and normal tool flows, but the public provider API does not expose the registration mutation. A boundary failure would add registration without the disclosure decision. - A remote network attacker without a trusted TLS position cannot select an HTTP destination. A compromised configured gateway can observe registration metadata and, after consent, inference content, but cannot make the client persist a different normalized base URL or unexpected model ID through the registration response. - A caller-controlled request destination would disclose the key only if it bypassed the same-origin and manual-redirect controls in `authorizedFetch`. - A same-user local process already shares OS-level authority and may be able to read environment variables or user files. The repository policy explicitly says the application permission system is not an OS sandbox. - A privileged release or runtime operator can select any structurally valid HTTPS gateway. That is trusted deployment authority; malicious user-controlled configuration is outside the repository's accepted vulnerability boundary. ## Security objectives - Do not create an installation identifier, register, erase a malformed record, or rotate a rejected credential until the current disclosure has been accepted through a fresh one-use capability. - Keep the real managed API key and raw installation secret out of public provider objects, shared Auth storage, TUI state, logs, and telemetry. - Accept only normalized HTTPS configuration, bind registration to the complete configured URL, and inject Authorization only for the registered origin without automatically following redirects. - Keep discovery read-only. Missing, expired, rejected, malformed, or wrong-gateway state must not trigger registration. - Preserve informed consent: disclose request/response logging and product-improvement use, warn against secrets/confidential code, default to No, and prevent closure while registration is pending. - Keep Base onboarding telemetry categorical and honor existing telemetry opt-outs. - Scope managed behavior to `altimate-free`; preserve explicit model configuration and recent-model behavior for other providers. Big Pickle may remain an explicit choice but must not remain the implicit free fallback. - Fail release builds without a valid official gateway while leaving source/development execution host-free until an operator supplies one. ## Assumptions and open questions - Provider-side storage and use of inference content is outside the supplied repository policy, but the recipient and disclosure are modeled because the user explicitly asked for that boundary. - The literal production gateway is not in source and is deliberately omitted. Offline review verified only the variable-to-binary configuration chain. - Requested 0600/0700 modes are meaningful on POSIX; effective Windows or unusual-filesystem ACL behavior remains platform-dependent. - `registerAfterConsent` does not itself accept the capability. Its only production caller is behind the worker-local consent gate; future callers must preserve this topology. - The normalizer permits an HTTPS path prefix. Stored credential and registration validation use the complete normalized URL; bearer injection is origin-scoped. This is an architectural distinction, not a finding. - Managed credential, endpoint, and session-header branches are keyed to `altimate-free`, but the diff intentionally changes global implicit model selection and applies provider allowlist checks to custom loaders. Full no-regression coverage for every other provider belongs to diff discovery and tests, not this architecture map. ## Effective resources | Consumer | Configuration chain and safe effective value | Recipients | Enforcing controls | Evidence | | --- | --- | --- | --- | --- | | Official release | Actions variable -\> build validation -\> embedded fallback; runtime env overrides remain first | Configured Altimate Base gateway | Required valid HTTPS release value, full stored-base match, same-origin key injection, manual redirects | `.github/workflows/release.yml:91-102`; `packages/opencode/script/build.ts:32-46,611-617`; `packages/opencode/src/altimate/free/client.ts:53-69,97-103,324-371` | | Source/development | Runtime primary env -\> legacy env -\> empty embedded fallback | Operator-selected HTTPS gateway after consent | Missing configuration fails closed; same URL and transport controls as release | `packages/opencode/src/altimate/free/client.ts:15-17,53-69`; `packages/opencode/src/altimate/free/url.ts:1-14` | | Credential store | XDG data -\> `altimate-code/altimate-base.json` | Registration, provider discovery, managed inference, rejection handling | Dedicated schema, exclusive atomic 0600 write, cross-process registration lock | `packages/opencode/src/global/index.ts:7-28`; `packages/opencode/src/altimate/free/store.ts:6-107`; `packages/opencode/src/altimate/free/client.ts:79-113,265-300` | | Consent capability | Disclosure acceptance -\> random parent token -\> arm RPC -\> consume RPC | TUI host callback and worker only | Token validation, 30-second expiry, 16-entry cap, delete on consume, no HTTP registration route | `packages/opencode/src/altimate/free/consent.ts:4-109`; `packages/opencode/src/cli/cmd/tui.ts:250-257`; `packages/opencode/src/cli/tui/worker.ts:68-83` | | Registration | Consumed capability -\> lock -\> `${gateway}/register` | Gateway receives secret hash/version; local store receives returned credential | Timeout, redirect rejection, response schema/base/expiry/model validation, atomic persistence | `packages/opencode/src/altimate/free/client.ts:181-300` | | Inference | Dedicated store -\> managed loader -\> `${base}/v1` -\> `authorizedFetch` | Registered gateway origin | Managed config pinning, authoritative session header, same-origin Authorization, no automatic redirect or background registration | `packages/opencode/src/provider/provider.ts:379-395,1179-1184,1687-1700`; `packages/opencode/src/session/llm.ts:55-66`; `packages/opencode/src/altimate/free/client.ts:324-371` | | Telemetry | TUI categorical event -\> opt-out-aware telemetry -\> configured ingestion endpoint | Existing Application Insights sink | Provider allowlist, categorical event schema, disable flags/config, bounded request | `packages/opencode/src/altimate/telemetry/index.ts:800-833,992-1048,1696-1751`; `docs/docs/reference/telemetry.md:54-73` | ## Resolved questions - Provider discovery and ordinary inference do not register or rotate credentials; they only read, reject, or reuse a separately consented rotation. - Registration is not exposed by the provider HTTP API; it is a private parent/worker operation. - Project provider config, shared Auth, and catalog input do not control the managed SDK, endpoint, headers, or key source. - Raw credentials do not reach the TUI or provider-list response. - Changing the effective gateway makes an old complete-base match fail and requires consented setup again. - Registration rejects redirects; inference does not automatically follow them and will not inject across origins. - Base-specific product telemetry is categorical and separate from hosted prompt/response logging. - The exact production gateway and cross-platform ACL equivalence remain intentionally unresolved. + +## Findings + +| Finding | Severity | Confidence | Detailed write-up | +| --- | --- | --- | --- | +| [TLS intent silently downgrades ClickHouse connections to plaintext](#finding-1) | medium | high | inline below | +| [Windows dependency discovery can execute commands from a vendored MCP config](#finding-2) | medium | high | inline below | + +### Confidence Scale + +| Label | Meaning | +| --- | --- | +| high | Direct evidence supports the finding with no material unresolved blocker. | +| medium | Evidence supports a plausible issue, but material runtime or reachability proof remains. | +| low | Evidence is incomplete and the item is retained only for explicit follow-up. | + + + +### [1] TLS intent silently downgrades ClickHouse connections to plaintext + +| Field | Value | +| --- | --- | +| Severity | medium | +| Confidence | high | +| Confidence rationale | The source explicitly constructs plaintext transport with credentials, and a focused mocked client-factory reproduction observed that exact configuration under TLS intent. | +| Category | Cleartext transmission of sensitive information | +| CWE | CWE-319 | +| Affected lines | packages/drivers/src/clickhouse.ts:26-28, packages/drivers/src/clickhouse.ts:39-40, packages/drivers/src/clickhouse.ts:43-53, packages/drivers/src/clickhouse.ts:63, packages/drivers/src/clickhouse.ts:87-101 | + +#### Summary + +The release head constructs `http://host:8123` without considering `tls` or `ssl`, attaches the configured username and password, and accepts an explicit plaintext URL even when TLS intent is present. Warehouse credentials, queries, and results can therefore cross the network without the requested transport protection. + +#### Root Cause + +The invariant is that TLS intent must produce an encrypted URL or fail closed. URL construction ignores `tls`/`ssl`, while certificate options merely decorate the client configuration and do not upgrade or validate the scheme. The same client receives credentials and performs queries over the resulting plaintext URL. + +#### Validation + +For `tls: true`, the client factory received `http://secure.example:8123` and a password. An explicitly contradictory plaintext connection string plus TLS intent was also accepted. + +Validation method: Static transport and credential trace plus focused mocked ClickHouse client-factory reproduction. + +Assertions: +- Default URL construction ignores TLS intent. +- Credentials are attached to the plaintext client configuration. +- Contradictory explicit HTTP plus TLS intent does not fail closed. + +Counterevidence and remaining uncertainty: +- The regression is not authored by Altimate Base hunks; current origin/main restores scheme selection, contradiction checks, and tests. + +#### Dataflow + +Connection host/TLS intent/credentials -\> unconditional or unchecked HTTP URL -\> `createClient` -\> metadata and query requests. + +- **Source:** ClickHouse connection credentials and TLS intent. + +- **Sink:** Plaintext ClickHouse HTTP transport. + +- **Outcome:** Credentials and warehouse traffic can be observed or modified. + +#### Reachability + +Reachable whenever the shipping driver attempts a connection for a TLS-intent configuration while an attacker can observe or control the network endpoint. + +- **Attacker:** On-path network attacker or malicious endpoint. + +- **Entry point:** ClickHouse connection creation. + +- **Outcome:** Credential/data disclosure or traffic modification. + +Preconditions: +- TLS or SSL intent +- Connection attempt +- Network or endpoint attacker position + +#### Severity + +**Medium** — The downgrade can disclose warehouse credentials and data or allow traffic modification. Exploitation requires a TLS-intent configuration and an attacker with an on-path or malicious-endpoint position, yielding high impact and medium likelihood. + +Merge current origin/main, then verify HTTPS scheme selection, rejection of contradictory plaintext/TLS configurations, and the focused ClickHouse transport tests. + +Impact assessment: +- **Level:** high +- **Why:** Warehouse credentials, query text, results, and metadata may be exposed or altered. + +Likelihood assessment: +- **Level:** medium +- **Why:** Requires an affected configuration and a network-positioned or endpoint attacker. + +#### Remediation + +Restore the upstream TLS-aware scheme selection and fail closed when explicit plaintext transport contradicts `tls`/`ssl`; merge the current origin/main fixes before release. + +Tests: +- Assert `tls: true` and `ssl: true` choose HTTPS defaults. +- Assert explicit `http://` plus TLS intent is rejected. +- Assert explicit HTTPS remains accepted and non-TLS HTTP behavior remains unchanged. + +Preventive controls: +- Derive transport scheme from a single validated connection policy. +- Reject contradictory security configuration. +- Test credentials only with the intended transport semantics. + + + +### [2] Windows dependency discovery can execute commands from a vendored MCP config + +| Field | Value | +| --- | --- | +| Severity | medium | +| Confidence | high | +| Confidence rationale | A direct source-to-process trace and focused Bun reproduction demonstrate the Windows path bypass and attacker-controlled command selection. | +| Category | OS command injection through unsafe configuration discovery | +| CWE | CWE-20, CWE-78 | +| Affected lines | packages/core/src/util/glob.ts:5-20, packages/opencode/src/altimate/datamate-transport.ts:48-71, packages/opencode/src/altimate/datamate-transport.ts:96-115, packages/opencode/src/altimate/tools/datamate.ts:266-267, packages/opencode/src/mcp/index.ts:541-548 | + +#### Summary + +On Windows, Datamate's forward-slash substring filter does not recognize native backslash paths beneath `node_modules`. Because the release head also lacks the newer glob-level traversal exclusions, a dependency-owned `mcp.json` can be selected and its attacker-controlled `command` and `args` passed to the local MCP process launcher. + +#### Root Cause + +The invariant is that vendored/generated directories must never contribute executable MCP configuration. The release head performs discovery without glob-level traversal exclusions, then post-filters native absolute paths using forward-slash substrings. Windows backslash paths survive, allowing `command` and `args` from a dependency config to reach `MCP.add` and `StdioClientTransport`. + +#### Validation + +A Windows-style `C:\repo\node_modules\malicious\mcp.json` survived filtering and produced the attacker-controlled command array that the downstream MCP transport launches. + +Validation method: Static data-flow review plus focused mocked package-interface reproduction. + +Assertions: +- The exact release head lacks traversal exclusions at the glob boundary. +- The fallback filter fails to match native Windows backslash paths. +- The selected config's command and args flow to the local-process transport. + +Counterevidence and remaining uncertainty: +- The regression is not authored by Altimate Base hunks; the two newer origin/main commits restore the missing controls. + +Limitations: +- The focused reproduction stopped at the exact transport configuration boundary rather than launching a native Windows process. + +#### Dataflow + +Dependency `mcp.json` -\> native Windows glob path -\> ineffective slash filter -\> Datamate config selection -\> `MCP.add` -\> `StdioClientTransport`. + +- **Source:** Untrusted project/dependency `command` and `args`. + +- **Sink:** Local process launch by `StdioClientTransport`. + +- **Outcome:** Attacker-selected code runs with the client user's privileges. + +#### Reachability + +Reachable in the shipping Windows client when a project contains the malicious config and Datamate add/sync is invoked. + +- **Attacker:** Author of an untrusted project dependency or generated subtree. + +- **Entry point:** Datamate discovery/add workflow. + +- **Outcome:** Local command execution. + +Preconditions: +- Windows path semantics +- Malicious matching mcp.json +- Datamate add/sync invocation +- No earlier legitimate config wins + +#### Severity + +**Medium** — Successful exploitation launches an attacker-chosen process in the user's context. It requires Windows, malicious project content, and a Datamate add/sync invocation, so impact is high while likelihood is medium. + +Merge current origin/main, then verify glob traversal exclusions, POSIX path normalization, Windows-path regression coverage, and focused Datamate tests. + +Impact assessment: +- **Level:** high +- **Why:** Arbitrary local process execution in the user's context. + +Likelihood assessment: +- **Level:** medium +- **Why:** Requires a malicious project artifact plus an explicit or model-driven Datamate workflow. + +#### Remediation + +Restore the upstream glob traversal ignore list and normalized POSIX path behavior, retain a defense-in-depth normalized Datamate filter, and merge the current origin/main fixes before release. + +Tests: +- Assert Windows-style `node_modules` and generated paths never become Datamate candidates. +- Assert ignored directories are pruned before recursive traversal. +- Assert a safe project-root `mcp.json` still registers normally. + +Preventive controls: +- Prune untrusted dependency/generated directories at the traversal API. +- Normalize paths before security comparisons. +- Keep process-launch discovery tests on both POSIX and Windows path forms. + +## Reviewed Surfaces + +| Surface | Risk Area | Outcome | Notes | +| --- | --- | --- | --- | +| Altimate Base registration, URL validation, credential storage, and same-origin authorization | Authentication, secret handling, SSRF, redirects, consent | No issue found | Reviewed the managed client, store, URL normalizer, and all registration/inference paths. Controls are fail-closed and scoped only to `altimate-free`. | +| Consent capability and TUI parent/worker boundary | Consent bypass and exposed mutation surfaces | No issue found | Reviewed the disclosure, default-No flow, one-use capability, private RPC, SDK callback, and provider HTTP response. Registration remains private and consent-gated. | +| Provider/model selection and cross-provider isolation | Credential confusion, endpoint steering, regression to other models | No issue found | Reviewed managed-provider pinning, project configuration filtering, Auth isolation, session headers, dialogs, and defaults. Big Pickle remains explicit-only and other providers retain their existing paths. | +| Release build and configurable gateway chain | Unsafe release configuration and destination integrity | No issue found | Reviewed build, publish, and check paths. Release configuration is required and validated; runtime override precedence remains explicit and HTTPS-only. | +| Onboarding telemetry and privacy signals | Secret or prompt leakage through product telemetry | No issue found | Reviewed categorical event schemas, provider allowlisting, bounded fields, and opt-out paths; no managed credential or inference content enters these events. | +| Datamate, MCP discovery, project loading, and local tool registration | Untrusted-repository command execution and path handling | Reported | The exact release head is missing two newer mainline traversal controls, creating the reported Windows vendored-config command path. Other reviewed MCP, project, ACP, and registry paths produced no surviving finding. | +| Warehouse drivers and driver resolution | Credential transport, dependency resolution, and command/tool execution | Reported | The exact release head is missing the newer mainline ClickHouse TLS fix, creating the reported cleartext downgrade. The remaining BigQuery, Databricks, DuckDB, MongoDB, MySQL, Oracle, Postgres, Redshift, Snowflake, SQL Server, and Trino driver paths produced no surviving finding. | +| TUI application, dialogs, SDK state, and onboarding UI | UI-triggered authority, model confusion, and consent-state races | No issue found | Reviewed the app, Base onboarding component, model/provider dialogs, SDK context, telemetry context, and dialog primitives; no additional security issue survived validation. | From ddd31f0e01fd2793423d6e8aff110d7413ca4a97 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 08:37:08 -0700 Subject: [PATCH 17/33] fix: finish Altimate Base release review --- .../src/altimate/datamate-transport.ts | 4 +- packages/opencode/src/cli/cmd/providers.ts | 41 ++++++++++-- packages/opencode/src/mcp/discover.ts | 12 ++-- packages/opencode/src/mcp/discovery-files.ts | 4 +- .../test/cli/providers-logout.test.ts | 66 +++++++++++++++++++ packages/opencode/test/mcp/discover.test.ts | 11 ++++ .../mcp-datamate-893-codex.test.ts | 5 +- .../release-readiness.md | 5 +- .../security-fix-verification.md | 16 +++++ .../security-review-snapshot.md | 2 + 10 files changed, 149 insertions(+), 17 deletions(-) create mode 100644 packages/opencode/test/cli/providers-logout.test.ts diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 45afccc2ee..daad669027 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -2,7 +2,7 @@ import { readFile } from "fs/promises" import path from "path" import { parseTree, findNodeAtLocation, getNodeValue } from "jsonc-parser" import { resolveConfigPath, addMcpToConfig, readMcpEntryFromDisk } from "../mcp/config" -import { scanProjectMcpJsonFiles } from "../mcp/discovery-files" +import { DiscoveryFiles } from "../mcp/discovery-files" import { Filesystem } from "../util/filesystem" import { Log } from "@/altimate/util/log" import type { Config } from "../config/config" @@ -45,7 +45,7 @@ function extractServersMap( */ async function findAllMcpJsonFiles(projectRootDir: string): Promise { try { - return (await scanProjectMcpJsonFiles(projectRootDir)).map((file) => file.path) + return (await DiscoveryFiles.scanProjectMcpJsonFiles(projectRootDir)).map((file) => file.path) } catch { log.warn("findAllMcpJsonFiles: glob scan failed", { cwd: projectRootDir }) return [] diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 87c30f6d5c..35530ce62e 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -15,6 +15,7 @@ import { Plugin } from "../../plugin" import type { Hooks } from "@opencode-ai/plugin" import { Process } from "@/util/process" import { errorMessage } from "@/util/error" +import { FreeTier } from "@/altimate/free/client" import { text } from "node:stream/consumers" import { Effect, Option } from "effect" @@ -516,21 +517,39 @@ export const ProvidersLogoutCommand = effectCmd({ UI.empty() const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) + // altimate_change start — integrate the dedicated managed Base store with normal provider logout + const hasAltimateBaseCredential = yield* Effect.tryPromise(() => FreeTier.credentials()).pipe( + Effect.map((credential) => credential !== undefined), + // A malformed or unreadable managed credential should still be removable through logout. + Effect.orElseSucceed(() => true), + ) yield* Prompt.intro("Remove credential") - if (credentials.length === 0) { + const database = yield* modelsDev.get() + const hasLegacyAltimateBaseCredential = credentials.some(([key]) => key === FreeTier.PROVIDER_ID) + const options = credentials + .filter(([key]) => key !== FreeTier.PROVIDER_ID) + .map(([key, value]) => ({ + label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", + value: key, + })) + if (hasAltimateBaseCredential || hasLegacyAltimateBaseCredential) { + options.push({ + label: "Altimate Base" + UI.Style.TEXT_DIM + " (managed)", + value: FreeTier.PROVIDER_ID, + }) + } + if (options.length === 0) { yield* Prompt.log.error("No credentials found") return } - const database = yield* modelsDev.get() - const options = credentials.map(([key, value]) => ({ - label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", - value: key, - })) + const requestedProvider = args.provider?.toLowerCase() const provider = args.provider ? options.find( (option) => option.value === args.provider || - database[option.value]?.name?.toLowerCase() === args.provider?.toLowerCase(), + database[option.value]?.name?.toLowerCase() === requestedProvider || + (option.value === FreeTier.PROVIDER_ID && + (requestedProvider === FreeTier.MODEL_ID || requestedProvider === "altimate base")), )?.value : yield* promptValue( yield* Prompt.autocomplete({ @@ -540,6 +559,14 @@ export const ProvidersLogoutCommand = effectCmd({ }), ) if (!provider) return yield* fail(`Unknown configured provider "${args.provider}"`) + if (provider === FreeTier.PROVIDER_ID) { + yield* cliTry("Failed to remove Altimate Base credential: ", () => FreeTier.clear()) + // Remove any stale entry created by pre-managed Base builds without touching other providers. + yield* Effect.orDie(authSvc.remove(FreeTier.PROVIDER_ID)) + yield* Prompt.outro("Logout successful") + return + } + // altimate_change end yield* Effect.orDie(authSvc.remove(provider)) yield* Prompt.outro("Logout successful") }), diff --git a/packages/opencode/src/mcp/discover.ts b/packages/opencode/src/mcp/discover.ts index e64a60359a..51aa60b081 100644 --- a/packages/opencode/src/mcp/discover.ts +++ b/packages/opencode/src/mcp/discover.ts @@ -5,7 +5,7 @@ import { Log } from "../util/log" import { Filesystem } from "../util/filesystem" import { ConfigPaths } from "../config/paths" import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" -import { resolveProjectDiscoveryFile, scanProjectMcpJsonFiles, type ProjectMcpFile } from "./discovery-files" +import { DiscoveryFiles } from "./discovery-files" const log = Log.create({ service: "mcp.discover" }) @@ -313,10 +313,10 @@ export async function discoverExternalMcp(projectDir: string): Promise<{ // dedup is deterministic and keeps the historical .vscode > .cursor > copilot order // (a plain alphabetical sort would let .cursor override .vscode). const IDE_PRECEDENCE = [".vscode/mcp.json", ".cursor/mcp.json", ".github/copilot/mcp.json"] - let mcpJsonFiles: ProjectMcpFile[] = [] + let mcpJsonFiles: DiscoveryFiles.ProjectMcpFile[] = [] try { - const scanned = await scanProjectMcpJsonFiles(projectDir) - const rank = (file: ProjectMcpFile) => { + const scanned = await DiscoveryFiles.scanProjectMcpJsonFiles(projectDir) + const rank = (file: DiscoveryFiles.ProjectMcpFile) => { const i = IDE_PRECEDENCE.indexOf(file.relative) return i === -1 ? IDE_PRECEDENCE.length : i } @@ -348,7 +348,9 @@ export async function discoverExternalMcp(projectDir: string): Promise<{ for (const { dir, label, projectScoped } of dirs) { const candidate = path.join(dir, source.file) - const resolved = projectScoped ? await resolveProjectDiscoveryFile(projectDir, candidate) : undefined + const resolved = projectScoped + ? await DiscoveryFiles.resolveProjectDiscoveryFile(projectDir, candidate) + : undefined if (projectScoped && !resolved) continue const filePath = resolved?.path ?? candidate const parsed = await readJsonSafe(filePath) diff --git a/packages/opencode/src/mcp/discovery-files.ts b/packages/opencode/src/mcp/discovery-files.ts index ce7afc6182..f378ef2538 100644 --- a/packages/opencode/src/mcp/discovery-files.ts +++ b/packages/opencode/src/mcp/discovery-files.ts @@ -62,5 +62,7 @@ export async function scanProjectMcpJsonFiles(projectDir: string): Promise resolveProjectDiscoveryFile(projectDir, candidate))) return files .filter((file): file is ProjectMcpFile => file !== undefined) - .sort((a, b) => a.relative.localeCompare(b.relative)) + .sort((a, b) => (a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0)) } + +export * as DiscoveryFiles from "./discovery-files" diff --git a/packages/opencode/test/cli/providers-logout.test.ts b/packages/opencode/test/cli/providers-logout.test.ts new file mode 100644 index 0000000000..d80948adb3 --- /dev/null +++ b/packages/opencode/test/cli/providers-logout.test.ts @@ -0,0 +1,66 @@ +import { describe, expect } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { Effect } from "effect" +import { cliIt } from "../lib/cli-process" + +describe("providers logout", () => { + cliIt.live( + "removes Altimate Base independently from ordinary provider credentials", + ({ home, opencode }) => + Effect.gen(function* () { + const dataDir = path.join(home, ".local", "share", "altimate-code") + const basePath = path.join(dataDir, "altimate-base.json") + const authPath = path.join(dataDir, "auth.json") + const baseRecord = { + version: 1, + installSecret: "test-install-secret", + apiKey: "sk-altimate-base-test", + baseURL: "https://gateway.test", + } + const anthropic = { type: "api", key: "anthropic-test-key" } + const writeBase = () => fs.writeFile(basePath, JSON.stringify(baseRecord, null, 2) + "\n", { mode: 0o600 }) + + yield* Effect.promise(() => fs.mkdir(dataDir, { recursive: true })) + yield* Effect.promise(writeBase) + yield* Effect.promise(() => + fs.writeFile( + authPath, + JSON.stringify({ + anthropic, + // Old builds could leave this entry in the shared auth store. Base logout owns only + // this reserved provider ID and must preserve every unrelated credential. + "altimate-free": { type: "api", key: "legacy-base-key" }, + }), + { mode: 0o600 }, + ), + ) + + const baseLogout = yield* opencode.spawn(["providers", "logout", "altimate-base"], { + env: { OPENCODE_AUTH_CONTENT: "" }, + }) + opencode.expectExit(baseLogout, 0, "providers logout altimate-base") + expect(baseLogout.stdout).toContain("Logout successful") + expect( + yield* Effect.promise(() => + fs.stat(basePath).then( + () => true, + () => false, + ), + ), + ).toBe(false) + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({ anthropic }) + + yield* Effect.promise(writeBase) + const baseBeforeGenericLogout = yield* Effect.promise(() => fs.readFile(basePath, "utf8")) + const genericLogout = yield* opencode.spawn(["providers", "logout", "anthropic"], { + env: { OPENCODE_AUTH_CONTENT: "" }, + }) + opencode.expectExit(genericLogout, 0, "providers logout anthropic") + expect(genericLogout.stdout).toContain("Logout successful") + expect(yield* Effect.promise(() => fs.readFile(basePath, "utf8"))).toBe(baseBeforeGenericLogout) + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({}) + }), + 120_000, + ) +}) diff --git a/packages/opencode/test/mcp/discover.test.ts b/packages/opencode/test/mcp/discover.test.ts index 01dc8cea00..c0ccc2bc48 100644 --- a/packages/opencode/test/mcp/discover.test.ts +++ b/packages/opencode/test/mcp/discover.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm, mkdir, symlink, writeFile } from "fs/promises" import os, { tmpdir } from "os" import path from "path" import { discoverExternalMcp } from "../../src/mcp/discover" +import { DiscoveryFiles } from "../../src/mcp/discovery-files" const testSymlink = process.platform === "win32" ? test.skip : test @@ -27,6 +28,16 @@ afterEach(async () => { }) describe("discoverExternalMcp", () => { + test("sorts authored config paths by code unit instead of host locale", async () => { + for (const directory of ["z-config", "ä-config"]) { + await mkdir(path.join(tempDir, directory), { recursive: true }) + await writeFile(path.join(tempDir, directory, "mcp.json"), "{}") + } + + const files = await DiscoveryFiles.scanProjectMcpJsonFiles(tempDir) + expect(files.map((file) => file.relative)).toEqual(["z-config/mcp.json", "ä-config/mcp.json"]) + }) + test("parses .vscode/mcp.json with servers key", async () => { await mkdir(path.join(tempDir, ".vscode"), { recursive: true }) await writeFile( diff --git a/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts b/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts index b37327af6e..d4e5a8244f 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts @@ -1,5 +1,6 @@ -import { describe, test, expect } from "bun:test" +import { describe, test, expect, spyOn } from "bun:test" import { mkdir, readFile, symlink, writeFile } from "fs/promises" +import os from "os" import path from "path" import { tmpdir } from "../fixture/fixture" import { discoverExternalMcp } from "../../src/mcp/discover" @@ -21,11 +22,13 @@ async function withIsolatedHome(fn: (home: string) => Promise): Promise await using home = await tmpdir() const oldHome = process.env.HOME const oldUserProfile = process.env.USERPROFILE + const homedirSpy = spyOn(os, "homedir").mockImplementation(() => home.path) process.env.HOME = home.path process.env.USERPROFILE = home.path try { return await fn(home.path) } finally { + homedirSpy.mockRestore() if (oldHome === undefined) delete process.env.HOME else process.env.HOME = oldHome if (oldUserProfile === undefined) delete process.env.USERPROFILE diff --git a/research/altimate-base-release-2026-08-30/release-readiness.md b/research/altimate-base-release-2026-08-30/release-readiness.md index e94dcedefb..528bab745b 100644 --- a/research/altimate-base-release-2026-08-30/release-readiness.md +++ b/research/altimate-base-release-2026-08-30/release-readiness.md @@ -45,6 +45,8 @@ The default-selection rules are deliberately narrow: - A persisted Altimate Base session continues to load, fork, and resume as Altimate Base. - Other providers retain their existing selection, headers, credentials, and runtime paths. +Altimate Base can also be disconnected through the normal `providers logout` flow. The command recognizes both the internal provider ID and the public `altimate-base` name, removes the dedicated managed credential, cleans only the reserved legacy Base auth entry, and leaves all unrelated provider credentials unchanged. + The registration dialog cannot be dismissed or replaced while registration is active. Escape, Ctrl-C, backdrop clicks, and programmatic replacement all honor the same close guard, without discarding a selection when a close is vetoed. ## Configurable gateway URL @@ -117,10 +119,11 @@ WARM serving returned 401 without its serving key and 200 with the key. The mana - Post-fix focused verification: 338 tests green across glob pruning, MCP/Datamate discovery, ClickHouse transport, dbt import, and normalization; one existing MCP todo remains. - Broader compatibility verification: 140 driver/SDK resolver tests and 166 MCP lifecycle/discovery tests green, plus all changed Base/model suites and 6 TUI consent tests green. - Root typecheck: 13/13 workspaces green after the final security edits. +- Final release-review regressions: locale-independent MCP ordering passed 23/23 discovery tests; Datamate passed 15 tests with one existing todo; the real provider-logout CLI test passed all 6 isolation assertions; the package typecheck remained green. - Local pre-release build with a non-production HTTPS endpoint: built successfully and the compiled binary passed its startup smoke check. - Branding, changed-file marker, and strict required-marker audits: green after the final report was added. -The immutable scan output is preserved in `security-review-snapshot.md`; it describes the stale scanned head. The current remediation state and proof are in `security-fix-verification.md`. +The historical scan body is preserved in `security-review-snapshot.md` with a prominent resolved-status banner; it describes the stale scanned head. The current remediation state and proof are in `security-fix-verification.md`. ### Harness repository diff --git a/research/altimate-base-release-2026-08-30/security-fix-verification.md b/research/altimate-base-release-2026-08-30/security-fix-verification.md index 49af99d306..7ac14c676e 100644 --- a/research/altimate-base-release-2026-08-30/security-fix-verification.md +++ b/research/altimate-base-release-2026-08-30/security-fix-verification.md @@ -14,6 +14,16 @@ The two medium-severity findings reported against stale client head `04fceafcf8` No Altimate Base model, provider, credential, or routing code was changed by these fixes. +## Final release-review follow-ups + +The review of the remediated branch found three additional release-quality issues, all now closed in the current tree: + +1. MCP file ordering used `localeCompare`, which could make the first Datamate transport differ by host locale. Discovery now uses a locale-independent code-unit comparison and has a regression using paths whose relative order differs under common locales. +2. The new discovery helper did not expose the repository-required namespace projection. It now exports `DiscoveryFiles`, and both MCP discovery and Datamate consume that projection. +3. The managed Altimate Base credential lived outside the shared provider auth store, so the normal `providers logout` command could not remove it. The command now lists the managed credential, accepts `altimate-free`, `altimate-base`, or `Altimate Base`, clears the dedicated store, and removes only the reserved stale shared-auth entry. Ordinary provider logout keeps the previous path and does not touch Base. + +The logout behavior is covered through the real CLI entrypoint in both directions: Base logout preserves an unrelated provider credential, and ordinary provider logout preserves the byte-identical Base credential record. + ## Security invariants ### MCP and Datamate discovery @@ -47,8 +57,10 @@ The original and follow-up bypasses no longer reproduce: - `packages/opencode/src/mcp/discovery-files.ts` - `packages/opencode/src/mcp/discover.ts` - `packages/opencode/src/altimate/datamate-transport.ts` +- `packages/opencode/src/cli/cmd/providers.ts` - `packages/opencode/test/mcp/discover.test.ts` - `packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts` +- `packages/opencode/test/cli/providers-logout.test.ts` - `packages/drivers/src/clickhouse.ts` - `packages/drivers/test/clickhouse-unit.test.ts` - `packages/opencode/test/altimate/connections.test.ts` @@ -66,6 +78,10 @@ The original and follow-up bypasses no longer reproduce: - `packages/tui`: Altimate Base dialog suite — 6 passed. - Repository `bun run typecheck` — 13 of 13 tasks successful. - `git diff --check` — clean. +- Final MCP discovery regression — 23 passed, including locale-independent ordering. +- Final Datamate release regression — 15 passed, 1 existing todo. Its home-directory isolation was corrected so developer MCP configuration cannot contaminate the result. +- Real CLI managed logout regression — 1 passed with 6 assertions covering Base removal, legacy Base cleanup, ordinary-provider preservation, and the inverse isolation check. +- `packages/opencode` typecheck after the final release-review fixes — passed. ## Residual uncertainty diff --git a/research/altimate-base-release-2026-08-30/security-review-snapshot.md b/research/altimate-base-release-2026-08-30/security-review-snapshot.md index f48fa482c0..590650fcb1 100644 --- a/research/altimate-base-release-2026-08-30/security-review-snapshot.md +++ b/research/altimate-base-release-2026-08-30/security-review-snapshot.md @@ -1,3 +1,5 @@ +> **Historical scan snapshot — both findings are resolved.** This report was generated against client head `04fceafcf8`, not the current release branch. Commit `755b410787` fixed both findings and their reviewed variants. See `security-fix-verification.md` for the current status and passing proof. The original report body is preserved below as historical evidence and must not be read as the current release state. + # Security Review: altimate-base-release-final ## Scope From a68f0b0ee8322764415f3133e40b82a264ccb06b Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 08:45:10 -0700 Subject: [PATCH 18/33] fix: clear partial Altimate Base registration state --- packages/opencode/src/altimate/free/client.ts | 5 +++++ packages/opencode/src/cli/cmd/providers.ts | 5 ++--- .../opencode/test/cli/providers-logout.test.ts | 14 ++++++++++---- .../release-readiness.md | 2 +- .../security-fix-verification.md | 10 ++++++---- 5 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 8b06794a7a..453f4705a9 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -88,6 +88,11 @@ export async function credentials(): Promise { } } +/** Includes install-secret-only state left behind when a consented registration request fails. */ +export async function hasStoredRegistrationState(): Promise { + return (await FreeTierStore.read()) !== undefined +} + function expired(value: Credentials): boolean { if (!value.expiresAt) return false const timestamp = Date.parse(value.expiresAt) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 35530ce62e..6c63a833a4 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -518,8 +518,7 @@ export const ProvidersLogoutCommand = effectCmd({ UI.empty() const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) // altimate_change start — integrate the dedicated managed Base store with normal provider logout - const hasAltimateBaseCredential = yield* Effect.tryPromise(() => FreeTier.credentials()).pipe( - Effect.map((credential) => credential !== undefined), + const hasAltimateBaseState = yield* Effect.tryPromise(() => FreeTier.hasStoredRegistrationState()).pipe( // A malformed or unreadable managed credential should still be removable through logout. Effect.orElseSucceed(() => true), ) @@ -532,7 +531,7 @@ export const ProvidersLogoutCommand = effectCmd({ label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", value: key, })) - if (hasAltimateBaseCredential || hasLegacyAltimateBaseCredential) { + if (hasAltimateBaseState || hasLegacyAltimateBaseCredential) { options.push({ label: "Altimate Base" + UI.Style.TEXT_DIM + " (managed)", value: FreeTier.PROVIDER_ID, diff --git a/packages/opencode/test/cli/providers-logout.test.ts b/packages/opencode/test/cli/providers-logout.test.ts index d80948adb3..a0bf30faf2 100644 --- a/packages/opencode/test/cli/providers-logout.test.ts +++ b/packages/opencode/test/cli/providers-logout.test.ts @@ -12,17 +12,23 @@ describe("providers logout", () => { const dataDir = path.join(home, ".local", "share", "altimate-code") const basePath = path.join(dataDir, "altimate-base.json") const authPath = path.join(dataDir, "auth.json") - const baseRecord = { + const failedRegistrationState = { version: 1, installSecret: "test-install-secret", + } + const registeredBase = { + ...failedRegistrationState, apiKey: "sk-altimate-base-test", baseURL: "https://gateway.test", } const anthropic = { type: "api", key: "anthropic-test-key" } - const writeBase = () => fs.writeFile(basePath, JSON.stringify(baseRecord, null, 2) + "\n", { mode: 0o600 }) + const writeBase = (record: typeof failedRegistrationState | typeof registeredBase) => + fs.writeFile(basePath, JSON.stringify(record, null, 2) + "\n", { mode: 0o600 }) yield* Effect.promise(() => fs.mkdir(dataDir, { recursive: true })) - yield* Effect.promise(writeBase) + // A failed consented registration intentionally retains only the install secret so a retry + // keeps the same abuse-control identity. Logout must still make that partial state erasable. + yield* Effect.promise(() => writeBase(failedRegistrationState)) yield* Effect.promise(() => fs.writeFile( authPath, @@ -51,7 +57,7 @@ describe("providers logout", () => { ).toBe(false) expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({ anthropic }) - yield* Effect.promise(writeBase) + yield* Effect.promise(() => writeBase(registeredBase)) const baseBeforeGenericLogout = yield* Effect.promise(() => fs.readFile(basePath, "utf8")) const genericLogout = yield* opencode.spawn(["providers", "logout", "anthropic"], { env: { OPENCODE_AUTH_CONTENT: "" }, diff --git a/research/altimate-base-release-2026-08-30/release-readiness.md b/research/altimate-base-release-2026-08-30/release-readiness.md index 528bab745b..643d6df510 100644 --- a/research/altimate-base-release-2026-08-30/release-readiness.md +++ b/research/altimate-base-release-2026-08-30/release-readiness.md @@ -45,7 +45,7 @@ The default-selection rules are deliberately narrow: - A persisted Altimate Base session continues to load, fork, and resume as Altimate Base. - Other providers retain their existing selection, headers, credentials, and runtime paths. -Altimate Base can also be disconnected through the normal `providers logout` flow. The command recognizes both the internal provider ID and the public `altimate-base` name, removes the dedicated managed credential, cleans only the reserved legacy Base auth entry, and leaves all unrelated provider credentials unchanged. +Altimate Base can also be disconnected through the normal `providers logout` flow. The command recognizes both the internal provider ID and the public `altimate-base` name, removes the dedicated managed registration state (including an install-secret-only failed attempt), cleans only the reserved legacy Base auth entry, and leaves all unrelated provider credentials unchanged. The registration dialog cannot be dismissed or replaced while registration is active. Escape, Ctrl-C, backdrop clicks, and programmatic replacement all honor the same close guard, without discarding a selection when a close is vetoed. diff --git a/research/altimate-base-release-2026-08-30/security-fix-verification.md b/research/altimate-base-release-2026-08-30/security-fix-verification.md index 7ac14c676e..82067c27c3 100644 --- a/research/altimate-base-release-2026-08-30/security-fix-verification.md +++ b/research/altimate-base-release-2026-08-30/security-fix-verification.md @@ -12,17 +12,18 @@ The two medium-severity findings reported against stale client head `04fceafcf8` 2. An authored-looking MCP path could be a symlink to a dependency or an out-of-project file; every candidate is now resolved canonically and checked against the project boundary and ignore policy before parsing. 3. dbt-clickhouse expresses TLS intent as `secure: true`; the ClickHouse driver now treats `secure`, `tls`, and `ssl` identically at the final transport boundary. -No Altimate Base model, provider, credential, or routing code was changed by these fixes. +No Altimate Base model, provider, credential, or routing code was changed by the two original scan remediations. The final release-review follow-ups below add only explicit logout discovery/clearing for managed local state; registration and inference behavior are unchanged. ## Final release-review follow-ups -The review of the remediated branch found three additional release-quality issues, all now closed in the current tree: +The review of the remediated branch found four additional release-quality issues, all now closed in the current tree: 1. MCP file ordering used `localeCompare`, which could make the first Datamate transport differ by host locale. Discovery now uses a locale-independent code-unit comparison and has a regression using paths whose relative order differs under common locales. 2. The new discovery helper did not expose the repository-required namespace projection. It now exports `DiscoveryFiles`, and both MCP discovery and Datamate consume that projection. -3. The managed Altimate Base credential lived outside the shared provider auth store, so the normal `providers logout` command could not remove it. The command now lists the managed credential, accepts `altimate-free`, `altimate-base`, or `Altimate Base`, clears the dedicated store, and removes only the reserved stale shared-auth entry. Ordinary provider logout keeps the previous path and does not touch Base. +3. The managed Altimate Base credential lived outside the shared provider auth store, so the normal `providers logout` command could not remove it. The command now lists managed registration state, accepts `altimate-free`, `altimate-base`, or `Altimate Base`, clears the dedicated store, and removes only the reserved stale shared-auth entry. Ordinary provider logout keeps the previous path and does not touch Base. +4. A failed consented registration can intentionally leave an install-secret-only record so retries keep the same abuse-control identity. Logout now recognizes and removes that partial registration state even though it does not connect or activate the model. -The logout behavior is covered through the real CLI entrypoint in both directions: Base logout preserves an unrelated provider credential, and ordinary provider logout preserves the byte-identical Base credential record. +The logout behavior is covered through the real CLI entrypoint in both directions: Base logout removes install-secret-only failed-registration state while preserving an unrelated provider credential, and ordinary provider logout preserves the byte-identical complete Base credential record. ## Security invariants @@ -57,6 +58,7 @@ The original and follow-up bypasses no longer reproduce: - `packages/opencode/src/mcp/discovery-files.ts` - `packages/opencode/src/mcp/discover.ts` - `packages/opencode/src/altimate/datamate-transport.ts` +- `packages/opencode/src/altimate/free/client.ts` - `packages/opencode/src/cli/cmd/providers.ts` - `packages/opencode/test/mcp/discover.test.ts` - `packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts` From bb778f7f35911e7893263df522a6278b543f0636 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 08:54:24 -0700 Subject: [PATCH 19/33] fix: preserve Altimate Base abuse identity on logout --- packages/opencode/src/altimate/free/client.ts | 19 +++++++---- packages/opencode/src/cli/cmd/providers.ts | 10 +++--- .../test/altimate/altimate-base.test.ts | 5 ++- .../test/cli/providers-logout.test.ts | 32 +++++++++++-------- .../release-readiness.md | 4 +-- .../security-fix-verification.md | 10 +++--- 6 files changed, 47 insertions(+), 33 deletions(-) diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 453f4705a9..bf71832e29 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -88,11 +88,6 @@ export async function credentials(): Promise { } } -/** Includes install-secret-only state left behind when a consented registration request fails. */ -export async function hasStoredRegistrationState(): Promise { - return (await FreeTierStore.read()) !== undefined -} - function expired(value: Credentials): boolean { if (!value.expiresAt) return false const timestamp = Date.parse(value.expiresAt) @@ -112,9 +107,19 @@ export async function isRegistered(): Promise { return (await credentialsForLoad()) !== undefined } -export async function clear(): Promise { +/** + * Disconnect the managed provider without resetting the fair-use identity. + * + * The install secret never leaves this machine; registration sends only its SHA-256 hash. Keeping + * it across logout prevents the supported CLI flow from minting a fresh free-allowance principal. + */ +export async function logout(): Promise { rejectedCredentials.clear() - await Flock.withLock(LOCK_KEY, () => FreeTierStore.remove()) + await Flock.withLock(LOCK_KEY, async () => { + const stored = await FreeTierStore.read() + if (!stored) return + await FreeTierStore.write({ version: 1, installSecret: stored.installSecret }) + }) } export function sanitizeCliVersion(raw: string): string { diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 6c63a833a4..ceb9da0c6b 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -518,8 +518,10 @@ export const ProvidersLogoutCommand = effectCmd({ UI.empty() const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) // altimate_change start — integrate the dedicated managed Base store with normal provider logout - const hasAltimateBaseState = yield* Effect.tryPromise(() => FreeTier.hasStoredRegistrationState()).pipe( - // A malformed or unreadable managed credential should still be removable through logout. + const hasAltimateBaseCredential = yield* Effect.tryPromise(() => FreeTier.credentials()).pipe( + Effect.map((value) => value !== undefined), + // Keep malformed credential state visible so logout reports the storage error instead of + // silently claiming there is nothing configured. Effect.orElseSucceed(() => true), ) yield* Prompt.intro("Remove credential") @@ -531,7 +533,7 @@ export const ProvidersLogoutCommand = effectCmd({ label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", value: key, })) - if (hasAltimateBaseState || hasLegacyAltimateBaseCredential) { + if (hasAltimateBaseCredential || hasLegacyAltimateBaseCredential) { options.push({ label: "Altimate Base" + UI.Style.TEXT_DIM + " (managed)", value: FreeTier.PROVIDER_ID, @@ -559,7 +561,7 @@ export const ProvidersLogoutCommand = effectCmd({ ) if (!provider) return yield* fail(`Unknown configured provider "${args.provider}"`) if (provider === FreeTier.PROVIDER_ID) { - yield* cliTry("Failed to remove Altimate Base credential: ", () => FreeTier.clear()) + yield* cliTry("Failed to remove Altimate Base credential: ", () => FreeTier.logout()) // Remove any stale entry created by pre-managed Base builds without touching other providers. yield* Effect.orDie(authSvc.remove(FreeTier.PROVIDER_ID)) yield* Prompt.outro("Logout successful") diff --git a/packages/opencode/test/altimate/altimate-base.test.ts b/packages/opencode/test/altimate/altimate-base.test.ts index 832d3abc2b..5b07137a49 100644 --- a/packages/opencode/test/altimate/altimate-base.test.ts +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -48,7 +48,10 @@ function json(body: Record, status = 200) { beforeEach(async () => { fetchSpy?.mockRestore() fetchSpy = undefined - await FreeTier.clear() + // Exercise the production disconnect path, then remove its retained fair-use identity so each + // test starts as a genuinely fresh installation. + await FreeTier.logout() + await FreeTierStore.remove() delete process.env.ALTIMATE_BASE_GATEWAY_URL delete process.env.ALTIMATE_FREE_GATEWAY_URL process.env.ALTIMATE_BASE_GATEWAY_URL = GATEWAY_URL diff --git a/packages/opencode/test/cli/providers-logout.test.ts b/packages/opencode/test/cli/providers-logout.test.ts index a0bf30faf2..40295c1e74 100644 --- a/packages/opencode/test/cli/providers-logout.test.ts +++ b/packages/opencode/test/cli/providers-logout.test.ts @@ -12,23 +12,23 @@ describe("providers logout", () => { const dataDir = path.join(home, ".local", "share", "altimate-code") const basePath = path.join(dataDir, "altimate-base.json") const authPath = path.join(dataDir, "auth.json") - const failedRegistrationState = { + const disconnectedBase = { version: 1, installSecret: "test-install-secret", } const registeredBase = { - ...failedRegistrationState, + ...disconnectedBase, apiKey: "sk-altimate-base-test", baseURL: "https://gateway.test", + expiresAt: "2099-01-01T00:00:00.000Z", + rejected: true, } const anthropic = { type: "api", key: "anthropic-test-key" } - const writeBase = (record: typeof failedRegistrationState | typeof registeredBase) => + const writeBase = (record: typeof disconnectedBase | typeof registeredBase) => fs.writeFile(basePath, JSON.stringify(record, null, 2) + "\n", { mode: 0o600 }) yield* Effect.promise(() => fs.mkdir(dataDir, { recursive: true })) - // A failed consented registration intentionally retains only the install secret so a retry - // keeps the same abuse-control identity. Logout must still make that partial state erasable. - yield* Effect.promise(() => writeBase(failedRegistrationState)) + yield* Effect.promise(() => writeBase(registeredBase)) yield* Effect.promise(() => fs.writeFile( authPath, @@ -47,14 +47,18 @@ describe("providers logout", () => { }) opencode.expectExit(baseLogout, 0, "providers logout altimate-base") expect(baseLogout.stdout).toContain("Logout successful") - expect( - yield* Effect.promise(() => - fs.stat(basePath).then( - () => true, - () => false, - ), - ), - ).toBe(false) + // Logout strips every usable credential field while retaining the local fair-use identity. + // A later consented setup therefore reuses the same gateway budget principal. + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(basePath, "utf8")))).toEqual(disconnectedBase) + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({ anthropic }) + + const disconnectedBeforeRepeatedLogout = yield* Effect.promise(() => fs.readFile(basePath, "utf8")) + const repeatedBaseLogout = yield* opencode.spawn(["providers", "logout", "altimate-base"], { + env: { OPENCODE_AUTH_CONTENT: "" }, + }) + opencode.expectExit(repeatedBaseLogout, 1, "providers logout disconnected altimate-base") + expect(repeatedBaseLogout.stderr).toContain('Unknown configured provider "altimate-base"') + expect(yield* Effect.promise(() => fs.readFile(basePath, "utf8"))).toBe(disconnectedBeforeRepeatedLogout) expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({ anthropic }) yield* Effect.promise(() => writeBase(registeredBase)) diff --git a/research/altimate-base-release-2026-08-30/release-readiness.md b/research/altimate-base-release-2026-08-30/release-readiness.md index 643d6df510..7a73b7da59 100644 --- a/research/altimate-base-release-2026-08-30/release-readiness.md +++ b/research/altimate-base-release-2026-08-30/release-readiness.md @@ -45,7 +45,7 @@ The default-selection rules are deliberately narrow: - A persisted Altimate Base session continues to load, fork, and resume as Altimate Base. - Other providers retain their existing selection, headers, credentials, and runtime paths. -Altimate Base can also be disconnected through the normal `providers logout` flow. The command recognizes both the internal provider ID and the public `altimate-base` name, removes the dedicated managed registration state (including an install-secret-only failed attempt), cleans only the reserved legacy Base auth entry, and leaves all unrelated provider credentials unchanged. +Altimate Base can also be disconnected through the normal `providers logout` flow. The command recognizes both the internal provider ID and the public `altimate-base` name, removes every usable gateway credential field, cleans only the reserved legacy Base auth entry, and leaves all unrelated provider credentials unchanged. It intentionally retains the opaque local install identity used to enforce the free allowance; an install-secret-only record neither activates the model nor appears as a connected credential. The registration dialog cannot be dismissed or replaced while registration is active. Escape, Ctrl-C, backdrop clicks, and programmatic replacement all honor the same close guard, without discarding a selection when a close is vetoed. @@ -119,7 +119,7 @@ WARM serving returned 401 without its serving key and 200 with the key. The mana - Post-fix focused verification: 338 tests green across glob pruning, MCP/Datamate discovery, ClickHouse transport, dbt import, and normalization; one existing MCP todo remains. - Broader compatibility verification: 140 driver/SDK resolver tests and 166 MCP lifecycle/discovery tests green, plus all changed Base/model suites and 6 TUI consent tests green. - Root typecheck: 13/13 workspaces green after the final security edits. -- Final release-review regressions: locale-independent MCP ordering passed 23/23 discovery tests; Datamate passed 15 tests with one existing todo; the real provider-logout CLI test passed all 6 isolation assertions; the package typecheck remained green. +- Final release-review regressions: locale-independent MCP ordering passed 23/23 discovery tests; Datamate passed 15 tests with one existing todo; the real provider-logout CLI test passed all 9 security and isolation assertions; the package typecheck remained green. - Local pre-release build with a non-production HTTPS endpoint: built successfully and the compiled binary passed its startup smoke check. - Branding, changed-file marker, and strict required-marker audits: green after the final report was added. diff --git a/research/altimate-base-release-2026-08-30/security-fix-verification.md b/research/altimate-base-release-2026-08-30/security-fix-verification.md index 82067c27c3..b8e7a6c701 100644 --- a/research/altimate-base-release-2026-08-30/security-fix-verification.md +++ b/research/altimate-base-release-2026-08-30/security-fix-verification.md @@ -12,7 +12,7 @@ The two medium-severity findings reported against stale client head `04fceafcf8` 2. An authored-looking MCP path could be a symlink to a dependency or an out-of-project file; every candidate is now resolved canonically and checked against the project boundary and ignore policy before parsing. 3. dbt-clickhouse expresses TLS intent as `secure: true`; the ClickHouse driver now treats `secure`, `tls`, and `ssl` identically at the final transport boundary. -No Altimate Base model, provider, credential, or routing code was changed by the two original scan remediations. The final release-review follow-ups below add only explicit logout discovery/clearing for managed local state; registration and inference behavior are unchanged. +No Altimate Base model, provider, credential, or routing code was changed by the two original scan remediations. The final release-review follow-ups below add only explicit managed-provider disconnect behavior; registration and inference behavior are unchanged. ## Final release-review follow-ups @@ -20,10 +20,10 @@ The review of the remediated branch found four additional release-quality issues 1. MCP file ordering used `localeCompare`, which could make the first Datamate transport differ by host locale. Discovery now uses a locale-independent code-unit comparison and has a regression using paths whose relative order differs under common locales. 2. The new discovery helper did not expose the repository-required namespace projection. It now exports `DiscoveryFiles`, and both MCP discovery and Datamate consume that projection. -3. The managed Altimate Base credential lived outside the shared provider auth store, so the normal `providers logout` command could not remove it. The command now lists managed registration state, accepts `altimate-free`, `altimate-base`, or `Altimate Base`, clears the dedicated store, and removes only the reserved stale shared-auth entry. Ordinary provider logout keeps the previous path and does not touch Base. -4. A failed consented registration can intentionally leave an install-secret-only record so retries keep the same abuse-control identity. Logout now recognizes and removes that partial registration state even though it does not connect or activate the model. +3. The managed Altimate Base credential lived outside the shared provider auth store, so the normal `providers logout` command could not disconnect it. The command now lists a complete managed credential, accepts `altimate-free`, `altimate-base`, or `Altimate Base`, strips its API key, gateway URL, expiry, and rejection marker, and removes only the reserved stale shared-auth entry. Ordinary provider logout keeps the previous path and does not touch Base. +4. A first logout implementation also removed the install secret, which would have made the supported CLI flow a way to mint a fresh free-allowance principal. The final behavior retains only that opaque local identity. An install-secret-only record is deliberately not listed as connected and cannot activate the model; registration sends only its SHA-256 hash. -The logout behavior is covered through the real CLI entrypoint in both directions: Base logout removes install-secret-only failed-registration state while preserving an unrelated provider credential, and ordinary provider logout preserves the byte-identical complete Base credential record. +The logout behavior is covered through the real CLI entrypoint in both directions: Base logout reduces a complete credential to the same install-secret-only identity while preserving an unrelated provider credential, and ordinary provider logout preserves the byte-identical complete Base credential record. ## Security invariants @@ -82,7 +82,7 @@ The original and follow-up bypasses no longer reproduce: - `git diff --check` — clean. - Final MCP discovery regression — 23 passed, including locale-independent ordering. - Final Datamate release regression — 15 passed, 1 existing todo. Its home-directory isolation was corrected so developer MCP configuration cannot contaminate the result. -- Real CLI managed logout regression — 1 passed with 6 assertions covering Base removal, legacy Base cleanup, ordinary-provider preservation, and the inverse isolation check. +- Real CLI managed logout regression — 1 passed with 9 assertions covering Base credential stripping, install-identity retention, partial-state invisibility, legacy Base cleanup, ordinary-provider preservation, and the inverse isolation check. - `packages/opencode` typecheck after the final release-review fixes — passed. ## Residual uncertainty From 696c49d6062b52325b7781a4e616bfb42717d883 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 08:57:51 -0700 Subject: [PATCH 20/33] fix: parse serialized ClickHouse TLS flags --- packages/drivers/src/clickhouse.ts | 21 ++++++++++++++----- packages/drivers/test/clickhouse-unit.test.ts | 20 ++++++++++++++++++ .../release-readiness.md | 6 +++--- .../security-fix-verification.md | 10 +++++---- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/packages/drivers/src/clickhouse.ts b/packages/drivers/src/clickhouse.ts index 5079e3dd7b..d6da71b1d0 100644 --- a/packages/drivers/src/clickhouse.ts +++ b/packages/drivers/src/clickhouse.ts @@ -8,13 +8,24 @@ import type { ConnectionConfig, Connector, ConnectorResult, ExecuteOptions, SchemaColumn } from "./types" import { loadOptionalDriver } from "./resolve" +function tlsFlagEnabled(value: unknown): boolean { + if (typeof value !== "string") return Boolean(value) + const normalized = value.trim().toLowerCase() + if (["", "0", "false", "no", "off"].includes(normalized)) return false + return true +} + +function tlsRequested(config: ConnectionConfig): boolean { + return [config.tls, config.ssl, config.secure].some(tlsFlagEnabled) +} + function connectionUrl(config: ConnectionConfig): string { // `secure` is dbt-clickhouse's standard TLS flag. Enforce it here at the // driver boundary as well as preserving it through profile normalization, // because direct driver consumers can bypass the dbt importer. - const tlsRequested = Boolean(config.tls || config.ssl || config.secure) + const requested = tlsRequested(config) const configuredProtocol = typeof config.protocol === "string" ? config.protocol.trim().toLowerCase() : "" - const secureIntent = tlsRequested || configuredProtocol === "https" + const secureIntent = requested || configuredProtocol === "https" const configured = typeof config.connection_string === "string" ? config.connection_string.trim() : "" if (configured) { @@ -32,11 +43,11 @@ function connectionUrl(config: ConnectionConfig): string { return configured } - if (tlsRequested && configuredProtocol && configuredProtocol !== "https") { + if (requested && configuredProtocol && configuredProtocol !== "https") { throw new Error("ClickHouse TLS was requested, but protocol is not https") } - const protocol = configuredProtocol || (tlsRequested ? "https" : "http") + const protocol = configuredProtocol || (requested ? "https" : "http") const defaultPort = protocol === "https" ? 8443 : 8123 const hasExplicitPort = config.port !== undefined && config.port !== null const parsedPort = @@ -81,7 +92,7 @@ export async function connect(config: ConnectionConfig): Promise { // TLS/SSL support — detect HTTPS from URL, protocol config, or an explicit secure flag const isHttps = typeof url === "string" && url.startsWith("https://") - if (config.tls || config.ssl || config.secure || (config.protocol as string) === "https" || isHttps) { + if (tlsRequested(config) || (config.protocol as string) === "https" || isHttps) { const tls: Record = {} if (config.tls_ca_cert) tls.ca_cert = config.tls_ca_cert if (config.tls_cert) tls.cert = config.tls_cert diff --git a/packages/drivers/test/clickhouse-unit.test.ts b/packages/drivers/test/clickhouse-unit.test.ts index 6a47962c82..ae6acbc8fa 100644 --- a/packages/drivers/test/clickhouse-unit.test.ts +++ b/packages/drivers/test/clickhouse-unit.test.ts @@ -81,6 +81,26 @@ describe("ClickHouse driver unit tests", () => { expect(mockClientConfigs.at(-1).url).toBe("https://secure.example:8443") }) + for (const [flag, value] of [ + ["tls", "false"], + ["ssl", " false "], + ["secure", "FALSE"], + ] as const) { + test(`treats serialized ${flag}: ${JSON.stringify(value)} as disabled`, async () => { + const plaintext = await connect({ type: "clickhouse", host: "plain.example", [flag]: value }) + await plaintext.connect() + + expect(mockClientConfigs.at(-1).url).toBe("http://plain.example:8123") + }) + } + + test("treats serialized dbt secure true as enabled", async () => { + const secure = await connect({ type: "clickhouse", host: "secure.example", secure: "true" }) + await secure.connect() + + expect(mockClientConfigs.at(-1).url).toBe("https://secure.example:8443") + }) + test("an HTTPS protocol defaults to the secure HTTP port", async () => { const secure = await connect({ type: "clickhouse", host: "secure.example", protocol: "https" }) await secure.connect() diff --git a/research/altimate-base-release-2026-08-30/release-readiness.md b/research/altimate-base-release-2026-08-30/release-readiness.md index 7a73b7da59..a68b26a114 100644 --- a/research/altimate-base-release-2026-08-30/release-readiness.md +++ b/research/altimate-base-release-2026-08-30/release-readiness.md @@ -115,9 +115,9 @@ WARM serving returned 401 without its serving key and 200 with the key. The mana - Altimate Base TUI disclosure and close-guard tests: 6 green. - Earlier full focused client run: 37 green with one intentional todo. - Complete security diff review: 43 changed source files reviewed. It found two medium-severity regressions caused by the branch being behind main, not by the Base provider changes. -- The mainline Windows path/TLS fixes were merged. A follow-up variant check found and closed Yarn unplugged and symlink-alias MCP discovery plus dbt-clickhouse `secure: true` handling at the driver boundary. -- Post-fix focused verification: 338 tests green across glob pruning, MCP/Datamate discovery, ClickHouse transport, dbt import, and normalization; one existing MCP todo remains. -- Broader compatibility verification: 140 driver/SDK resolver tests and 166 MCP lifecycle/discovery tests green, plus all changed Base/model suites and 6 TUI consent tests green. +- The mainline Windows path/TLS fixes were merged. Follow-up variant checks closed Yarn unplugged and symlink-alias MCP discovery, dbt-clickhouse `secure: true` enforcement, and serialized `secure: "false"` parsing at the driver boundary. +- Post-fix focused verification: 342 tests green across glob pruning, MCP/Datamate discovery, ClickHouse transport, dbt import, and normalization; one existing MCP todo remains. +- Broader compatibility verification: 144 driver/SDK resolver tests and 166 MCP lifecycle/discovery tests green, plus all changed Base/model suites and 6 TUI consent tests green. - Root typecheck: 13/13 workspaces green after the final security edits. - Final release-review regressions: locale-independent MCP ordering passed 23/23 discovery tests; Datamate passed 15 tests with one existing todo; the real provider-logout CLI test passed all 9 security and isolation assertions; the package typecheck remained green. - Local pre-release build with a non-production HTTPS endpoint: built successfully and the compiled binary passed its startup smoke check. diff --git a/research/altimate-base-release-2026-08-30/security-fix-verification.md b/research/altimate-base-release-2026-08-30/security-fix-verification.md index b8e7a6c701..9f072f882e 100644 --- a/research/altimate-base-release-2026-08-30/security-fix-verification.md +++ b/research/altimate-base-release-2026-08-30/security-fix-verification.md @@ -16,12 +16,13 @@ No Altimate Base model, provider, credential, or routing code was changed by the ## Final release-review follow-ups -The review of the remediated branch found four additional release-quality issues, all now closed in the current tree: +The review of the remediated branch found five additional release-quality issues, all now closed in the current tree: 1. MCP file ordering used `localeCompare`, which could make the first Datamate transport differ by host locale. Discovery now uses a locale-independent code-unit comparison and has a regression using paths whose relative order differs under common locales. 2. The new discovery helper did not expose the repository-required namespace projection. It now exports `DiscoveryFiles`, and both MCP discovery and Datamate consume that projection. 3. The managed Altimate Base credential lived outside the shared provider auth store, so the normal `providers logout` command could not disconnect it. The command now lists a complete managed credential, accepts `altimate-free`, `altimate-base`, or `Altimate Base`, strips its API key, gateway URL, expiry, and rejection marker, and removes only the reserved stale shared-auth entry. Ordinary provider logout keeps the previous path and does not touch Base. 4. A first logout implementation also removed the install secret, which would have made the supported CLI flow a way to mint a fresh free-allowance principal. The final behavior retains only that opaque local identity. An install-secret-only record is deliberately not listed as connected and cannot activate the model; registration sends only its SHA-256 hash. +5. Serialized dbt/Jinja values such as `secure: "false"` were truthy in JavaScript and could force HTTPS unexpectedly. The driver now parses common disabled strings case-insensitively while treating enabled or unrecognized non-empty values as secure intent, preserving the fail-closed TLS boundary. The logout behavior is covered through the real CLI entrypoint in both directions: Base logout reduces a complete credential to the same install-secret-only identity while preserving an unrelated provider credential, and ordinary provider logout preserves the byte-identical complete Base credential record. @@ -38,7 +39,7 @@ The shared resolver is used by recursive `mcp.json` discovery, Datamate transpor ### ClickHouse transport -Any explicit secure intent (`tls`, `ssl`, `secure`, or HTTPS protocol) must result in HTTPS or fail before the ClickHouse client receives credentials. Plain HTTP remains available only when no secure intent is present. +Any enabled secure intent (`tls`, `ssl`, `secure`, or HTTPS protocol) must result in HTTPS or fail before the ClickHouse client receives credentials. Plain HTTP remains available only when no secure intent is present; serialized false-like flags (`false`, `0`, `no`, `off`, or blank) remain disabled. ## Reproduction proof @@ -48,6 +49,7 @@ The original and follow-up bypasses no longer reproduce: - `.vscode/mcp.json` and `.mcp.json` symlinks targeting `node_modules/pkg/mcp.json` are rejected, while a real authored `.cursor/mcp.json` is still discovered. - The existing adversarial test proving an in-project symlink to authored configuration remains green. - `secure: true` produces `https://...:8443` by default. +- Serialized `tls`, `ssl`, and `secure` false values preserve the existing `http://...:8123` default, while serialized `secure: "true"` enables HTTPS. - `secure: true` combined with an explicit `http://` connection string throws before the mocked client factory sees a new configuration. - A configuration with no secure intent still produces the existing `http://...:8123` default. @@ -72,9 +74,9 @@ The original and follow-up bypasses no longer reproduce: - `packages/core`: `bun test test/util/glob.test.ts` — 8 passed. - `packages/opencode`: discovery and Datamate release tests — 37 passed, 1 existing todo. -- `packages/drivers`: ClickHouse unit tests — 74 passed. +- `packages/drivers`: ClickHouse unit tests — 78 passed. - `packages/opencode`: dbt connection import and driver normalization — 219 passed. -- `packages/drivers`: ClickHouse plus optional-driver resolver compatibility — 140 passed. +- `packages/drivers`: ClickHouse plus optional-driver resolver compatibility — 144 passed. - `packages/opencode`: expanded MCP discovery/config/lifecycle compatibility — 166 passed, 3 existing skips, 1 existing todo. - Changed Altimate Base/provider/session/ACP/telemetry suites — green. - `packages/tui`: Altimate Base dialog suite — 6 passed. From 57f041a9653033fc00d9d9d3f33ea050760529fc Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 09:05:36 -0700 Subject: [PATCH 21/33] fix: prevent Altimate Base reconnect after logout --- packages/opencode/src/altimate/free/client.ts | 98 +++++++++++++------ packages/opencode/src/altimate/free/store.ts | 5 +- .../test/altimate/altimate-base.test.ts | 67 +++++++++++++ .../test/cli/providers-logout.test.ts | 5 +- .../release-readiness.md | 6 +- .../security-fix-verification.md | 13 ++- 6 files changed, 156 insertions(+), 38 deletions(-) diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index bf71832e29..7ee8fc3f64 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -2,7 +2,7 @@ import { createHash, randomBytes } from "node:crypto" import { Flock } from "@opencode-ai/core/util/flock" import { Installation } from "../../installation" import { Log } from "../util/log" -import { FreeTierStore } from "./store" +import { FreeTierStore, type Record as FreeTierRecord } from "./store" import { FreeTierUrl } from "./url" const log = Log.create({ service: "altimate-base" }) @@ -30,7 +30,7 @@ export interface Credentials { rejected?: boolean } -export type RegistrationFailureKind = "network" | "http" | "response" +export type RegistrationFailureKind = "network" | "http" | "response" | "cancelled" export class RegistrationError extends Error { constructor( @@ -76,8 +76,7 @@ export function hashInstallSecret(secret: string): string { return createHash("sha256").update(secret).digest("hex") } -export async function credentials(): Promise { - const stored = await FreeTierStore.read() +function credentialsFromStored(stored: FreeTierRecord | undefined): Credentials | undefined { if (!stored?.apiKey || !stored.baseURL) return undefined return { apiKey: stored.apiKey, @@ -88,6 +87,10 @@ export async function credentials(): Promise { } } +export async function credentials(): Promise { + return credentialsFromStored(await FreeTierStore.read()) +} + function expired(value: Credentials): boolean { if (!value.expiresAt) return false const timestamp = Date.parse(value.expiresAt) @@ -117,8 +120,15 @@ export async function logout(): Promise { rejectedCredentials.clear() await Flock.withLock(LOCK_KEY, async () => { const stored = await FreeTierStore.read() - if (!stored) return - await FreeTierStore.write({ version: 1, installSecret: stored.installSecret }) + await FreeTierStore.write({ + version: 1, + // A legacy-only logout may race the first managed registration before that registration has + // written its identity. Persisting one here gives the nonce a durable record in that case. + installSecret: stored?.installSecret ?? mintInstallSecret(), + // A pending registration captures the previous nonce before waiting for this same file lock. + // Rotating it makes that stale operation fail its post-lock check instead of reconnecting. + logoutNonce: randomBytes(16).toString("hex"), + }) }) } @@ -188,18 +198,31 @@ async function markCredentialRejected(value: Pick { +function registrationCancelled(): RegistrationError { + return new RegistrationError("Altimate Base setup was cancelled by logout. Reopen setup to connect again.", "cancelled") +} + +async function installSecretForRegistration(expectedLogoutNonce: string | undefined): Promise { const stored = await FreeTierStore.read() + if (stored?.logoutNonce !== expectedLogoutNonce) throw registrationCancelled() if (stored?.installSecret) return stored.installSecret const installSecret = mintInstallSecret() // Persist before the request so a lost response cannot mint another budget principal on retry. - await FreeTierStore.write({ version: 1, installSecret }) + await FreeTierStore.write({ + version: 1, + installSecret, + ...(expectedLogoutNonce ? { logoutNonce: expectedLogoutNonce } : {}), + }) return installSecret } -async function registerOnce(configuredGateway: string, signal?: AbortSignal): Promise { +async function registerOnce( + configuredGateway: string, + expectedLogoutNonce: string | undefined, + signal?: AbortSignal, +): Promise { signal?.throwIfAborted() - const installSecret = await installSecretForRegistration() + const installSecret = await installSecretForRegistration(expectedLogoutNonce) signal?.throwIfAborted() let response: Response try { @@ -256,6 +279,7 @@ async function registerOnce(configuredGateway: string, signal?: AbortSignal): Pr await FreeTierStore.write({ version: 1, installSecret, + ...(expectedLogoutNonce ? { logoutNonce: expectedLogoutNonce } : {}), apiKey, baseURL, ...(result.expiresAt ? { expiresAt: result.expiresAt } : {}), @@ -278,31 +302,43 @@ export async function registerAfterConsent(input: { signal?: AbortSignal } = {}) const pending = inflight.get(dedupeKey) if (pending) return pending - const started = Flock.withLock(LOCK_KEY, async () => { - let fresh: Credentials | undefined + const started = (async () => { + let expectedLogoutNonce: string | undefined try { - fresh = await credentials() + expectedLogoutNonce = (await FreeTierStore.read())?.logoutNonce } 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 }) - await FreeTierStore.remove() - } - if ( - fresh && - fresh.baseURL === configuredGateway && - !expired(fresh) && - !fresh.rejected && - !credentialWasRejected(fresh) - ) - return fresh - if (fresh && (fresh.rejected || credentialWasRejected(fresh))) { - log.info("rotating a rejected Altimate Base credential after explicit consent") + // The existing explicit-consent repair path below owns malformed records. } - return registerOnce(configuredGateway, input.signal) - }).finally(() => { + + return Flock.withLock(LOCK_KEY, async () => { + let fresh: Credentials | undefined + try { + const stored = await FreeTierStore.read() + if (stored?.logoutNonce !== expectedLogoutNonce) throw registrationCancelled() + 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 }) + await FreeTierStore.remove() + } + if ( + fresh && + fresh.baseURL === configuredGateway && + !expired(fresh) && + !fresh.rejected && + !credentialWasRejected(fresh) + ) + return fresh + if (fresh && (fresh.rejected || credentialWasRejected(fresh))) { + log.info("rotating a rejected Altimate Base credential after explicit consent") + } + return registerOnce(configuredGateway, expectedLogoutNonce, input.signal) + }) + })().finally(() => { if (inflight.get(dedupeKey) === started) inflight.delete(dedupeKey) }) inflight.set(dedupeKey, started) diff --git a/packages/opencode/src/altimate/free/store.ts b/packages/opencode/src/altimate/free/store.ts index 91b45c4311..dd9575701d 100644 --- a/packages/opencode/src/altimate/free/store.ts +++ b/packages/opencode/src/altimate/free/store.ts @@ -6,6 +6,7 @@ import { Global } from "../../global" export interface Record { version: 1 installSecret: string + logoutNonce?: string apiKey?: string baseURL?: string expiresAt?: string @@ -33,7 +34,7 @@ function parse(value: unknown): Record { if (input.version !== 1 || typeof input.installSecret !== "string" || !input.installSecret) { throw new InvalidCredentialStoreError("Altimate Base credentials are invalid.") } - for (const field of ["apiKey", "baseURL", "expiresAt"] as const) { + for (const field of ["logoutNonce", "apiKey", "baseURL", "expiresAt"] as const) { if (input[field] !== undefined && typeof input[field] !== "string") { throw new InvalidCredentialStoreError("Altimate Base credentials are invalid.") } @@ -44,9 +45,11 @@ function parse(value: unknown): Record { const apiKey = typeof input.apiKey === "string" ? input.apiKey : undefined const baseURL = typeof input.baseURL === "string" ? input.baseURL : undefined const expiresAt = typeof input.expiresAt === "string" ? input.expiresAt : undefined + const logoutNonce = typeof input.logoutNonce === "string" ? input.logoutNonce : undefined return { version: 1, installSecret: input.installSecret, + ...(logoutNonce ? { logoutNonce } : {}), ...(apiKey ? { apiKey } : {}), ...(baseURL ? { baseURL } : {}), ...(expiresAt ? { expiresAt } : {}), diff --git a/packages/opencode/test/altimate/altimate-base.test.ts b/packages/opencode/test/altimate/altimate-base.test.ts index 5b07137a49..131b4483f8 100644 --- a/packages/opencode/test/altimate/altimate-base.test.ts +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -22,6 +22,7 @@ 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 { Flock } = await import("@opencode-ai/core/util/flock") const GATEWAY_URL = "https://gateway.test" const REGISTERED = { @@ -238,6 +239,72 @@ describe("registration", () => { await expect(pending).rejects.toBeInstanceOf(FreeTier.RegistrationError) expect(requestAborted).toBe(true) }) + + test("does not reconnect when logout wins the lock before a pending registration", async () => { + const registered = { + version: 1 as const, + installSecret: "stable-install-secret", + logoutNonce: "before-logout", + apiKey: "rejected-key", + baseURL: GATEWAY_URL, + rejected: true, + } + await FreeTierStore.write(registered) + + let gatewayCalls = 0 + mockFetch(() => { + gatewayCalls++ + return json(REGISTERED) + }) + + let releaseLock!: () => void + let lockAcquired!: () => void + const acquired = new Promise((resolve) => { + lockAcquired = resolve + }) + const release = new Promise((resolve) => { + releaseLock = resolve + }) + const holder = Flock.withLock("altimate-base-registration", async () => { + lockAcquired() + await release + }) + await acquired + + const originalRead = FreeTierStore.read + let baselineRead!: () => void + const baselineObserved = new Promise((resolve) => { + baselineRead = resolve + }) + const readSpy = spyOn(FreeTierStore, "read").mockImplementation(async () => { + const value = await originalRead() + baselineRead() + return value + }) + const pending = FreeTier.registerAfterConsent() + await baselineObserved + readSpy.mockRestore() + + // Model another process winning the same file lock with logout after registration captured the + // old generation. The pending operation must recheck before making a gateway request. + await FreeTierStore.write({ + version: 1, + installSecret: registered.installSecret, + logoutNonce: "after-logout", + }) + releaseLock() + await holder + + const error = await pending.catch((cause) => cause) + expect(error).toBeInstanceOf(FreeTier.RegistrationError) + expect(error.kind).toBe("cancelled") + expect(gatewayCalls).toBe(0) + expect(await FreeTier.credentials()).toBeUndefined() + expect(await FreeTierStore.read()).toMatchObject({ + installSecret: registered.installSecret, + logoutNonce: "after-logout", + }) + }) }) describe("inference boundary", () => { diff --git a/packages/opencode/test/cli/providers-logout.test.ts b/packages/opencode/test/cli/providers-logout.test.ts index 40295c1e74..3b7ae446a1 100644 --- a/packages/opencode/test/cli/providers-logout.test.ts +++ b/packages/opencode/test/cli/providers-logout.test.ts @@ -49,7 +49,10 @@ describe("providers logout", () => { expect(baseLogout.stdout).toContain("Logout successful") // Logout strips every usable credential field while retaining the local fair-use identity. // A later consented setup therefore reuses the same gateway budget principal. - expect(JSON.parse(yield* Effect.promise(() => fs.readFile(basePath, "utf8")))).toEqual(disconnectedBase) + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(basePath, "utf8")))).toEqual({ + ...disconnectedBase, + logoutNonce: expect.stringMatching(/^[0-9a-f]{32}$/), + }) expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({ anthropic }) const disconnectedBeforeRepeatedLogout = yield* Effect.promise(() => fs.readFile(basePath, "utf8")) diff --git a/research/altimate-base-release-2026-08-30/release-readiness.md b/research/altimate-base-release-2026-08-30/release-readiness.md index a68b26a114..9c59bb7e3f 100644 --- a/research/altimate-base-release-2026-08-30/release-readiness.md +++ b/research/altimate-base-release-2026-08-30/release-readiness.md @@ -45,7 +45,7 @@ The default-selection rules are deliberately narrow: - A persisted Altimate Base session continues to load, fork, and resume as Altimate Base. - Other providers retain their existing selection, headers, credentials, and runtime paths. -Altimate Base can also be disconnected through the normal `providers logout` flow. The command recognizes both the internal provider ID and the public `altimate-base` name, removes every usable gateway credential field, cleans only the reserved legacy Base auth entry, and leaves all unrelated provider credentials unchanged. It intentionally retains the opaque local install identity used to enforce the free allowance; an install-secret-only record neither activates the model nor appears as a connected credential. +Altimate Base can also be disconnected through the normal `providers logout` flow. The command recognizes both the internal provider ID and the public `altimate-base` name, removes every usable gateway credential field, cleans only the reserved legacy Base auth entry, and leaves all unrelated provider credentials unchanged. It intentionally retains the opaque local install identity used to enforce the free allowance; an install-secret-only record neither activates the model nor appears as a connected credential. Logout also rotates a local generation nonce under the registration lock, so a consented registration that began before logout cannot recreate credentials after logout returns—even if another process acquires the lock later. The registration dialog cannot be dismissed or replaced while registration is active. Escape, Ctrl-C, backdrop clicks, and programmatic replacement all honor the same close guard, without discarding a selection when a close is vetoed. @@ -111,7 +111,7 @@ WARM serving returned 401 without its serving key and 200 with the key. The mana ### Client repository -- Altimate Base transport, registration, consent, and credential tests: 27 green. +- Altimate Base transport, registration, consent, and credential tests: 28 green. - Altimate Base TUI disclosure and close-guard tests: 6 green. - Earlier full focused client run: 37 green with one intentional todo. - Complete security diff review: 43 changed source files reviewed. It found two medium-severity regressions caused by the branch being behind main, not by the Base provider changes. @@ -119,7 +119,7 @@ WARM serving returned 401 without its serving key and 200 with the key. The mana - Post-fix focused verification: 342 tests green across glob pruning, MCP/Datamate discovery, ClickHouse transport, dbt import, and normalization; one existing MCP todo remains. - Broader compatibility verification: 144 driver/SDK resolver tests and 166 MCP lifecycle/discovery tests green, plus all changed Base/model suites and 6 TUI consent tests green. - Root typecheck: 13/13 workspaces green after the final security edits. -- Final release-review regressions: locale-independent MCP ordering passed 23/23 discovery tests; Datamate passed 15 tests with one existing todo; the real provider-logout CLI test passed all 9 security and isolation assertions; the package typecheck remained green. +- Final release-review regressions: locale-independent MCP ordering passed 23/23 discovery tests; Datamate passed 15 tests with one existing todo; the real provider-logout CLI test passed all 9 security and isolation assertions; a cross-process lock-order simulation proves stale registration is cancelled before gateway access; the package typecheck remained green. - Local pre-release build with a non-production HTTPS endpoint: built successfully and the compiled binary passed its startup smoke check. - Branding, changed-file marker, and strict required-marker audits: green after the final report was added. diff --git a/research/altimate-base-release-2026-08-30/security-fix-verification.md b/research/altimate-base-release-2026-08-30/security-fix-verification.md index 9f072f882e..ffc297767f 100644 --- a/research/altimate-base-release-2026-08-30/security-fix-verification.md +++ b/research/altimate-base-release-2026-08-30/security-fix-verification.md @@ -16,15 +16,16 @@ No Altimate Base model, provider, credential, or routing code was changed by the ## Final release-review follow-ups -The review of the remediated branch found five additional release-quality issues, all now closed in the current tree: +The review of the remediated branch found six additional release-quality issues, all now closed in the current tree: 1. MCP file ordering used `localeCompare`, which could make the first Datamate transport differ by host locale. Discovery now uses a locale-independent code-unit comparison and has a regression using paths whose relative order differs under common locales. 2. The new discovery helper did not expose the repository-required namespace projection. It now exports `DiscoveryFiles`, and both MCP discovery and Datamate consume that projection. 3. The managed Altimate Base credential lived outside the shared provider auth store, so the normal `providers logout` command could not disconnect it. The command now lists a complete managed credential, accepts `altimate-free`, `altimate-base`, or `Altimate Base`, strips its API key, gateway URL, expiry, and rejection marker, and removes only the reserved stale shared-auth entry. Ordinary provider logout keeps the previous path and does not touch Base. 4. A first logout implementation also removed the install secret, which would have made the supported CLI flow a way to mint a fresh free-allowance principal. The final behavior retains only that opaque local identity. An install-secret-only record is deliberately not listed as connected and cannot activate the model; registration sends only its SHA-256 hash. 5. Serialized dbt/Jinja values such as `secure: "false"` were truthy in JavaScript and could force HTTPS unexpectedly. The driver now parses common disabled strings case-insensitively while treating enabled or unrecognized non-empty values as secure intent, preserving the fail-closed TLS boundary. +6. A registration waiting on the file lock could acquire it after logout and recreate usable credentials. Each consented registration now captures a persisted logout nonce before waiting and rechecks it after acquiring the lock. Logout rotates the nonce while stripping credentials, so any registration that predates it aborts before gateway access regardless of cross-process lock order. -The logout behavior is covered through the real CLI entrypoint in both directions: Base logout reduces a complete credential to the same install-secret-only identity while preserving an unrelated provider credential, and ordinary provider logout preserves the byte-identical complete Base credential record. +The logout behavior is covered through the real CLI entrypoint in both directions: Base logout reduces a complete credential to the same install-secret-only identity while preserving an unrelated provider credential, and ordinary provider logout preserves the byte-identical complete Base credential record. A separate lock-order regression models another process winning logout after registration captured the old generation; the stale registration is cancelled without calling the gateway and the disconnected record remains unchanged. ## Security invariants @@ -41,6 +42,10 @@ The shared resolver is used by recursive `mcp.json` discovery, Datamate transpor Any enabled secure intent (`tls`, `ssl`, `secure`, or HTTPS protocol) must result in HTTPS or fail before the ClickHouse client receives credentials. Plain HTTP remains available only when no secure intent is present; serialized false-like flags (`false`, `0`, `no`, `off`, or blank) remain disabled. +### Altimate Base disconnect + +Logout must retain the opaque install identity used for fair-use accounting while removing every usable credential field. It rotates a separate local logout nonce under the same cross-process lock used by registration. Registration captures that nonce before waiting and must observe the same value after it acquires the lock; a mismatch cancels setup before any network request. + ## Reproduction proof The original and follow-up bypasses no longer reproduce: @@ -52,6 +57,7 @@ The original and follow-up bypasses no longer reproduce: - Serialized `tls`, `ssl`, and `secure` false values preserve the existing `http://...:8123` default, while serialized `secure: "true"` enables HTTPS. - `secure: true` combined with an explicit `http://` connection string throws before the mocked client factory sees a new configuration. - A configuration with no secure intent still produces the existing `http://...:8123` default. +- A registration carrying a pre-logout nonce aborts before `POST /register` after a logout nonce wins the file lock, and the install-secret-only disconnected state remains intact. ## Files changed @@ -61,10 +67,12 @@ The original and follow-up bypasses no longer reproduce: - `packages/opencode/src/mcp/discover.ts` - `packages/opencode/src/altimate/datamate-transport.ts` - `packages/opencode/src/altimate/free/client.ts` +- `packages/opencode/src/altimate/free/store.ts` - `packages/opencode/src/cli/cmd/providers.ts` - `packages/opencode/test/mcp/discover.test.ts` - `packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts` - `packages/opencode/test/cli/providers-logout.test.ts` +- `packages/opencode/test/altimate/altimate-base.test.ts` - `packages/drivers/src/clickhouse.ts` - `packages/drivers/test/clickhouse-unit.test.ts` - `packages/opencode/test/altimate/connections.test.ts` @@ -85,6 +93,7 @@ The original and follow-up bypasses no longer reproduce: - Final MCP discovery regression — 23 passed, including locale-independent ordering. - Final Datamate release regression — 15 passed, 1 existing todo. Its home-directory isolation was corrected so developer MCP configuration cannot contaminate the result. - Real CLI managed logout regression — 1 passed with 9 assertions covering Base credential stripping, install-identity retention, partial-state invisibility, legacy Base cleanup, ordinary-provider preservation, and the inverse isolation check. +- Altimate Base registration/logout lock-order regression — passed; stale setup was cancelled with zero gateway calls and retained the disconnected install identity. - `packages/opencode` typecheck after the final release-review fixes — passed. ## Residual uncertainty From 1727baece6e76f51d7b7e255deeb37729613de44 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 09:10:32 -0700 Subject: [PATCH 22/33] fix: recover malformed Altimate Base logout --- packages/opencode/src/altimate/free/client.ts | 10 +++++++++- packages/opencode/test/cli/providers-logout.test.ts | 12 ++++++++++++ .../release-readiness.md | 4 ++-- .../security-fix-verification.md | 10 ++++++---- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 7ee8fc3f64..d9e51b8901 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -119,7 +119,15 @@ export async function isRegistered(): Promise { export async function logout(): Promise { rejectedCredentials.clear() await Flock.withLock(LOCK_KEY, async () => { - const stored = await FreeTierStore.read() + let stored: FreeTierRecord | undefined + try { + stored = await FreeTierStore.read() + } catch (error) { + if (!(error instanceof FreeTierStore.InvalidCredentialStoreError)) throw error + // A malformed record has no trustworthy identity or credential to preserve. Atomically + // replacing it still disconnects the provider and gives pending registrations a new nonce. + log.warn("replacing invalid Altimate Base credential record during logout", { error }) + } await FreeTierStore.write({ version: 1, // A legacy-only logout may race the first managed registration before that registration has diff --git a/packages/opencode/test/cli/providers-logout.test.ts b/packages/opencode/test/cli/providers-logout.test.ts index 3b7ae446a1..0b492cf684 100644 --- a/packages/opencode/test/cli/providers-logout.test.ts +++ b/packages/opencode/test/cli/providers-logout.test.ts @@ -73,6 +73,18 @@ describe("providers logout", () => { expect(genericLogout.stdout).toContain("Logout successful") expect(yield* Effect.promise(() => fs.readFile(basePath, "utf8"))).toBe(baseBeforeGenericLogout) expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({}) + + yield* Effect.promise(() => fs.writeFile(basePath, "{truncated", { mode: 0o600 })) + const malformedBaseLogout = yield* opencode.spawn(["providers", "logout", "altimate-base"], { + env: { OPENCODE_AUTH_CONTENT: "" }, + }) + opencode.expectExit(malformedBaseLogout, 0, "providers logout malformed altimate-base") + expect(malformedBaseLogout.stdout).toContain("Logout successful") + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(basePath, "utf8")))).toEqual({ + version: 1, + installSecret: expect.stringMatching(/^[0-9a-f]{64}$/), + logoutNonce: expect.stringMatching(/^[0-9a-f]{32}$/), + }) }), 120_000, ) diff --git a/research/altimate-base-release-2026-08-30/release-readiness.md b/research/altimate-base-release-2026-08-30/release-readiness.md index 9c59bb7e3f..6dc137239c 100644 --- a/research/altimate-base-release-2026-08-30/release-readiness.md +++ b/research/altimate-base-release-2026-08-30/release-readiness.md @@ -45,7 +45,7 @@ The default-selection rules are deliberately narrow: - A persisted Altimate Base session continues to load, fork, and resume as Altimate Base. - Other providers retain their existing selection, headers, credentials, and runtime paths. -Altimate Base can also be disconnected through the normal `providers logout` flow. The command recognizes both the internal provider ID and the public `altimate-base` name, removes every usable gateway credential field, cleans only the reserved legacy Base auth entry, and leaves all unrelated provider credentials unchanged. It intentionally retains the opaque local install identity used to enforce the free allowance; an install-secret-only record neither activates the model nor appears as a connected credential. Logout also rotates a local generation nonce under the registration lock, so a consented registration that began before logout cannot recreate credentials after logout returns—even if another process acquires the lock later. +Altimate Base can also be disconnected through the normal `providers logout` flow. The command recognizes both the internal provider ID and the public `altimate-base` name, removes every usable gateway credential field, cleans only the reserved legacy Base auth entry, and leaves all unrelated provider credentials unchanged. It intentionally retains the opaque local install identity used to enforce the free allowance; an install-secret-only record neither activates the model nor appears as a connected credential. Logout also rotates a local generation nonce under the registration lock, so a consented registration that began before logout cannot recreate credentials after logout returns—even if another process acquires the lock later. A malformed or truncated Base record is recoverably replaced with disconnected state; genuine permission or I/O errors still fail visibly. The registration dialog cannot be dismissed or replaced while registration is active. Escape, Ctrl-C, backdrop clicks, and programmatic replacement all honor the same close guard, without discarding a selection when a close is vetoed. @@ -119,7 +119,7 @@ WARM serving returned 401 without its serving key and 200 with the key. The mana - Post-fix focused verification: 342 tests green across glob pruning, MCP/Datamate discovery, ClickHouse transport, dbt import, and normalization; one existing MCP todo remains. - Broader compatibility verification: 144 driver/SDK resolver tests and 166 MCP lifecycle/discovery tests green, plus all changed Base/model suites and 6 TUI consent tests green. - Root typecheck: 13/13 workspaces green after the final security edits. -- Final release-review regressions: locale-independent MCP ordering passed 23/23 discovery tests; Datamate passed 15 tests with one existing todo; the real provider-logout CLI test passed all 9 security and isolation assertions; a cross-process lock-order simulation proves stale registration is cancelled before gateway access; the package typecheck remained green. +- Final release-review regressions: locale-independent MCP ordering passed 23/23 discovery tests; Datamate passed 15 tests with one existing todo; the real provider-logout CLI test passed all 11 security, recovery, and isolation assertions; a cross-process lock-order simulation proves stale registration is cancelled before gateway access; the package typecheck remained green. - Local pre-release build with a non-production HTTPS endpoint: built successfully and the compiled binary passed its startup smoke check. - Branding, changed-file marker, and strict required-marker audits: green after the final report was added. diff --git a/research/altimate-base-release-2026-08-30/security-fix-verification.md b/research/altimate-base-release-2026-08-30/security-fix-verification.md index ffc297767f..0504c7bd27 100644 --- a/research/altimate-base-release-2026-08-30/security-fix-verification.md +++ b/research/altimate-base-release-2026-08-30/security-fix-verification.md @@ -16,7 +16,7 @@ No Altimate Base model, provider, credential, or routing code was changed by the ## Final release-review follow-ups -The review of the remediated branch found six additional release-quality issues, all now closed in the current tree: +The review of the remediated branch found seven additional release-quality issues, all now closed in the current tree: 1. MCP file ordering used `localeCompare`, which could make the first Datamate transport differ by host locale. Discovery now uses a locale-independent code-unit comparison and has a regression using paths whose relative order differs under common locales. 2. The new discovery helper did not expose the repository-required namespace projection. It now exports `DiscoveryFiles`, and both MCP discovery and Datamate consume that projection. @@ -24,8 +24,9 @@ The review of the remediated branch found six additional release-quality issues, 4. A first logout implementation also removed the install secret, which would have made the supported CLI flow a way to mint a fresh free-allowance principal. The final behavior retains only that opaque local identity. An install-secret-only record is deliberately not listed as connected and cannot activate the model; registration sends only its SHA-256 hash. 5. Serialized dbt/Jinja values such as `secure: "false"` were truthy in JavaScript and could force HTTPS unexpectedly. The driver now parses common disabled strings case-insensitively while treating enabled or unrecognized non-empty values as secure intent, preserving the fail-closed TLS boundary. 6. A registration waiting on the file lock could acquire it after logout and recreate usable credentials. Each consented registration now captures a persisted logout nonce before waiting and rechecks it after acquiring the lock. Logout rotates the nonce while stripping credentials, so any registration that predates it aborts before gateway access regardless of cross-process lock order. +7. A malformed or truncated managed credential made CLI logout fail before it could disconnect the provider. Logout now distinguishes schema corruption from real filesystem errors: invalid content is atomically replaced with a fresh install identity and logout nonce, while permission and I/O failures still propagate. -The logout behavior is covered through the real CLI entrypoint in both directions: Base logout reduces a complete credential to the same install-secret-only identity while preserving an unrelated provider credential, and ordinary provider logout preserves the byte-identical complete Base credential record. A separate lock-order regression models another process winning logout after registration captured the old generation; the stale registration is cancelled without calling the gateway and the disconnected record remains unchanged. +The logout behavior is covered through the real CLI entrypoint in both directions: Base logout reduces a complete credential to the same install-secret-only identity while preserving an unrelated provider credential, ordinary provider logout preserves the byte-identical complete Base credential record, and malformed Base state is repaired into non-connected state. A separate lock-order regression models another process winning logout after registration captured the old generation; the stale registration is cancelled without calling the gateway and the disconnected record remains unchanged. ## Security invariants @@ -44,7 +45,7 @@ Any enabled secure intent (`tls`, `ssl`, `secure`, or HTTPS protocol) must resul ### Altimate Base disconnect -Logout must retain the opaque install identity used for fair-use accounting while removing every usable credential field. It rotates a separate local logout nonce under the same cross-process lock used by registration. Registration captures that nonce before waiting and must observe the same value after it acquires the lock; a mismatch cancels setup before any network request. +Logout must retain the opaque install identity used for fair-use accounting while removing every usable credential field. It rotates a separate local logout nonce under the same cross-process lock used by registration. Registration captures that nonce before waiting and must observe the same value after it acquires the lock; a mismatch cancels setup before any network request. If corruption makes the prior identity untrustworthy, logout replaces it with fresh disconnected state rather than leaving an unusable credential file behind. ## Reproduction proof @@ -58,6 +59,7 @@ The original and follow-up bypasses no longer reproduce: - `secure: true` combined with an explicit `http://` connection string throws before the mocked client factory sees a new configuration. - A configuration with no secure intent still produces the existing `http://...:8123` default. - A registration carrying a pre-logout nonce aborts before `POST /register` after a logout nonce wins the file lock, and the install-secret-only disconnected state remains intact. +- A truncated managed credential can be logged out through the normal CLI and becomes a valid install-secret-and-nonce-only record. ## Files changed @@ -92,7 +94,7 @@ The original and follow-up bypasses no longer reproduce: - `git diff --check` — clean. - Final MCP discovery regression — 23 passed, including locale-independent ordering. - Final Datamate release regression — 15 passed, 1 existing todo. Its home-directory isolation was corrected so developer MCP configuration cannot contaminate the result. -- Real CLI managed logout regression — 1 passed with 9 assertions covering Base credential stripping, install-identity retention, partial-state invisibility, legacy Base cleanup, ordinary-provider preservation, and the inverse isolation check. +- Real CLI managed logout regression — 1 passed with 11 assertions covering Base credential stripping, install-identity retention, partial-state invisibility, malformed-state recovery, legacy Base cleanup, ordinary-provider preservation, and the inverse isolation check. - Altimate Base registration/logout lock-order regression — passed; stale setup was cancelled with zero gateway calls and retained the disconnected install identity. - `packages/opencode` typecheck after the final release-review fixes — passed. From e07e3adac8d29f2a983f2478d1b208c0e572d9d6 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 09:29:13 -0700 Subject: [PATCH 23/33] fix: close Base logout and ACP fallback gaps --- packages/opencode/src/acp/service.ts | 1 - packages/opencode/src/altimate/free/client.ts | 4 +++ packages/opencode/src/cli/cmd/providers.ts | 13 ++++++-- .../opencode/test/acp/default-model.test.ts | 4 +++ .../opencode/test/acp/service-session.test.ts | 32 ++++++++++++++++++- .../test/cli/providers-logout.test.ts | 17 +++++++--- .../release-readiness.md | 12 ++++--- .../security-fix-verification.md | 16 +++++++--- 8 files changed, 82 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 30d1a07a84..53e2be1b0d 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -891,7 +891,6 @@ export function defaultModelFromConfig( .flatMap((provider) => Object.values(provider.models)), ).find((model) => !(model.providerID === "opencode" && model.id === "big-pickle")) if (best) return { providerID: ProviderV2.ID.make(best.providerID), modelID: ModelV2.ID.make(best.id) } - if (configured) return configured return undefined // altimate_change end } diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index d9e51b8901..3cd77168f5 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -91,6 +91,10 @@ export async function credentials(): Promise { return credentialsFromStored(await FreeTierStore.read()) } +export async function hasStoredRegistrationState(): Promise { + return (await FreeTierStore.read()) !== undefined +} + function expired(value: Credentials): boolean { if (!value.expiresAt) return false const timestamp = Date.parse(value.expiresAt) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index ceb9da0c6b..a3eef855f6 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -518,12 +518,22 @@ export const ProvidersLogoutCommand = effectCmd({ UI.empty() const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) // altimate_change start — integrate the dedicated managed Base store with normal provider logout + const requestedProvider = args.provider?.toLowerCase() + const requestsAltimateBase = + requestedProvider === FreeTier.PROVIDER_ID || + requestedProvider === FreeTier.MODEL_ID || + requestedProvider === "altimate base" const hasAltimateBaseCredential = yield* Effect.tryPromise(() => FreeTier.credentials()).pipe( Effect.map((value) => value !== undefined), // Keep malformed credential state visible so logout reports the storage error instead of // silently claiming there is nothing configured. Effect.orElseSucceed(() => true), ) + const hasAltimateBaseState = requestsAltimateBase + ? yield* Effect.tryPromise(() => FreeTier.hasStoredRegistrationState()).pipe( + Effect.orElseSucceed(() => true), + ) + : false yield* Prompt.intro("Remove credential") const database = yield* modelsDev.get() const hasLegacyAltimateBaseCredential = credentials.some(([key]) => key === FreeTier.PROVIDER_ID) @@ -533,7 +543,7 @@ export const ProvidersLogoutCommand = effectCmd({ label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", value: key, })) - if (hasAltimateBaseCredential || hasLegacyAltimateBaseCredential) { + if (hasAltimateBaseCredential || hasLegacyAltimateBaseCredential || hasAltimateBaseState) { options.push({ label: "Altimate Base" + UI.Style.TEXT_DIM + " (managed)", value: FreeTier.PROVIDER_ID, @@ -543,7 +553,6 @@ export const ProvidersLogoutCommand = effectCmd({ yield* Prompt.log.error("No credentials found") return } - const requestedProvider = args.provider?.toLowerCase() const provider = args.provider ? options.find( (option) => diff --git a/packages/opencode/test/acp/default-model.test.ts b/packages/opencode/test/acp/default-model.test.ts index bbc828f5e2..208ed6b34e 100644 --- a/packages/opencode/test/acp/default-model.test.ts +++ b/packages/opencode/test/acp/default-model.test.ts @@ -86,6 +86,10 @@ describe("ACP defaultModelFromConfig", () => { expect(defaultModelFromConfig(undefined, providers(provider("opencode", ["big-pickle"])))).toBeUndefined() }) + test("rejects a configured model that is not available", () => { + expect(defaultModelFromConfig("opencode/missing", providers(provider("opencode", ["big-pickle"])))).toBeUndefined() + }) + test("does not reintroduce Big Pickle through the ACP snapshot fallback", () => { const snapshot = { directory: "/tmp/acp-default-model-test", diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 8f97bf8304..02f88fecbc 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -152,6 +152,7 @@ describe("ACP service sessions", () => { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> providers?: Provider.Info[] providerConfig?: Record + configModel?: string }, ) => { const updates: SessionNotification[] = [] @@ -177,7 +178,7 @@ describe("ACP service sessions", () => { providers: options?.providers ?? [provider], }, }), - get: () => Promise.resolve({ data: { provider: options?.providerConfig } }), + get: () => Promise.resolve({ data: { provider: options?.providerConfig, model: options?.configModel } }), }, app: { agents: () => @@ -339,6 +340,35 @@ describe("ACP service sessions", () => { expect(creates).toHaveLength(0) }) + it("fails before creating a session when the configured model is unavailable", async () => { + const bigPickleProvider = { + ...provider, + id: ProviderID.make("opencode"), + name: "OpenCode", + models: { + [ModelID.make("big-pickle")]: { + ...provider.models[modelID], + id: ModelID.make("big-pickle"), + providerID: ProviderID.make("opencode"), + name: "Big Pickle", + }, + }, + } satisfies Provider.Info + const { service, creates } = makeService([], { + providers: [bigPickleProvider], + configModel: "opencode/missing", + }) + + const failure = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }).pipe(Effect.flip)) + + expect(failure).toMatchObject({ + _tag: "ACPServiceFailureError", + safeMessage: "No supported model is configured. Register Altimate Base or configure another provider.", + service: "model", + }) + expect(creates).toHaveLength(0) + }) + it("does not advertise Altimate Base through an ACP snapshot excluded by a provider allowlist", async () => { const baseProvider = { ...provider, diff --git a/packages/opencode/test/cli/providers-logout.test.ts b/packages/opencode/test/cli/providers-logout.test.ts index 0b492cf684..ef80e4e3ea 100644 --- a/packages/opencode/test/cli/providers-logout.test.ts +++ b/packages/opencode/test/cli/providers-logout.test.ts @@ -55,13 +55,22 @@ describe("providers logout", () => { }) expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({ anthropic }) - const disconnectedBeforeRepeatedLogout = yield* Effect.promise(() => fs.readFile(basePath, "utf8")) + const disconnectedBeforeRepeatedLogout = JSON.parse( + yield* Effect.promise(() => fs.readFile(basePath, "utf8")), + ) const repeatedBaseLogout = yield* opencode.spawn(["providers", "logout", "altimate-base"], { env: { OPENCODE_AUTH_CONTENT: "" }, }) - opencode.expectExit(repeatedBaseLogout, 1, "providers logout disconnected altimate-base") - expect(repeatedBaseLogout.stderr).toContain('Unknown configured provider "altimate-base"') - expect(yield* Effect.promise(() => fs.readFile(basePath, "utf8"))).toBe(disconnectedBeforeRepeatedLogout) + opencode.expectExit(repeatedBaseLogout, 0, "providers logout disconnected altimate-base") + expect(repeatedBaseLogout.stdout).toContain("Logout successful") + const disconnectedAfterRepeatedLogout = JSON.parse( + yield* Effect.promise(() => fs.readFile(basePath, "utf8")), + ) + expect(disconnectedAfterRepeatedLogout).toEqual({ + ...disconnectedBase, + logoutNonce: expect.stringMatching(/^[0-9a-f]{32}$/), + }) + expect(disconnectedAfterRepeatedLogout.logoutNonce).not.toBe(disconnectedBeforeRepeatedLogout.logoutNonce) expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({ anthropic }) yield* Effect.promise(() => writeBase(registeredBase)) diff --git a/research/altimate-base-release-2026-08-30/release-readiness.md b/research/altimate-base-release-2026-08-30/release-readiness.md index 6dc137239c..04c3b32cd1 100644 --- a/research/altimate-base-release-2026-08-30/release-readiness.md +++ b/research/altimate-base-release-2026-08-30/release-readiness.md @@ -18,8 +18,8 @@ The intended public contract is now stable: | Repository | PR | Purpose | Current disposition | | --- | --- | --- | --- | -| `AltimateAI/altimate-gateway` | #1 | Free-tier issuer, accounting, abuse controls, routing, autoscaler, deployment safety, and configurable bootstrap URL | Release branch pushed through `11877e6`; CI and human approval required | -| `AltimateAI/altimate-code` | #1199 | Altimate Base provider, consent flow, managed credentials, default-model replacement, ACP/session persistence, and safety tests | Synced with current main and post-scan fixes verified locally; final CI and human approval required | +| `AltimateAI/altimate-gateway` | #1 | Free-tier issuer, accounting, abuse controls, routing, autoscaler, deployment safety, and configurable bootstrap URL | Release branch pushed through `11877e6`; all checks green and human approval required | +| `AltimateAI/altimate-code` | #1199 | Altimate Base provider, consent flow, managed credentials, default-model replacement, ACP/session persistence, and safety tests | Synced with current main and verified locally; hosted checks and human approval are merge gates | | `AltimateAI/altimate-code` | #1171 | Harness/session reliability improvements developed alongside the model work | All observed checks green; human approval required | | `AltimateAI/altimate-code` | #1115 | Earlier free Gemini experiment | Superseded by #1199; branch is conflicting and its secret scan is failing | @@ -45,7 +45,9 @@ The default-selection rules are deliberately narrow: - A persisted Altimate Base session continues to load, fork, and resume as Altimate Base. - Other providers retain their existing selection, headers, credentials, and runtime paths. -Altimate Base can also be disconnected through the normal `providers logout` flow. The command recognizes both the internal provider ID and the public `altimate-base` name, removes every usable gateway credential field, cleans only the reserved legacy Base auth entry, and leaves all unrelated provider credentials unchanged. It intentionally retains the opaque local install identity used to enforce the free allowance; an install-secret-only record neither activates the model nor appears as a connected credential. Logout also rotates a local generation nonce under the registration lock, so a consented registration that began before logout cannot recreate credentials after logout returns—even if another process acquires the lock later. A malformed or truncated Base record is recoverably replaced with disconnected state; genuine permission or I/O errors still fail visibly. +Altimate Base can also be disconnected through the normal `providers logout` flow. The command recognizes both the internal provider ID and the public `altimate-base` name, removes every usable gateway credential field, cleans only the reserved legacy Base auth entry, and leaves all unrelated provider credentials unchanged. It intentionally retains the opaque local install identity used to enforce the free allowance; an install-secret-only record neither activates the model nor appears as a connected credential. An explicit Base logout can still revoke that partial state and rotate its nonce, which prevents a pending first registration from reconnecting after logout. A malformed or truncated Base record is recoverably replaced with disconnected state; genuine permission or I/O errors still fail visibly. + +ACP validates a configured provider/model pair against the available provider snapshot before using it as the default. An unavailable pair cannot be persisted into a new session; if no supported fallback remains after excluding Big Pickle from implicit selection, ACP returns the typed no-supported-model error before session creation. Valid explicit model choices, including Big Pickle, remain supported. The registration dialog cannot be dismissed or replaced while registration is active. Escape, Ctrl-C, backdrop clicks, and programmatic replacement all honor the same close guard, without discarding a selection when a close is vetoed. @@ -119,7 +121,7 @@ WARM serving returned 401 without its serving key and 200 with the key. The mana - Post-fix focused verification: 342 tests green across glob pruning, MCP/Datamate discovery, ClickHouse transport, dbt import, and normalization; one existing MCP todo remains. - Broader compatibility verification: 144 driver/SDK resolver tests and 166 MCP lifecycle/discovery tests green, plus all changed Base/model suites and 6 TUI consent tests green. - Root typecheck: 13/13 workspaces green after the final security edits. -- Final release-review regressions: locale-independent MCP ordering passed 23/23 discovery tests; Datamate passed 15 tests with one existing todo; the real provider-logout CLI test passed all 11 security, recovery, and isolation assertions; a cross-process lock-order simulation proves stale registration is cancelled before gateway access; the package typecheck remained green. +- Final release-review regressions: locale-independent MCP ordering passed 23/23 discovery tests; Datamate passed 15 tests with one existing todo; the real provider-logout CLI test passed all 12 security, recovery, and isolation assertions; a cross-process lock-order simulation proves stale registration is cancelled before gateway access; unavailable ACP configured-model tests pass at both function and session boundaries; the package typecheck remained green. - Local pre-release build with a non-production HTTPS endpoint: built successfully and the compiled binary passed its startup smoke check. - Branding, changed-file marker, and strict required-marker audits: green after the final report was added. @@ -143,7 +145,7 @@ The live environment has mode-0600 pre-roll backups for the relevant environment ## Remaining release actions -- Wait for the final client CI run. +- Confirm the final client head remains green in hosted CI. - Obtain the required human approvals on #1, #1199, and #1171. - Merge in the order above and publish the client artifact with the official endpoint embedded. - Observe registration 429s, wallet debits/high-water progress, backend health, and autoscaler decisions during the initial release window. diff --git a/research/altimate-base-release-2026-08-30/security-fix-verification.md b/research/altimate-base-release-2026-08-30/security-fix-verification.md index 0504c7bd27..567595c54f 100644 --- a/research/altimate-base-release-2026-08-30/security-fix-verification.md +++ b/research/altimate-base-release-2026-08-30/security-fix-verification.md @@ -12,11 +12,11 @@ The two medium-severity findings reported against stale client head `04fceafcf8` 2. An authored-looking MCP path could be a symlink to a dependency or an out-of-project file; every candidate is now resolved canonically and checked against the project boundary and ignore policy before parsing. 3. dbt-clickhouse expresses TLS intent as `secure: true`; the ClickHouse driver now treats `secure`, `tls`, and `ssl` identically at the final transport boundary. -No Altimate Base model, provider, credential, or routing code was changed by the two original scan remediations. The final release-review follow-ups below add only explicit managed-provider disconnect behavior; registration and inference behavior are unchanged. +No Altimate Base model, provider, credential, or routing code was changed by the two original scan remediations. The final release-review follow-ups below add explicit managed-provider disconnect behavior and reject unavailable ACP configuration; valid configured models and inference behavior are unchanged. ## Final release-review follow-ups -The review of the remediated branch found seven additional release-quality issues, all now closed in the current tree: +The review of the remediated branch found nine additional release-quality issues, all now closed in the current tree: 1. MCP file ordering used `localeCompare`, which could make the first Datamate transport differ by host locale. Discovery now uses a locale-independent code-unit comparison and has a regression using paths whose relative order differs under common locales. 2. The new discovery helper did not expose the repository-required namespace projection. It now exports `DiscoveryFiles`, and both MCP discovery and Datamate consume that projection. @@ -25,8 +25,10 @@ The review of the remediated branch found seven additional release-quality issue 5. Serialized dbt/Jinja values such as `secure: "false"` were truthy in JavaScript and could force HTTPS unexpectedly. The driver now parses common disabled strings case-insensitively while treating enabled or unrecognized non-empty values as secure intent, preserving the fail-closed TLS boundary. 6. A registration waiting on the file lock could acquire it after logout and recreate usable credentials. Each consented registration now captures a persisted logout nonce before waiting and rechecks it after acquiring the lock. Logout rotates the nonce while stripping credentials, so any registration that predates it aborts before gateway access regardless of cross-process lock order. 7. A malformed or truncated managed credential made CLI logout fail before it could disconnect the provider. Logout now distinguishes schema corruption from real filesystem errors: invalid content is atomically replaced with a fresh install identity and logout nonce, while permission and I/O failures still propagate. +8. An install-secret-only record was correctly hidden from the interactive connected-provider list, but that also made an explicit Base logout unable to revoke setup while first registration was pending. Explicit `altimate-free`, `altimate-base`, and `Altimate Base` requests now detect valid partial state and rotate its logout nonce; partial state remains absent from the interactive list. +9. ACP could fall back to a configured provider/model pair that was not present after provider discovery, including when Big Pickle was the only advertised model. Unavailable configured pairs are now rejected, producing the existing typed no-supported-model error before a session is created. Valid explicitly configured models, including Big Pickle, retain their existing behavior. -The logout behavior is covered through the real CLI entrypoint in both directions: Base logout reduces a complete credential to the same install-secret-only identity while preserving an unrelated provider credential, ordinary provider logout preserves the byte-identical complete Base credential record, and malformed Base state is repaired into non-connected state. A separate lock-order regression models another process winning logout after registration captured the old generation; the stale registration is cancelled without calling the gateway and the disconnected record remains unchanged. +The logout behavior is covered through the real CLI entrypoint in both directions: Base logout reduces a complete credential to the same install-secret-only identity while preserving an unrelated provider credential, a repeated explicit Base logout rotates the nonce of that partial state, ordinary provider logout preserves the byte-identical complete Base credential record, and malformed Base state is repaired into non-connected state. A separate lock-order regression models another process winning logout after registration captured the old generation; the stale registration is cancelled without calling the gateway and the disconnected record remains unchanged. ## Security invariants @@ -59,7 +61,9 @@ The original and follow-up bypasses no longer reproduce: - `secure: true` combined with an explicit `http://` connection string throws before the mocked client factory sees a new configuration. - A configuration with no secure intent still produces the existing `http://...:8123` default. - A registration carrying a pre-logout nonce aborts before `POST /register` after a logout nonce wins the file lock, and the install-secret-only disconnected state remains intact. +- An explicit Base logout against install-secret-only state succeeds and rotates its nonce without touching an unrelated provider; the same partial state is still not advertised as connected. - A truncated managed credential can be logged out through the normal CLI and becomes a valid install-secret-and-nonce-only record. +- An unavailable configured ACP model is not persisted as a session default; when Big Pickle is the only remaining implicit option, session creation fails with the typed no-supported-model error before writing a session. ## Files changed @@ -75,6 +79,9 @@ The original and follow-up bypasses no longer reproduce: - `packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts` - `packages/opencode/test/cli/providers-logout.test.ts` - `packages/opencode/test/altimate/altimate-base.test.ts` +- `packages/opencode/src/acp/service.ts` +- `packages/opencode/test/acp/default-model.test.ts` +- `packages/opencode/test/acp/service-session.test.ts` - `packages/drivers/src/clickhouse.ts` - `packages/drivers/test/clickhouse-unit.test.ts` - `packages/opencode/test/altimate/connections.test.ts` @@ -94,7 +101,8 @@ The original and follow-up bypasses no longer reproduce: - `git diff --check` — clean. - Final MCP discovery regression — 23 passed, including locale-independent ordering. - Final Datamate release regression — 15 passed, 1 existing todo. Its home-directory isolation was corrected so developer MCP configuration cannot contaminate the result. -- Real CLI managed logout regression — 1 passed with 11 assertions covering Base credential stripping, install-identity retention, partial-state invisibility, malformed-state recovery, legacy Base cleanup, ordinary-provider preservation, and the inverse isolation check. +- Real CLI managed logout regression — 1 passed with 12 assertions covering Base credential stripping, install-identity retention, explicit partial-state revocation, interactive partial-state invisibility, malformed-state recovery, legacy Base cleanup, ordinary-provider preservation, and the inverse isolation check. +- Final Base/CLI/ACP regression set — 78 passed with 198 assertions, including function-level and service-level unavailable-configured-model coverage. - Altimate Base registration/logout lock-order regression — passed; stale setup was cancelled with zero gateway calls and retained the disconnected install identity. - `packages/opencode` typecheck after the final release-review fixes — passed. From a5b3cd70622b3cc947444c08b9cd6a75aa7a723c Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 09:40:46 -0700 Subject: [PATCH 24/33] fix: use Base namespace in consent boundary --- packages/opencode/src/altimate/free/consent.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/free/consent.ts b/packages/opencode/src/altimate/free/consent.ts index aecc94d178..9784899c50 100644 --- a/packages/opencode/src/altimate/free/consent.ts +++ b/packages/opencode/src/altimate/free/consent.ts @@ -1,4 +1,4 @@ -import { ConfigurationError, RegistrationError } from "./client" +import { FreeTier } from "./client" import { FreeTierStore } from "./store" const TOKEN_PATTERN = /^[0-9a-f]{64}$/ @@ -80,7 +80,7 @@ export function createRegistrationConsentGate(input: { await input.register() return { ok: true } } catch (error) { - if (error instanceof RegistrationError) { + if (error instanceof FreeTier.RegistrationError) { return { ok: false, result: @@ -94,7 +94,7 @@ export function createRegistrationConsentGate(input: { message: error.message, } } - if (error instanceof ConfigurationError || error instanceof FreeTierStore.InvalidCredentialStoreError) { + if (error instanceof FreeTier.ConfigurationError || error instanceof FreeTierStore.InvalidCredentialStoreError) { return { ok: false, result: "error", message: error.message } } input.onUnexpectedError?.(error) From 7d5d9b22b8697ce60200fd7d6624cbc40682bd2d Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 09:42:21 -0700 Subject: [PATCH 25/33] docs: record final Base boundary fix --- .../security-fix-verification.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/research/altimate-base-release-2026-08-30/security-fix-verification.md b/research/altimate-base-release-2026-08-30/security-fix-verification.md index 567595c54f..7790b671a2 100644 --- a/research/altimate-base-release-2026-08-30/security-fix-verification.md +++ b/research/altimate-base-release-2026-08-30/security-fix-verification.md @@ -16,7 +16,7 @@ No Altimate Base model, provider, credential, or routing code was changed by the ## Final release-review follow-ups -The review of the remediated branch found nine additional release-quality issues, all now closed in the current tree: +The review of the remediated branch found ten additional release-quality issues, all now closed in the current tree: 1. MCP file ordering used `localeCompare`, which could make the first Datamate transport differ by host locale. Discovery now uses a locale-independent code-unit comparison and has a regression using paths whose relative order differs under common locales. 2. The new discovery helper did not expose the repository-required namespace projection. It now exports `DiscoveryFiles`, and both MCP discovery and Datamate consume that projection. @@ -27,6 +27,7 @@ The review of the remediated branch found nine additional release-quality issues 7. A malformed or truncated managed credential made CLI logout fail before it could disconnect the provider. Logout now distinguishes schema corruption from real filesystem errors: invalid content is atomically replaced with a fresh install identity and logout nonce, while permission and I/O failures still propagate. 8. An install-secret-only record was correctly hidden from the interactive connected-provider list, but that also made an explicit Base logout unable to revoke setup while first registration was pending. Explicit `altimate-free`, `altimate-base`, and `Altimate Base` requests now detect valid partial state and rotate its logout nonce; partial state remains absent from the interactive list. 9. ACP could fall back to a configured provider/model pair that was not present after provider discovery, including when Big Pickle was the only advertised model. Unavailable configured pairs are now rejected, producing the existing typed no-supported-model error before a session is created. Valid explicitly configured models, including Big Pickle, retain their existing behavior. +10. The consent boundary imported Base error classes directly, bypassing the package's required ESM namespace projection. It now consumes `FreeTier.RegistrationError` and `FreeTier.ConfigurationError` through the public `FreeTier` namespace without changing runtime behavior. The logout behavior is covered through the real CLI entrypoint in both directions: Base logout reduces a complete credential to the same install-secret-only identity while preserving an unrelated provider credential, a repeated explicit Base logout rotates the nonce of that partial state, ordinary provider logout preserves the byte-identical complete Base credential record, and malformed Base state is repaired into non-connected state. A separate lock-order regression models another process winning logout after registration captured the old generation; the stale registration is cancelled without calling the gateway and the disconnected record remains unchanged. @@ -73,6 +74,7 @@ The original and follow-up bypasses no longer reproduce: - `packages/opencode/src/mcp/discover.ts` - `packages/opencode/src/altimate/datamate-transport.ts` - `packages/opencode/src/altimate/free/client.ts` +- `packages/opencode/src/altimate/free/consent.ts` - `packages/opencode/src/altimate/free/store.ts` - `packages/opencode/src/cli/cmd/providers.ts` - `packages/opencode/test/mcp/discover.test.ts` @@ -103,6 +105,7 @@ The original and follow-up bypasses no longer reproduce: - Final Datamate release regression — 15 passed, 1 existing todo. Its home-directory isolation was corrected so developer MCP configuration cannot contaminate the result. - Real CLI managed logout regression — 1 passed with 12 assertions covering Base credential stripping, install-identity retention, explicit partial-state revocation, interactive partial-state invisibility, malformed-state recovery, legacy Base cleanup, ordinary-provider preservation, and the inverse isolation check. - Final Base/CLI/ACP regression set — 78 passed with 198 assertions, including function-level and service-level unavailable-configured-model coverage. +- Final namespace-boundary correction — the 28-test Base registration/consent suite, package typecheck, and targeted lint passed with zero errors. - Altimate Base registration/logout lock-order regression — passed; stale setup was cancelled with zero gateway calls and retained the disconnected install identity. - `packages/opencode` typecheck after the final release-review fixes — passed. From 15655e023a6878ae66d3837e3053e995d7de0038 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 30 Aug 2026 13:07:45 -0700 Subject: [PATCH 26/33] docs: record live altimate base release verification --- .../release-readiness.md | 42 ++++++++++++------- .../security-fix-verification.md | 15 +++++++ .../security-review-snapshot.md | 2 +- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/research/altimate-base-release-2026-08-30/release-readiness.md b/research/altimate-base-release-2026-08-30/release-readiness.md index 04c3b32cd1..7891a4b882 100644 --- a/research/altimate-base-release-2026-08-30/release-readiness.md +++ b/research/altimate-base-release-2026-08-30/release-readiness.md @@ -5,7 +5,7 @@ Scope: hosted Qwen 3.8 release, free-tier gateway, Altimate Base client integrat ## Release decision -The implementation is code-complete and exact gateway commit `11877e6` is deployed on staging. The post-deploy public acceptance suite and a forced streamed tool call both passed. It is not yet a completed public release: the three release PRs still need human approval and merge, and the client artifact still needs to be built and published from the approved release commit. +The implementation is code-complete and exact gateway commit `ba6d7ad` is deployed on the live public gateway. The public acceptance suite, forced streamed tool call, both serving tiers, autoscaler transitions, accounting signal, and abuse controls have been exercised against the deployed system. All observed checks on the three release PRs are green. It is not yet a completed client release: the PRs still need human approval and merge, and the client artifact must be built and published from the approved release commit. The intended public contract is now stable: @@ -18,14 +18,14 @@ The intended public contract is now stable: | Repository | PR | Purpose | Current disposition | | --- | --- | --- | --- | -| `AltimateAI/altimate-gateway` | #1 | Free-tier issuer, accounting, abuse controls, routing, autoscaler, deployment safety, and configurable bootstrap URL | Release branch pushed through `11877e6`; all checks green and human approval required | -| `AltimateAI/altimate-code` | #1199 | Altimate Base provider, consent flow, managed credentials, default-model replacement, ACP/session persistence, and safety tests | Synced with current main and verified locally; hosted checks and human approval are merge gates | -| `AltimateAI/altimate-code` | #1171 | Harness/session reliability improvements developed alongside the model work | All observed checks green; human approval required | +| `AltimateAI/altimate-gateway` | #1 | Free-tier issuer, accounting, abuse controls, routing, autoscaler, deployment safety, and configurable bootstrap URL | Release branch pushed through `ba6d7ad`; tests, shell/config, environment hygiene, secret scan, and Kilo review are green; human approval required | +| `AltimateAI/altimate-code` | #1199 | Altimate Base provider, consent flow, managed credentials, default-model replacement, ACP/session persistence, and safety tests | Synced with current main; all observed hosted checks and reviews are green; human approval required | +| `AltimateAI/altimate-code` | #1171 | Harness/session reliability improvements developed alongside the model work | All observed hosted checks and reviews are green; human approval required | | `AltimateAI/altimate-code` | #1115 | Earlier free Gemini experiment | Superseded by #1199; branch is conflicting and its secret scan is failing | Recommended order: -1. Completed: roll gateway commit `11877e6` to staging and repeat the public smoke and streamed-tool-call tests. +1. Completed: roll gateway commit `ba6d7ad` to the live gateway and repeat the public, tier, autoscaler, and abuse-control checks. 2. Approve and merge gateway PR #1. 3. Approve and merge client PR #1199, then build the release artifact with the official gateway endpoint embedded. 4. Merge harness PR #1171 independently once its approval is available. @@ -67,7 +67,7 @@ The standalone gateway bootstrap uses `ALTIMATE_BASE_GATEWAY_URL`, with `ALTIMAT ## Gateway and abuse controls -The staging gateway uses a public alias, a per-install registration secret hash, short-lived managed keys, a lifetime wallet allowance, request and registration rate limits, a kill switch, and an issuer-owned accounting ledger. Unsupported models and endpoints are denied. +The live gateway uses a public alias, a per-install registration secret hash, short-lived managed keys, a lifetime wallet allowance, request and registration rate limits, a kill switch, and an issuer-owned accounting ledger. Unsupported models and endpoints are denied. Release hardening completed during this pass includes: @@ -79,7 +79,11 @@ Release hardening completed during this pass includes: - Autoscaler schedule edits are validated before becoming the last-good configuration. Invalid time zones, weekdays, time windows, and alert cooldowns are rejected. - Repeated thrash, budget-refusal, and wake-timeout alerts use a persisted per-event cooldown instead of posting once per poll. - Monthly GPU spend is written to a redundant atomic checkpoint. If the main state file is missing or corrupt, spend is recovered; if neither copy is trustworthy, new scale-ups are refused. -- Exact commit `11877e6` was copied from a clean Git archive, checksum-verified on the VM, and compared against the deployed source while preserving only the mode-0600 environment file and rolling state. A mode-0600 pre-roll source backup and a copy of the deployed release inputs are retained for rollback. +- Rolling deployment now mounts the generated upstream directory rather than an individual file, so an atomic rename cannot leave Nginx pinned to a stale inode. The live repair exposed a roughly 1.3-second health-check gap while the stable proxy was recreated; the permanent path uses graceful reloads and the route verifier rejects a stale bind mount. +- BURST Flex-start creation now supplies the required delete-at-expiry action, termination maintenance policy, maximum run duration, and bounded request-validity window. The live allocation was observed as Flex-start with the expected two GPUs and deletion contract. +- SGLang's lifetime-cumulative TTFT histogram is checkpointed and converted to per-poll deltas. A single slow WARM cold-start sample had previously left the cumulative p50 in the 40-second bucket and prevented BURST teardown indefinitely; the deployed controller now treats an unchanged histogram as no fresh TTFT signal. +- The Postgres arrivals source no longer targets an ephemeral container address. Compose publishes the database strictly on loopback for the host autoscaler, and the scaler DSN targets that stable loopback listener. The live source changed from connection-refused to `arrivals_known=true` without exposing Postgres on the public or VPC interfaces. +- Exact commit `ba6d7ad` was copied from a clean Git archive, checksum-verified against the local release tree, and marked only after the database, controller, and watchdog recovered. The deployment preserved the secret environment file and rolling state; its temporary secret-bearing backup was securely removed after success, while exact source archives remain available for code rollback. These controls are scoped to the Altimate Base gateway and its named serving tiers. They do not change routing or credentials for other client models. @@ -87,7 +91,7 @@ These controls are scoped to the Altimate Base gateway and its named serving tie ### Live public path -The deployed staging serving path passed the end-to-end smoke suite 8/8 after the `11877e6` rollout: +The live serving path passed the end-to-end smoke suite 8/8 during the release rollout: - first registration - same-install key rotation @@ -98,18 +102,23 @@ The deployed staging serving path passed the end-to-end smoke suite 8/8 after th - denied unauthenticated inference - correct registration response shape -A separate forced streamed-tool-call request through the public `altimate-base` alias emitted the requested function with the expected structured arguments across 32 stream events and finished with `tool_calls`. The release script itself also required a real completion from the candidate color before switching traffic. +A separate forced streamed-tool-call request through the public `altimate-base` alias emitted the requested function with valid structured JSON arguments across 24 stream events, emitted one terminal marker, and finished with `tool_calls`. Its temporary key was revoked and then rejected. The release script itself also required a real completion from the candidate color before switching traffic. -WARM serving returned 401 without its serving key and 200 with the key. The managed container was healthy with no restarts. A second client identity received its own successful registration after the proxy fix, demonstrating that registration limiting is per client rather than per proxy. +WARM and BURST each returned exactly `qwen3.8-27b` from authenticated direct model inventory. Both ran the exact pinned serving image with the expected GPU count, `on-failure:5`, zero container restarts, no legacy container, and the legacy service disabled or inactive. BURST then carried public completion traffic before WARM was refreshed and verified. + +A long proxy-log window contained successful completions, the expected authentication/policy denials, and two client-aborted requests, with no gateway 5xx responses. A replacement continuity verifier produced ten fully valid accepted completions and two correct per-key 429 responses; there were no JSON, usage, or terminal-result failures among accepted requests. After the final autoscaler-only deployment, public liveness returned 200 without redirect. A new registration was correctly rejected with 429 because the configured registration window was already full; the limiter was respected rather than bypassed. + +The final live autoscaler proof is signal-driven, not merely configured: queue and streams were zero on three direct samples from both tiers, TTFT became unknown when the cumulative histogram did not advance, the BURST idle counter began accumulating, and Postgres arrivals were known. A temporary operator `force warm` was used only after those quiescence checks to avoid another 30 minutes of H100 cost; BURST was deleted, the override was cleared, and the checked-in configuration checksum was restored exactly. The final state is WARM with no BURST coordinates or cleanup latch, and both the service and watchdog timer are active. ### Gateway repository - Complete pytest suite: green, with only the expected integration skips. -- Fresh focused regression set: 159 tests green. -- CI-pinned Ruff 0.9.10: green for `issuer`. +- New TTFT-delta, counter-reset, multi-series, and loopback-only Postgres contract regressions: green. +- CI-pinned Ruff 0.9.10: green for `issuer` and the autoscaler files changed in the final fix. - ShellCheck and Bash syntax checks: green for both bootstrap copies. -- Checked-in autoscaler configuration: structurally valid with a non-secret test serving key. +- Compose renders successfully with the example environment, `git diff --check` is clean, and local Gitleaks reports no leaks. - Both copies of the bootstrap script are byte-identical. +- Gateway PR #1 hosted checks are green: tests, shell/config, environment hygiene, secrets, and Kilo review. ### Client repository @@ -124,6 +133,7 @@ WARM serving returned 401 without its serving key and 200 with the key. The mana - Final release-review regressions: locale-independent MCP ordering passed 23/23 discovery tests; Datamate passed 15 tests with one existing todo; the real provider-logout CLI test passed all 12 security, recovery, and isolation assertions; a cross-process lock-order simulation proves stale registration is cancelled before gateway access; unavailable ACP configured-model tests pass at both function and session boundaries; the package typecheck remained green. - Local pre-release build with a non-production HTTPS endpoint: built successfully and the compiled binary passed its startup smoke check. - Branding, changed-file marker, and strict required-marker audits: green after the final report was added. +- PR #1199 hosted TypeScript, driver E2E, Windows ripgrep, compliance, standards, anti-slop, marker, secret, and automated-review checks are green; intentionally out-of-scope jobs are skipped rather than failed. The historical scan body is preserved in `security-review-snapshot.md` with a prominent resolved-status banner; it describes the stale scanned head. The current remediation state and proof are in `security-fix-verification.md`. @@ -134,18 +144,18 @@ The historical scan body is preserved in `security-review-snapshot.md` with a pr - Root typecheck: green. - Strict fork-marker analysis: green. - Full repository run completed with 11,998 passes; the seven failures were pre-existing MCP contamination/timeouts outside the harness diff. +- PR #1171 hosted TypeScript, Windows ripgrep, compliance, standards, anti-slop, marker, secret, and automated-review checks are green; intentionally out-of-scope jobs are skipped rather than failed. ## Rollback and operational safety -Gateway deployment uses the checked-in blue/green rolling script. It validates configuration and database readiness, starts the inactive color, switches Nginx atomically, recreates the issuer/background jobs, verifies them, and restores both traffic and application services on failure. Recent staging rolls exercised this path repeatedly, including after credential rotation. +Gateway deployment uses the checked-in blue/green rolling script. It validates configuration and database readiness, starts the inactive color, switches Nginx atomically, recreates the issuer/background jobs, verifies them, and restores both traffic and application services on failure. Live rolls exercised this path repeatedly, including after credential rotation. The final autoscaler-only archive deployment used an explicit prior-archive rollback trap, preserved the live environment and rolling directory, and did not rebuild or reroute the unchanged inference proxy. If the client release must be rolled back, users with explicit models remain unaffected. The prior client artifact can be restored without changing the gateway alias. If the hosted backend must change, update the gateway mapping behind `altimate-base`; do not change the public model ID. -The live environment has mode-0600 pre-roll backups for the relevant environment transitions. No raw gateway key, master key, or trace credential is recorded in this document. +Exact source archives are retained for rollback. Temporary copies of secret environment data created during the final deployment were securely removed. No raw gateway key, master key, endpoint, infrastructure identity, or trace credential is recorded in this document. ## Remaining release actions -- Confirm the final client head remains green in hosted CI. - Obtain the required human approvals on #1, #1199, and #1171. - Merge in the order above and publish the client artifact with the official endpoint embedded. - Observe registration 429s, wallet debits/high-water progress, backend health, and autoscaler decisions during the initial release window. diff --git a/research/altimate-base-release-2026-08-30/security-fix-verification.md b/research/altimate-base-release-2026-08-30/security-fix-verification.md index 7790b671a2..668c1a855b 100644 --- a/research/altimate-base-release-2026-08-30/security-fix-verification.md +++ b/research/altimate-base-release-2026-08-30/security-fix-verification.md @@ -14,6 +14,21 @@ The two medium-severity findings reported against stale client head `04fceafcf8` No Altimate Base model, provider, credential, or routing code was changed by the two original scan remediations. The final release-review follow-ups below add explicit managed-provider disconnect behavior and reject unavailable ACP configuration; valid configured models and inference behavior are unchanged. +## Gateway release verification + +Gateway commit `ba6d7ad` is deployed on the live public gateway. This is a separate repository and deployment boundary from the two historical client findings, but it was rechecked before declaring the combined release ready: + +- The complete gateway pytest suite passed with only expected integration skips; CI-pinned Ruff, Compose rendering, diff hygiene, and local Gitleaks passed. +- Hosted PR #1 checks passed for tests, shell/config, environment hygiene, secret scanning, and Kilo review. +- Public acceptance previously passed 8/8, and a forced streamed function call completed with valid JSON arguments, a terminal marker, and `tool_calls`; its temporary key was revoked and rejected afterward. +- WARM and BURST both served only the expected Qwen model from the exact pinned image, with authenticated direct inventory, expected GPU counts, and no container restarts. +- Flex-start BURST was live-observed with bounded request validity, maximum runtime, terminate maintenance policy, and delete-at-expiry behavior. +- The final autoscaler fix derives TTFT from new Prometheus histogram observations rather than lifetime-cumulative counters. Live TTFT became unknown when counters did not move, and the BURST idle counter advanced instead of remaining pinned by one historical slow request. +- The Postgres arrivals query now uses a loopback-only host bind instead of an ephemeral container address. Live `arrivals_known` is true, while the database remains unbound from public and VPC interfaces. +- After three zero-queue/zero-stream samples on both tiers, BURST was drained and deleted. The temporary override was cleared, the checked-in configuration checksum was restored exactly, and the service/watchdog remain active in WARM with no cleanup latch. + +No raw endpoint, serving key, gateway master key, infrastructure identity, or trace credential is included in this evidence. + ## Final release-review follow-ups The review of the remediated branch found ten additional release-quality issues, all now closed in the current tree: diff --git a/research/altimate-base-release-2026-08-30/security-review-snapshot.md b/research/altimate-base-release-2026-08-30/security-review-snapshot.md index 590650fcb1..6995b55826 100644 --- a/research/altimate-base-release-2026-08-30/security-review-snapshot.md +++ b/research/altimate-base-release-2026-08-30/security-review-snapshot.md @@ -1,4 +1,4 @@ -> **Historical scan snapshot — both findings are resolved.** This report was generated against client head `04fceafcf8`, not the current release branch. Commit `755b410787` fixed both findings and their reviewed variants. See `security-fix-verification.md` for the current status and passing proof. The original report body is preserved below as historical evidence and must not be read as the current release state. +> **Historical scan snapshot — both findings are resolved.** This report was generated against client head `04fceafcf8`, not current release head `7d5d9b22b8`. Commit `755b410787` fixed both findings and their reviewed variants. Gateway release commit `ba6d7ad` was separately verified in the live environment; it does not reopen either client finding. See `security-fix-verification.md` for the current status and passing proof. The original report body is preserved below as historical evidence and must not be read as the current release state. # Security Review: altimate-base-release-final From d087b61243d761f273215aa7b4bd721b0eb0fd01 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 31 Aug 2026 11:14:37 -0700 Subject: [PATCH 27/33] fix: use free tier store namespace type --- packages/opencode/src/altimate/free/client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 3cd77168f5..bf5d4e0877 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -2,7 +2,7 @@ import { createHash, randomBytes } from "node:crypto" import { Flock } from "@opencode-ai/core/util/flock" import { Installation } from "../../installation" import { Log } from "../util/log" -import { FreeTierStore, type Record as FreeTierRecord } from "./store" +import { FreeTierStore } from "./store" import { FreeTierUrl } from "./url" const log = Log.create({ service: "altimate-base" }) @@ -76,7 +76,7 @@ export function hashInstallSecret(secret: string): string { return createHash("sha256").update(secret).digest("hex") } -function credentialsFromStored(stored: FreeTierRecord | undefined): Credentials | undefined { +function credentialsFromStored(stored: FreeTierStore.Record | undefined): Credentials | undefined { if (!stored?.apiKey || !stored.baseURL) return undefined return { apiKey: stored.apiKey, @@ -123,7 +123,7 @@ export async function isRegistered(): Promise { export async function logout(): Promise { rejectedCredentials.clear() await Flock.withLock(LOCK_KEY, async () => { - let stored: FreeTierRecord | undefined + let stored: FreeTierStore.Record | undefined try { stored = await FreeTierStore.read() } catch (error) { From 7ccd16db9420a4abf7df6d38527d0b0378f68c62 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 31 Aug 2026 12:17:42 -0700 Subject: [PATCH 28/33] fix(base): use gateway URL namespace in release build --- packages/opencode/script/build.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 12138ea2a0..5516041102 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -19,7 +19,7 @@ import { Script } from "@opencode-ai/script" import pkg from "../package.json" import { walkInputs } from "./stamp-inputs" import { assertUsableCatalog, catalogDiagnosticOrigin, formatCatalogSummary } from "./models-catalog" -import { normalizeGatewayUrl } from "../src/altimate/free/url" +import { FreeTierUrl } from "../src/altimate/free/url" // Python engine has been eliminated — all methods run natively in TypeScript. // ALTIMATE_ENGINE_VERSION is no longer needed at runtime. @@ -32,12 +32,10 @@ console.log(`Loaded CHANGELOG.md (${changelog.length} chars)`) // altimate_change start — inject the official Altimate Base endpoint at release time const rawAltimateBaseGatewayUrl = process.env.ALTIMATE_BASE_GATEWAY_URL?.trim() ?? "" const altimateBaseGatewayUrl = rawAltimateBaseGatewayUrl - ? normalizeGatewayUrl(rawAltimateBaseGatewayUrl) + ? FreeTierUrl.normalizeGatewayUrl(rawAltimateBaseGatewayUrl) : undefined if (rawAltimateBaseGatewayUrl && !altimateBaseGatewayUrl) { - console.error( - "error: ALTIMATE_BASE_GATEWAY_URL must be HTTPS and contain no credentials, query, or fragment", - ) + console.error("error: ALTIMATE_BASE_GATEWAY_URL must be HTTPS and contain no credentials, query, or fragment") process.exit(1) } if (Script.release && !altimateBaseGatewayUrl) { From 76296503fcd9d9b757c440c3d17b9166c71fd456 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 31 Aug 2026 13:12:01 -0700 Subject: [PATCH 29/33] fix: keep restored ACP model and effort consistent --- packages/opencode/src/acp/service.ts | 48 +++++++++++++---- .../opencode/test/acp/default-model.test.ts | 35 ++++++++----- .../opencode/test/acp/service-session.test.ts | 52 +++++++++++++++++++ 3 files changed, 112 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 53e2be1b0d..f0e0afa0c3 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -224,15 +224,17 @@ export function make(input: { "session", ) const restored = restoreFromMessages(messages.map((item) => item.info)) - // altimate_change start — fail closed when a legacy session has no usable model - const model = availableModel(snapshot, restored.model) ?? (yield* requireDefaultModel(snapshot)) + // altimate_change start — fail closed when a legacy session has no usable model and do not pair a fallback with stale effort + const restoredModel = availableModel(snapshot, restored.model) + const model = restoredModel ?? (yield* requireDefaultModel(snapshot)) + const variant = selectRestoredVariant(snapshot, model, restored.variant, restoredModel !== undefined) // altimate_change end const state = yield* session.load({ id: params.sessionId, cwd: params.cwd, mcpServers: params.mcpServers, model, - variant: restored.variant ?? selectVariant(snapshot, model), + variant, modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) sessionSnapshots.set(state.id, snapshot) @@ -311,15 +313,17 @@ export function make(input: { "session", ) const restored = restoreFromMessages(messages.map((item) => item.info)) - // altimate_change start — fail closed when a resumed session has no usable model - const model = availableModel(snapshot, restored.model) ?? (yield* requireDefaultModel(snapshot)) + // altimate_change start — fail closed when a resumed session has no usable model and do not pair a fallback with stale effort + const restoredModel = availableModel(snapshot, restored.model) + const model = restoredModel ?? (yield* requireDefaultModel(snapshot)) + const variant = selectRestoredVariant(snapshot, model, restored.variant, restoredModel !== undefined) // altimate_change end const state = yield* session.load({ id: params.sessionId, cwd: params.cwd, mcpServers: params.mcpServers ?? [], model, - variant: restored.variant ?? selectVariant(snapshot, model), + variant, modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) sessionSnapshots.set(state.id, snapshot) @@ -396,15 +400,17 @@ export function make(input: { "session", ) const restored = restoreFromMessages(messages.map((item) => item.info)) - // altimate_change start — fail closed when a fork has no usable model - const model = availableModel(snapshot, restored.model) ?? fallbackModel + // altimate_change start — fail closed when a fork has no usable model and do not pair a fallback with stale effort + const restoredModel = availableModel(snapshot, restored.model) + const model = restoredModel ?? fallbackModel + const variant = selectRestoredVariant(snapshot, model, restored.variant, restoredModel !== undefined) // altimate_change end const state = yield* session.load({ id: forked.id, cwd: params.cwd, mcpServers: params.mcpServers ?? [], model, - variant: restored.variant ?? selectVariant(snapshot, model), + variant, modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) sessionSnapshots.set(state.id, snapshot) @@ -974,6 +980,26 @@ function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultMod return Object.keys(variants)[0] } +// altimate_change start — restored effort belongs to its restored model; validate both as one selection +function selectRestoredVariant( + snapshot: Directory.Snapshot, + model: Directory.DefaultModel, + restoredVariant: string | undefined, + restoredModelRetained: boolean, +) { + const variants = Directory.variants(snapshot, model) + if ( + restoredModelRetained && + restoredVariant && + variants && + Object.prototype.hasOwnProperty.call(variants, restoredVariant) + ) { + return restoredVariant + } + return selectVariant(snapshot, model) +} +// altimate_change end + function configOptions(snapshot: Directory.Snapshot, session: ConfigState) { return buildConfigOptions({ providers: Object.values(snapshot.providers), @@ -1182,3 +1208,7 @@ function findProviderID(value: unknown): string | undefined { if ("data" in value) return findProviderID(value.data) if ("error" in value) return findProviderID(value.error) } + +// altimate_change start — expose the module through the repository's namespace projection convention +export * as ACPService from "./service" +// altimate_change end diff --git a/packages/opencode/test/acp/default-model.test.ts b/packages/opencode/test/acp/default-model.test.ts index 208ed6b34e..12f94058ef 100644 --- a/packages/opencode/test/acp/default-model.test.ts +++ b/packages/opencode/test/acp/default-model.test.ts @@ -6,7 +6,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" import { ProviderID, ModelID } from "@/provider/schema" -import { defaultModelFromConfig, selectDefaultModel } from "@/acp/service" +import { ACPService } from "@/acp/service" import type { Snapshot } from "@/acp/directory" const model = (providerID: ProviderID, id: string): Provider.Model => ({ @@ -49,7 +49,7 @@ const providers = (...infos: Provider.Info[]) => describe("ACP defaultModelFromConfig", () => { test("prefers altimate-backend/altimate-default when available and no model configured", () => { - const result = defaultModelFromConfig( + const result = ACPService.defaultModelFromConfig( undefined, providers(provider("altimate-backend", ["altimate-default"]), provider("opencode", ["big-pickle"])), ) @@ -60,7 +60,7 @@ describe("ACP defaultModelFromConfig", () => { }) test("prefers registered Altimate Base when the paid gateway is not present", () => { - const result = defaultModelFromConfig( + const result = ACPService.defaultModelFromConfig( undefined, providers(provider("altimate-free", ["altimate-base"]), provider("opencode", ["big-pickle"])), ) @@ -71,7 +71,7 @@ describe("ACP defaultModelFromConfig", () => { }) test("treats an empty provider object as unrestricted", () => { - const result = defaultModelFromConfig( + const result = ACPService.defaultModelFromConfig( undefined, providers(provider("altimate-free", ["altimate-base"]), provider("opencode", ["big-pickle"])), {}, @@ -83,11 +83,15 @@ describe("ACP defaultModelFromConfig", () => { }) test("never chooses Big Pickle implicitly", () => { - expect(defaultModelFromConfig(undefined, providers(provider("opencode", ["big-pickle"])))).toBeUndefined() + expect( + ACPService.defaultModelFromConfig(undefined, providers(provider("opencode", ["big-pickle"]))), + ).toBeUndefined() }) test("rejects a configured model that is not available", () => { - expect(defaultModelFromConfig("opencode/missing", providers(provider("opencode", ["big-pickle"])))).toBeUndefined() + expect( + ACPService.defaultModelFromConfig("opencode/missing", providers(provider("opencode", ["big-pickle"]))), + ).toBeUndefined() }) test("does not reintroduce Big Pickle through the ACP snapshot fallback", () => { @@ -114,14 +118,17 @@ describe("ACP defaultModelFromConfig", () => { availableCommands: [], } satisfies Snapshot - expect(selectDefaultModel(snapshot)).toEqual({ + expect(ACPService.selectDefaultModel(snapshot)).toEqual({ providerID: ProviderV2.ID.make("openai"), modelID: ModelV2.ID.make("gpt-5"), }) }) test("falls back to another OpenCode model when Altimate Base is not registered", () => { - const result = defaultModelFromConfig(undefined, providers(provider("opencode", ["big-pickle", "gpt-5"]))) + const result = ACPService.defaultModelFromConfig( + undefined, + providers(provider("opencode", ["big-pickle", "gpt-5"])), + ) expect(result).toEqual({ providerID: ProviderV2.ID.make("opencode"), modelID: ModelV2.ID.make("gpt-5"), @@ -129,7 +136,7 @@ describe("ACP defaultModelFromConfig", () => { }) test("skips altimate-backend when an explicit provider allowlist excludes it", () => { - const result = defaultModelFromConfig( + const result = ACPService.defaultModelFromConfig( undefined, providers(provider("altimate-backend", ["altimate-default"]), provider("opencode", ["gpt-5"])), { opencode: {} }, @@ -138,7 +145,7 @@ describe("ACP defaultModelFromConfig", () => { }) test("does not recover an excluded managed provider through the sorted fallback", () => { - const result = defaultModelFromConfig( + const result = ACPService.defaultModelFromConfig( undefined, providers(provider("altimate-free", ["altimate-base"]), provider("opencode", ["big-pickle"])), { opencode: {} }, @@ -147,7 +154,7 @@ describe("ACP defaultModelFromConfig", () => { }) test("an Altimate Base-only provider block cannot force the managed provider", () => { - const result = defaultModelFromConfig( + const result = ACPService.defaultModelFromConfig( undefined, providers(provider("altimate-free", ["altimate-base"]), provider("openai", ["gpt-5"])), { "altimate-free": {} }, @@ -156,7 +163,7 @@ describe("ACP defaultModelFromConfig", () => { }) test("honors an explicit provider allowlist that includes altimate-backend", () => { - const result = defaultModelFromConfig( + const result = ACPService.defaultModelFromConfig( undefined, providers(provider("altimate-backend", ["altimate-default"]), provider("opencode", ["gpt-5"])), { "altimate-backend": {}, opencode: {} }, @@ -165,7 +172,7 @@ describe("ACP defaultModelFromConfig", () => { }) test("a valid configured model takes precedence over the altimate-backend default", () => { - const result = defaultModelFromConfig( + const result = ACPService.defaultModelFromConfig( "opencode/big-pickle", providers(provider("altimate-backend", ["altimate-default"]), provider("opencode", ["big-pickle"])), ) @@ -193,7 +200,7 @@ describe("ACP defaultModelFromConfig", () => { availableCommands: [], } satisfies Snapshot - expect(selectDefaultModel(snapshot)).toBeUndefined() + expect(ACPService.selectDefaultModel(snapshot)).toBeUndefined() }) }) // altimate_change end diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 02f88fecbc..929c1a73ce 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -465,6 +465,38 @@ describe("ACP service sessions", () => { expect(forks).toHaveLength(1) }) + it("drops a restored variant when load, resume, or fork falls back to a different model", async () => { + // `high` is valid for the fallback model, which makes this the important + // case: it still belongs to the removed model and must not leak across the + // model boundary merely because the variant names happen to match. + const { service } = makeService([ + { + info: { + role: "assistant", + providerID: "removed-provider", + modelID: "removed-model", + variant: "high", + }, + parts: [], + }, + ]) + + const loaded = await Effect.runPromise( + service.loadSession({ cwd: "/workspace", sessionId: "ses_fallback_load", mcpServers: [] }), + ) + const resumed = await Effect.runPromise( + service.resumeSession({ cwd: "/workspace", sessionId: "ses_fallback_resume", mcpServers: [] }), + ) + const forked = await Effect.runPromise( + service.forkSession({ cwd: "/workspace", sessionId: "ses_fallback_parent", mcpServers: [] }), + ) + + for (const result of [loaded, resumed, forked]) { + expect(select(result, "model")?.currentValue).toBe("test/test-model") + expect(select(result, "effort")?.currentValue).toBe("default") + } + }) + it("loads a session and restores model variant and mode from messages", async () => { const { service } = makeService([ { @@ -486,6 +518,26 @@ describe("ACP service sessions", () => { expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan") }) + it("drops a restored variant that is no longer advertised by the retained model", async () => { + const { service } = makeService([ + { + info: { + role: "assistant", + providerID: "test", + modelID: "test-model", + variant: "retired-effort", + }, + parts: [], + }, + ]) + + const result = await Effect.runPromise( + service.loadSession({ cwd: "/workspace", sessionId: "ses_invalid_variant", mcpServers: [] }), + ) + + expect(select(result, "effort")?.currentValue).toBe("default") + }) + it("replays loaded session transcript chunks", async () => { const { service, updates } = makeService([ { From d5acff9dafa9e936b02553165c98c9ddb81189dc Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 31 Aug 2026 13:14:53 -0700 Subject: [PATCH 30/33] chore: mark restored ACP effort projection --- packages/opencode/src/acp/service.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index f0e0afa0c3..5eeffa7a02 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -234,7 +234,9 @@ export function make(input: { cwd: params.cwd, mcpServers: params.mcpServers, model, + // altimate_change start — use the model-coupled restored effort selected above variant, + // altimate_change end modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) sessionSnapshots.set(state.id, snapshot) @@ -323,7 +325,9 @@ export function make(input: { cwd: params.cwd, mcpServers: params.mcpServers ?? [], model, + // altimate_change start — use the model-coupled restored effort selected above variant, + // altimate_change end modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) sessionSnapshots.set(state.id, snapshot) @@ -410,7 +414,9 @@ export function make(input: { cwd: params.cwd, mcpServers: params.mcpServers ?? [], model, + // altimate_change start — use the model-coupled restored effort selected above variant, + // altimate_change end modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) sessionSnapshots.set(state.id, snapshot) From 9a9d9ed8910e4ad442047255828575951c3c7c40 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 31 Aug 2026 15:45:13 -0700 Subject: [PATCH 31/33] fix: mark Altimate Base migration changes --- packages/opencode/src/acp/service.ts | 2 ++ packages/tui/src/app.tsx | 3 ++- packages/tui/src/context/local.tsx | 13 ++++++++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index f5f771adec..110c2963b9 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -817,11 +817,13 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { : providers // altimate_change end const defaultModelStarted = performance.now() + // altimate_change start — omit the managed provider from ACP defaults when config lookup fails const defaultModel = defaultModelFromConfig( config?.model, configSafeProviders, config?.provider as Record | undefined, ) + // altimate_change end ACPProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel }) const modes = agents .filter((agent) => agent.mode !== "subagent" && agent.hidden !== true) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 0a12c93c4a..679e0148d1 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -111,8 +111,9 @@ import { cliErrorMessage, errorFormat } from "./util/error" import { detectModeFromCOLORFGBG } from "./terminal-detection" // altimate_change end -// altimate_change — remember an explicit migration decline without suppressing later manual setup +// altimate_change start — remember an explicit migration decline without suppressing later manual setup const ALTIMATE_BASE_MIGRATION_DECLINED_KEY = "altimate_base_big_pickle_migration_declined_v1" +// altimate_change end const appGlobalBindingCommands = [ "session.list", diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 0d61f28834..2ec5491569 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -66,7 +66,10 @@ export function isExistingBigPickleSelection(current: unknown, recent: readonly } // altimate_change end -export function recentModels(model: ModelRef, recent: ModelRef[]) { +export function recentModels( + model: { providerID: string; modelID: string }, + recent: { providerID: string; modelID: string }[], +) { const seen = new Set() return [model, ...recent] .filter((item) => { @@ -220,7 +223,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ .then((x) => { if (!x || typeof x !== "object") return const value = x as Record + // altimate_change start — discard malformed persisted model references before default migration if (Array.isArray(value.recent)) setModelStore("recent", value.recent.filter(isModelRef)) + // altimate_change end if (Array.isArray(value.favorite)) setModelStore("favorite", value.favorite) if (typeof value.variant === "object" && value.variant !== null) setModelStore("variant", value.variant as Record) @@ -233,6 +238,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const args = useArgs() + // altimate_change start — distinguish explicit model choices from the retired implicit default // A command-line, project, or agent model is an explicit choice. Legacy migration applies // only to the implicit/persisted default and must never rewrite those settings. function hasExplicitModel() { @@ -246,6 +252,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ .some((model) => isLegacyBigPickleModel(parseModel(model))) return configured || isLegacyBigPickleModel(agent.current()?.model) } + // altimate_change end const fallbackModel = createMemo(() => { if (args.model) { @@ -297,6 +304,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ ) }) + // altimate_change start — share validated selection with legacy-default and session migration function selectModel(model: ModelRef, options?: { recent?: boolean }) { let selected = false batch(() => { @@ -328,6 +336,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ function hasExistingLegacySelection() { return isExistingBigPickleSelection(currentModel(), modelStore.recent, hasExplicitLegacyModel()) } + // altimate_change end return { current: currentModel, @@ -402,9 +411,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ setModelStore("recent", recentModels(next, modelStore.recent)) save() }, + // altimate_change start — share the validated selection path with default migration set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) { selectModel(model, options) }, + // altimate_change end // altimate_change start — migrate Big Pickle defaults after managed-model consent usesLegacyDefault, hasExistingLegacySelection, From 242f2d0a52df2aa501ce134891007c2826c9cbdb Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 31 Aug 2026 16:02:14 -0700 Subject: [PATCH 32/33] fix: honor provider allowlists during Base migration --- .../tui/src/component/altimate-onboarding.tsx | 9 +++--- packages/tui/src/context/local.tsx | 31 +++++++++++++++++-- packages/tui/test/context/local.test.ts | 15 +++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index 6195c5f524..6319713426 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -468,10 +468,11 @@ export function DialogAltimateBaseConfirm(props: { decided = true setBusy(false) dialog.clear() - // A migration also removes the retired implicit model from recents. If state changed while - // the dialog was open, the explicit Yes still selects Altimate Base. - const migrated = props.origin === "migration" && local.model.migrateLegacyDefault() - if (!migrated) local.model.set({ providerID: "altimate-free", modelID: "altimate-base" }, { recent: true }) + // 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. + if (props.origin === "migration") local.model.migrateLegacyDefault() + else local.model.set({ providerID: "altimate-free", modelID: "altimate-base" }, { recent: true }) markSetupComplete() } diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 2ec5491569..4ccb37f47a 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -64,6 +64,24 @@ export function isExistingBigPickleSelection(current: unknown, recent: readonly if (!isLegacyBigPickleModel(current)) return false return explicit || recent.some(isLegacyBigPickleModel) } + +export function allowsManagedBaseDefault(providerConfig: unknown) { + if (providerConfig === undefined || providerConfig === null) return true + if (typeof providerConfig !== "object" || Array.isArray(providerConfig)) return false + // A non-empty provider block is an explicit project allowlist. As in Provider.defaultModel, + // naming the managed provider there cannot force it into the request-logging default path. + return Object.keys(providerConfig).length === 0 +} + +export function shouldMigrateLegacyDefault( + current: unknown, + recent: readonly unknown[], + explicit: boolean, + providerConfig: unknown, +) { + if (explicit || !allowsManagedBaseDefault(providerConfig)) return false + return isExistingBigPickleSelection(current, recent, false) +} // altimate_change end export function recentModels( @@ -329,8 +347,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } function usesLegacyDefault() { - if (hasExplicitModel()) return false - return isExistingBigPickleSelection(currentModel(), modelStore.recent, false) + return shouldMigrateLegacyDefault( + currentModel(), + modelStore.recent, + hasExplicitModel(), + sync.data.config.provider, + ) } function hasExistingLegacySelection() { @@ -430,7 +452,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return true }, restoreSession(model: ModelRef) { - const shouldMigrate = !hasExplicitModel() && isModelValid(ALTIMATE_BASE_MODEL) + const shouldMigrate = + !hasExplicitModel() && + allowsManagedBaseDefault(sync.data.config.provider) && + isModelValid(ALTIMATE_BASE_MODEL) const selected = migrateLegacySessionModel(model, shouldMigrate) if (!selectModel(selected)) return undefined if (selected !== model) { diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index 646de36aae..7ead765a96 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test" import { + allowsManagedBaseDefault, ALTIMATE_BASE_MODEL, isExistingBigPickleSelection, LEGACY_BIG_PICKLE_MODEL, @@ -7,6 +8,7 @@ import { migrateLegacySessionModel, parseModel, recentModels, + shouldMigrateLegacyDefault, } from "../../src/context/local" test("parses model IDs containing slashes", () => { @@ -48,6 +50,19 @@ test("distinguishes an existing Big Pickle user from a fresh catalogue fallback" ).toBe(false) }) +test("honors project provider allowlists during Big Pickle default migration", () => { + expect(allowsManagedBaseDefault(undefined)).toBe(true) + expect(allowsManagedBaseDefault({})).toBe(true) + expect(allowsManagedBaseDefault({ openai: {} })).toBe(false) + expect(allowsManagedBaseDefault({ "altimate-free": {} })).toBe(false) + + expect(shouldMigrateLegacyDefault(LEGACY_BIG_PICKLE_MODEL, [LEGACY_BIG_PICKLE_MODEL], false, {})).toBe(true) + expect( + shouldMigrateLegacyDefault(LEGACY_BIG_PICKLE_MODEL, [LEGACY_BIG_PICKLE_MODEL], false, { openai: {} }), + ).toBe(false) + expect(shouldMigrateLegacyDefault(LEGACY_BIG_PICKLE_MODEL, [LEGACY_BIG_PICKLE_MODEL], true, {})).toBe(false) +}) + test("replaces Big Pickle recents while preserving every unrelated model and order", () => { expect( migrateLegacyRecentModels([ From f873da15e0a65dda2ddd2f2b1f9869ff49292e0d Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 1 Sep 2026 08:27:19 -0700 Subject: [PATCH 33/33] fix: address blocking review findings on Altimate Base release Fixes the seven MAJOR findings from the multi-model review of #1199. - `Provider.defaultModel()` and ACP `defaultModelFromConfig()` now select Altimate Base only as a LAST resort. It previously short-circuited above the general candidate scan, so a user who registered Base and later added a paid key still routed prompts to the request-logging tier. ACP has no `recent` list, so this was the common path there, not an edge case. - The Big Pickle migration now checks the recorded decline BEFORE registration state. Once registered the decline was never read, so a user who refused the migration and deliberately switched back was re-flipped on every launch. - Dismissing the migration dialog with Escape now persists the decline. `onCleanup` recorded telemetry but never called `onDecline`, so the prompt returned every launch forever. - `restoreSession()` no longer rewrites the model of an opened session. Migration is a decision about the DEFAULT model and belongs to the disclosure flow; applying it per-session moved historical threads onto the logging tier with no prompt, including for users who had declined. - Removed the headless leg of the migration from `defaultModel()`. The TUI owns the disclosure and already rewrites `model.json` on accept, so headless follows on the next launch instead of migrating behind the user's back. This also removes the TUI-`kv`-versus-headless split. - `registerAfterConsent()` now takes a consent capability as a REQUIRED argument and consumes it before any network or storage effect. Consent is a property of the operation rather than of its call sites; a future CLI, HTTP route, or plugin cannot register by importing it. `ConsentCapabilityStore` moved to a leaf module `free/capability.ts` to keep the dependency acyclic. - A single 401 no longer disowns the credential on disk. One 401 can come from a gateway deploy or key-propagation skew, and persisting it forced every user back through the disclosure. Rejection is persisted after two consecutive 401s; any success resets the count. - The ACP provider allowlist now gates only the managed provider. `config.provider` is a customization map (the docs demonstrate single-entry blocks setting `apiKey` or `options`), and treating it as a catalogue-wide allowlist hid every other authenticated provider from ACP clients and invalidated restored sessions pinned to them. - Disclosure copy and the security FAQ now state that logged requests are tied to a permanent per-installation identifier, that `/providers logout` does not reset it by design, and how to reset it locally. - Moved the release-readiness, security-review, and fix-verification documents out of this public repository. They carried gateway deployment topology, secret storage, service-account scoping, and an incident note. Tests: adds coverage for registration without an armed capability, the 401 threshold and its reset, paid-provider precedence over Base, the Base last-resort fallback, and an unrelated provider block leaving other providers advertised. Updates the tests that locked in the previous precedence and the headless migration. Verified: 5375 pass / 642 skip / 0 fail across 193 files; 13/13 typecheck tasks; strict upstream marker audit clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Nc6UbwpSz9yQqMoyJ3ABES --- docs/docs/configure/providers.md | 6 + docs/docs/reference/security-faq.md | 13 + packages/opencode/src/acp/service.ts | 55 ++-- .../opencode/src/altimate/free/capability.ts | 54 ++++ packages/opencode/src/altimate/free/client.ts | 55 +++- .../opencode/src/altimate/free/consent.ts | 65 +---- packages/opencode/src/cli/tui/worker.ts | 2 +- packages/opencode/src/provider/provider.ts | 45 ++-- .../opencode/test/acp/default-model.test.ts | 27 ++ .../opencode/test/acp/service-session.test.ts | 29 ++ .../test/altimate/altimate-base.test.ts | 110 ++++++-- .../opencode/test/provider/provider.test.ts | 15 +- packages/tui/src/app.tsx | 10 +- .../tui/src/component/altimate-onboarding.tsx | 6 +- packages/tui/src/component/prompt/index.tsx | 6 +- packages/tui/src/context/local.tsx | 22 +- packages/tui/test/context/local.test.ts | 11 - .../README.md | 17 ++ .../release-readiness.md | 161 ------------ .../security-fix-verification.md | 129 --------- .../security-review-snapshot.md | 247 ------------------ 21 files changed, 378 insertions(+), 707 deletions(-) create mode 100644 packages/opencode/src/altimate/free/capability.ts create mode 100644 research/altimate-base-release-2026-08-30/README.md delete mode 100644 research/altimate-base-release-2026-08-30/release-readiness.md delete mode 100644 research/altimate-base-release-2026-08-30/security-fix-verification.md delete mode 100644 research/altimate-base-release-2026-08-30/security-review-snapshot.md diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index e460933d13..73dd89cddc 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -76,6 +76,12 @@ Altimate Base credentials are stored separately from the shared provider-auth fi returned to the TUI. The installation secret is hashed before registration; the gateway receives the hash, not the local secret. +That hash is stable across launches, so it links this installation's logged requests together — +it is what enforces the free allowance. `/providers logout` clears the credential but keeps the +installation identity on purpose, so logging out is not a way to reset the allowance. Each +inference request additionally carries a session identifier used for rate limiting. See the +security FAQ for what this means for privacy and how to reset the local identity. + ## Anthropic ```json diff --git a/docs/docs/reference/security-faq.md b/docs/docs/reference/security-faq.md index 781388643e..dbd67d579b 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -19,6 +19,19 @@ secrets or confidential code. The dialog defaults to **No**, and no registration unless you explicitly accept. This request logging is part of the Altimate Base service and is separate from anonymous product telemetry. +**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 +requests from this installation are linked to one another. This is deliberate: it is how the free +allowance is enforced. `/providers logout` disconnects the provider but **keeps** the installation +identity, by design, so that logging out and back in cannot mint a fresh allowance. Each inference +request also carries a session identifier used for rate limiting and abuse control. + +Altimate Base is therefore pseudonymous, not anonymous. To reset the local identity completely, +delete `altimate-base.json` from the application data directory — `$XDG_DATA_HOME/altimate-code/`, +which defaults to `~/.local/share/altimate-code/` on both macOS and Linux — while the app is +closed. This is outside the supported flow, and the gateway applies its own network-level rate +limits. + If you use a self-hosted or VPC-deployed model (e.g., AWS Bedrock, Azure OpenAI), your data never leaves your cloud account. ## Can the AI read my database credentials? diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 110c2963b9..cf23b1e832 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -787,34 +787,24 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { ProviderV2.ID, Provider.Info > - // altimate_change start — apply the project provider allowlist to ACP model advertisement + // altimate_change start — keep the managed provider out of ACP unless this project allows it 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 + > // A failed config lookup cannot prove that this project permits the request-logging managed // provider. Keep every other connected provider usable, but fail closed for Altimate Base. - const configSafeProviders = configLoaded - ? providers - : (Object.fromEntries(Object.entries(providers).filter(([id]) => id !== "altimate-free")) as Record< - ProviderV2.ID, - Provider.Info - >) - const configuredProviderEntries = Object.keys(config?.provider ?? {}) - const hasProviderAllowlist = configuredProviderEntries.length > 0 - const configuredModelProvider = config?.model ? Provider.parseModel(config.model).providerID : undefined - // An empty provider object is the default/unrestricted configuration. A non-empty object is an - // allowlist for advertised ACP model options, except that an explicitly configured model remains - // authoritative. The managed provider can never be enabled merely by naming it in the block. - const snapshotProviders = !configLoaded - ? configSafeProviders - : hasProviderAllowlist - ? (Object.fromEntries( - Object.entries(providers).filter( - ([id]) => - id === configuredModelProvider || - (id !== "altimate-free" && Object.prototype.hasOwnProperty.call(config?.provider, id)), - ), - ) as Record) - : providers + const configSafeProviders = configLoaded ? providers : withoutManagedBase() + 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. + const snapshotProviders = configLoaded && !hasProviderAllowlist ? providers : withoutManagedBase() // altimate_change end const defaultModelStarted = performance.now() // altimate_change start — omit the managed provider from ACP defaults when config lookup fails @@ -889,14 +879,6 @@ export function defaultModelFromConfig( return { providerID: ProviderV2.ID.make("altimate-backend"), modelID: ModelV2.ID.make("altimate-default") } } - // Altimate Base replaces Big Pickle as the free fallback, but only after the user has already - // consented and registered (which is why it is present in `providers`). A project provider block - // cannot force the managed model; an explicit configured model above remains authoritative. - const baseProvider = providers[ProviderV2.ID.make("altimate-free")] - if (!hasProviderAllowlist && baseProvider?.models[ModelV2.ID.make("altimate-base")]) { - return { providerID: ProviderV2.ID.make("altimate-free"), modelID: ModelV2.ID.make("altimate-base") } - } - // First-session ACP startup must not scan historical sessions just to infer // a default. Configured model, opencode provider, then sorted best model keep // the protocol response deterministic without extra session/message reads. @@ -915,6 +897,15 @@ export function defaultModelFromConfig( .flatMap((provider) => Object.values(provider.models)), ).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, but only as a LAST resort and only + // after the user consented and registered (which is why it is present in `providers`). Anything + // else connected outranks the request-logging tier. A project provider block cannot force the + // managed model; an explicit configured model above remains authoritative. + const baseProvider = providers[ProviderV2.ID.make("altimate-free")] + if (!hasProviderAllowlist && baseProvider?.models[ModelV2.ID.make("altimate-base")]) { + return { providerID: ProviderV2.ID.make("altimate-free"), modelID: ModelV2.ID.make("altimate-base") } + } return undefined // altimate_change end } diff --git a/packages/opencode/src/altimate/free/capability.ts b/packages/opencode/src/altimate/free/capability.ts new file mode 100644 index 0000000000..e7a7d5b4fd --- /dev/null +++ b/packages/opencode/src/altimate/free/capability.ts @@ -0,0 +1,54 @@ +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 takes a capability as a required argument and consumes it before touching the + * network, which makes consent a property of the operation rather than of its call sites. + */ +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 + } +} + +export * as FreeTierCapability from "./capability" diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index bf5d4e0877..9e1bbd6c97 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -1,5 +1,6 @@ import { createHash, randomBytes } from "node:crypto" import { Flock } from "@opencode-ai/core/util/flock" +import { ConsentCapabilityStore } from "./capability" import { Installation } from "../../installation" import { Log } from "../util/log" import { FreeTierStore } from "./store" @@ -21,6 +22,11 @@ const LOCK_KEY = "altimate-base-registration" const inflight = new Map>() 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 +// gateway deploy, an LB restart, or key-propagation skew; persisting on the first one would take +// the whole free tier offline until every user re-ran the disclosure flow. +const REJECTED_PERSIST_THRESHOLD = 2 +const unauthorizedCounts = new Map() export interface Credentials { apiKey: string @@ -195,10 +201,34 @@ function credentialWasRejected(value: Pick): function clearRejectedCredentialInMemory(value: Pick): void { rejectedCredentials.delete(credentialFingerprint(value)) + clearUnauthorizedCount(value) +} + +function countUnauthorized(value: Pick): number { + const fingerprint = credentialFingerprint(value) + const next = (unauthorizedCounts.get(fingerprint) ?? 0) + 1 + unauthorizedCounts.delete(fingerprint) + unauthorizedCounts.set(fingerprint, next) + while (unauthorizedCounts.size > REJECTED_CREDENTIAL_LIMIT) { + const oldest = unauthorizedCounts.keys().next().value + if (!oldest) break + unauthorizedCounts.delete(oldest) + } + return next +} + +function clearUnauthorizedCount(value: Pick): void { + unauthorizedCounts.delete(credentialFingerprint(value)) } async function markCredentialRejected(value: Pick): Promise { markCredentialRejectedInMemory(value) + if (countUnauthorized(value) < REJECTED_PERSIST_THRESHOLD) { + // Blocked for the rest of this process, but not disowned on disk: a relaunch retries the + // credential, so a transient gateway fault resolves itself without another disclosure. + log.warn("Altimate Base credential rejected once; not persisting yet") + return + } await Flock.withLock(LOCK_KEY, async () => { const stored = await FreeTierStore.read() if (!stored?.apiKey || stored.apiKey !== value.apiKey || stored.baseURL !== value.baseURL || stored.rejected) return @@ -303,12 +333,21 @@ async function registerOnce( } /** - * Register only after the private TUI host has consumed its one-shot consent capability. + * Register only after consuming a one-shot consent capability. * - * Keeping this as the sole exported registration operation makes accidental background - * registration visible at call sites. Provider discovery and inference never call it. + * The capability is a REQUIRED argument and is consumed here, before any network or storage + * effect, so "registration requires an accepted disclosure" is enforced by this function rather + * than by the discipline of its callers. A future CLI, HTTP route, or plugin cannot register by + * importing this: it would have to obtain a capability that only the TUI host arms. + * Provider discovery and inference never call it. */ -export async function registerAfterConsent(input: { signal?: AbortSignal } = {}): Promise { +export async function registerAfterConsent( + consent: { capability: ConsentCapabilityStore; token: string }, + input: { signal?: AbortSignal } = {}, +): Promise { + if (!consent.capability.consume(consent.token)) { + throw new RegistrationError("Altimate Base consent expired. Reopen setup and try again.", "cancelled") + } const configuredGateway = gatewayUrl() const dedupeKey = configuredGateway const pending = inflight.get(dedupeKey) @@ -405,7 +444,13 @@ export async function authorizedFetch(input: RequestInfo | URL, init?: RequestIn // been revoked after this request was authorized. Only explicit consent and // registration rotate/clear rejected credentials, keeping the ordinary // inference path lock-free after its initial credential read. - if (response.status !== 401) return response + // + // It does, however, prove the credential is not dead right now, so the consecutive-401 counter + // resets. Only an unbroken run of 401s disowns a credential on disk. + if (response.status !== 401) { + if (response.ok) clearUnauthorizedCount(active) + return response + } await markCredentialRejected(active) if (!isReplayable(input, init?.body)) return response diff --git a/packages/opencode/src/altimate/free/consent.ts b/packages/opencode/src/altimate/free/consent.ts index 9784899c50..a9c2bff3d8 100644 --- a/packages/opencode/src/altimate/free/consent.ts +++ b/packages/opencode/src/altimate/free/consent.ts @@ -1,9 +1,8 @@ +import { ConsentCapabilityStore } from "./capability" import { FreeTier } from "./client" import { FreeTierStore } from "./store" -const TOKEN_PATTERN = /^[0-9a-f]{64}$/ -const DEFAULT_TTL_MS = 30_000 -const DEFAULT_MAX_PENDING = 16 +export { ConsentCapabilityStore } export type RegistrationResult = | { ok: true } @@ -13,53 +12,9 @@ export type RegistrationResult = message: string } -/** - * 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. - */ -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 - } -} - export function createRegistrationConsentGate(input: { - register: () => Promise + /** Receives the capability and token so registration itself consumes the one-shot proof. */ + register: (consent: { capability: ConsentCapabilityStore; token: string }) => Promise capabilities?: ConsentCapabilityStore onUnexpectedError?: (error: unknown) => void }) { @@ -69,17 +24,13 @@ export function createRegistrationConsentGate(input: { capabilities.arm(value.token) }, async register(value: { token: string }): Promise { - if (!capabilities.consume(value.token)) { - return { - ok: false, - result: "error", - message: "Altimate Base consent expired. Reopen setup and try again.", - } - } try { - await input.register() + await input.register({ capability: capabilities, token: value.token }) return { ok: true } } catch (error) { + if (error instanceof FreeTier.RegistrationError && error.kind === "cancelled") { + return { ok: false, result: "error", message: error.message } + } if (error instanceof FreeTier.RegistrationError) { return { ok: false, diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index a422809684..b0ef0ff40e 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -68,7 +68,7 @@ GlobalBus.on("event", (event) => { let server: Awaited> | undefined // altimate_change start — worker-local, expiring capabilities gate every registration mutation const altimateBaseRegistration = FreeTierConsent.createRegistrationConsentGate({ - register: () => FreeTier.registerAfterConsent(), + register: (consent) => FreeTier.registerAfterConsent(consent), onUnexpectedError: (error) => console.error("[altimate-base] registration failed", error), }) // altimate_change end diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index ebb708ed61..95fedc9717 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -2131,17 +2131,12 @@ export namespace Provider { ) } - // altimate_change start — exact legacy-default matcher for the Big Pickle migration + // altimate_change start — discard malformed persisted model references before use function isModelReference(model: unknown): model is { providerID: ProviderID; modelID: ModelID } { if (!model || typeof model !== "object") return false const value = model as Record return typeof value.providerID === "string" && typeof value.modelID === "string" } - - export function isLegacyBigPickle(model: unknown) { - if (!isModelReference(model)) return false - return model.providerID === "opencode" && model.modelID === "big-pickle" - } // altimate_change end export async function defaultModel() { @@ -2167,13 +2162,11 @@ export namespace Provider { .then((x) => (Array.isArray(x.recent) ? x.recent.filter(isModelReference) : [])) .catch(() => [])) as { providerID: ProviderID; modelID: ModelID }[] for (const entry of recent) { - // Existing implicit Big Pickle users move to Altimate Base once they have accepted the - // disclosure and the managed provider is available. Until then, keep the old selection so - // headless commands do not silently register, transmit prompts, or lose their only model. - if (isLegacyBigPickle(entry) && registeredBaseAvailable) { - log.info("migrating legacy opencode/big-pickle default to altimate-free/altimate-base") - return { providerID: baseProviderID, modelID: baseModelID } - } + // 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 + // `migrateLegacyDefault()` rewrites model.json on accept, so headless follows on the next + // launch. Migrating here instead would move a declining user to the request-logging tier + // with no prompt and no way to refuse. const provider = providers[entry.providerID] if (!provider) continue if (!provider.models[entry.modelID]) continue @@ -2203,22 +2196,28 @@ export namespace Provider { // altimate_change end // altimate_change start — select registered Altimate Base and never select Big Pickle implicitly - // Once the user has accepted the disclosure and registered, Altimate Base owns the free - // fallback role that used to belong to Big Pickle. A project provider block cannot force the - // managed model; an explicit `model` setting above remains authoritative. - if (registeredBaseAvailable) { - log.info("defaulting to altimate-free/altimate-base (no model configured)") - return { providerID: baseProviderID, modelID: baseModelID } - } - - const candidates = Object.values(providers).filter((provider) => providerAllowed(provider.id)) - if (candidates.length === 0) throw new Error("no providers found") + // Altimate Base owns the free fallback role that used to belong to Big Pickle, but only as a + // LAST resort. Anything the user has actually connected outranks the request-logging tier, 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( + (provider) => provider.id !== FreeTier.PROVIDER_ID && providerAllowed(provider.id), + ) + if (candidates.length === 0 && !registeredBaseAvailable) throw new Error("no providers found") for (const provider of candidates) { const model = sort(Object.values(provider.models)).find( (candidate) => !(provider.id === "opencode" && candidate.id === "big-pickle"), ) if (model) return { providerID: provider.id, modelID: model.id } } + + if (registeredBaseAvailable) { + log.info("defaulting to altimate-free/altimate-base (no other connected model)") + return { providerID: baseProviderID, modelID: baseModelID } + } throw new Error("no models found") // altimate_change end } diff --git a/packages/opencode/test/acp/default-model.test.ts b/packages/opencode/test/acp/default-model.test.ts index 1b8e9261c1..701628f5ee 100644 --- a/packages/opencode/test/acp/default-model.test.ts +++ b/packages/opencode/test/acp/default-model.test.ts @@ -144,6 +144,33 @@ describe("ACP defaultModelFromConfig", () => { expect(result?.providerID).toBe(ProviderV2.ID.make("opencode")) }) + test("a connected paid provider outranks registered Altimate Base", () => { + // Base logs requests, so it must never win over something the user actually connected. ACP has + // no recent-model list, so without this ordering a registered user with an Anthropic key would + // silently route every new session to the free logging tier. + const result = ACPService.defaultModelFromConfig( + undefined, + providers(provider("altimate-free", ["altimate-base"]), provider("anthropic", ["claude-sonnet-4"])), + undefined, + ) + expect(result).toEqual({ + providerID: ProviderV2.ID.make("anthropic"), + modelID: ModelV2.ID.make("claude-sonnet-4"), + }) + }) + + test("falls back to Altimate Base when nothing else is connected", () => { + const result = ACPService.defaultModelFromConfig( + undefined, + providers(provider("altimate-free", ["altimate-base"]), provider("opencode", ["big-pickle"])), + undefined, + ) + expect(result).toEqual({ + providerID: ProviderV2.ID.make("altimate-free"), + modelID: ModelV2.ID.make("altimate-base"), + }) + }) + test("does not recover an excluded managed provider through the sorted fallback", () => { const result = ACPService.defaultModelFromConfig( undefined, diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 6061a60fff..ed8db2c47a 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -373,6 +373,35 @@ describe("ACP service sessions", () => { expect(creates).toHaveLength(0) }) + it("keeps unrelated providers advertised when a provider block names only one of them", async () => { + // `config.provider` is a customization map — the docs show single-entry blocks setting apiKey + // or options. Treating it as a catalogue-wide allowlist hid every other authenticated provider + // from ACP clients and invalidated restored sessions pinned to them. + const other = { + ...provider, + id: ProviderID.make("anthropic"), + name: "Anthropic", + models: { + [ModelID.make("claude-sonnet-4")]: { + ...provider.models[modelID], + id: ModelID.make("claude-sonnet-4"), + providerID: ProviderID.make("anthropic"), + name: "Claude Sonnet 4", + }, + }, + } satisfies Provider.Info + const { service } = makeService([], { + providers: [provider, other], + providerConfig: { test: {} }, + }) + + const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + const models = flattenSelectOptions(select(result, "model")) + + expect(models.some((option) => option.value.includes("test-model"))).toBe(true) + 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 () => { const baseProvider = { ...provider, diff --git a/packages/opencode/test/altimate/altimate-base.test.ts b/packages/opencode/test/altimate/altimate-base.test.ts index 131b4483f8..607a1f9361 100644 --- a/packages/opencode/test/altimate/altimate-base.test.ts +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -72,6 +72,15 @@ afterAll(() => { fs.rmSync(temporaryHome, { recursive: true, force: true }) }) +// A test double for the TUI host: arms a fresh one-shot capability and hands registration the +// proof it now requires. Registration cannot run without one, which is the property under test. +function consented() { + const capability = new FreeTierConsent.ConsentCapabilityStore() + const token = "a".repeat(64) + capability.arm(token) + return { capability, token } +} + describe("gateway configuration", () => { test("requires source-mode configuration and prefers the new override", () => { delete process.env.ALTIMATE_BASE_GATEWAY_URL @@ -109,7 +118,7 @@ describe("registration", () => { return json(REGISTERED) }) - const result = await FreeTier.registerAfterConsent() + const result = await FreeTier.registerAfterConsent(consented()) const sentHash = String(requestBody?.install_secret_hash) expect(sentHash).toMatch(/^[0-9a-f]{64}$/) expect(sentHash).toBe(createHash("sha256").update(result.installSecret).digest("hex")) @@ -123,27 +132,56 @@ 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 capability = new FreeTierConsent.ConsentCapabilityStore() + const forged = "f".repeat(64) + + // A caller holding a store but no armed token 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({ capability, token: forged })).rejects.toBeInstanceOf( + FreeTier.RegistrationError, + ) + expect(gatewayCalls).toBe(0) + expect(await FreeTierStore.read()).toBeUndefined() + + capability.arm(forged) + const result = await FreeTier.registerAfterConsent({ capability, token: forged }) + 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({ capability, token: forged })).rejects.toBeInstanceOf( + FreeTier.RegistrationError, + ) + expect(gatewayCalls).toBe(1) + }) + 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()).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.registerAfterConsent(consented())).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()).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.registerAfterConsent(consented())).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()).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.registerAfterConsent(consented())).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()).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) expect(await FreeTier.isRegistered()).toBe(false) }) @@ -161,7 +199,7 @@ describe("registration", () => { return json(REGISTERED) }) - const result = await FreeTier.registerAfterConsent() + const result = await FreeTier.registerAfterConsent(consented()) expect(result.apiKey).toBe(REGISTERED.api_key) expect(calls).toBe(0) }) @@ -172,7 +210,7 @@ describe("registration", () => { mockFetch(() => json(REGISTERED)) await expect(FreeTier.credentialsForLoad()).rejects.toBeInstanceOf(FreeTierStore.InvalidCredentialStoreError) - const result = await FreeTier.registerAfterConsent() + const result = await FreeTier.registerAfterConsent(consented()) expect(result.apiKey).toBe(REGISTERED.api_key) expect(await FreeTier.credentials()).toEqual(result) }) @@ -183,7 +221,7 @@ describe("registration", () => { firstHash = String(JSON.parse(String(init?.body)).install_secret_hash) throw new Error("connection reset") }) - await expect(FreeTier.registerAfterConsent()).rejects.toBeInstanceOf(FreeTier.RegistrationError) + await expect(FreeTier.registerAfterConsent(consented())).rejects.toBeInstanceOf(FreeTier.RegistrationError) fetchSpy?.mockRestore() let secondHash = "" @@ -191,7 +229,7 @@ describe("registration", () => { secondHash = String(JSON.parse(String(init?.body)).install_secret_hash) return json(REGISTERED) }) - await FreeTier.registerAfterConsent() + await FreeTier.registerAfterConsent(consented()) expect(secondHash).toBe(firstHash) }) @@ -199,13 +237,13 @@ describe("registration", () => { mockFetch(() => { throw new Error("connection reset") }) - const network = await FreeTier.registerAfterConsent().catch((error) => error) + const network = await FreeTier.registerAfterConsent(consented()).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().catch((error) => error) + const response = await FreeTier.registerAfterConsent(consented()).catch((error) => error) expect(response).toBeInstanceOf(FreeTier.RegistrationError) expect(response.kind).toBe("response") expect(response.status).toBeUndefined() @@ -232,7 +270,7 @@ describe("registration", () => { }) }) - const pending = FreeTier.registerAfterConsent({ signal: controller.signal }) + const pending = FreeTier.registerAfterConsent(consented(), { signal: controller.signal }) await requestStarted controller.abort() @@ -281,7 +319,7 @@ describe("registration", () => { baselineRead() return value }) - const pending = FreeTier.registerAfterConsent() + const pending = FreeTier.registerAfterConsent(consented()) await baselineObserved readSpy.mockRestore() @@ -443,6 +481,45 @@ describe("inference boundary", () => { expect(authorizations).toEqual([`Bearer ${REGISTERED.api_key}`, "Bearer sk-altimate-base-rotated"]) }) + test("a single 401 does not disown the credential on disk", async () => { + // Distinct key per test: the consecutive-401 counter is keyed by credential fingerprint and + // is module state, so reusing REGISTERED.api_key would inherit counts from earlier tests. + await seed({ apiKey: "sk-401-single" }) + mockFetch(() => new Response("", { status: 401 })) + const response = await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { + method: "POST", + body: "{}", + }) + expect(response.status).toBe(401) + // Blocked for this process, but a relaunch must retry: one 401 can be a gateway deploy or + // key-propagation skew, and persisting it would force every user back through the disclosure. + expect((await FreeTierStore.read())?.rejected).toBeUndefined() + }) + + test("consecutive 401s do disown the credential on disk", async () => { + await seed({ apiKey: "sk-401-consecutive" }) + mockFetch(() => new Response("", { status: 401 })) + for (let attempt = 0; attempt < 2; attempt++) { + await FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { method: "POST", body: "{}" }) + } + expect((await FreeTierStore.read())?.rejected).toBe(true) + }) + + test("a success between 401s resets the consecutive count", async () => { + await seed({ apiKey: "sk-401-reset" }) + let status = 401 + mockFetch(() => new Response("{}", { status })) + const call = () => + FreeTier.authorizedFetch(`${REGISTERED.base_url}/v1/chat/completions`, { method: "POST", body: "{}" }) + + await call() + status = 200 + await call() + status = 401 + await call() + expect((await FreeTierStore.read())?.rejected).toBeUndefined() + }) + test("a 401 never triggers background registration", async () => { await seed() const urls: string[] = [] @@ -510,7 +587,7 @@ describe("inference boundary", () => { }) expect(rejected.status).toBe(401) - const rotated = await FreeTier.registerAfterConsent() + const rotated = await FreeTier.registerAfterConsent(consented()) expect(rotated.apiKey).toBe("sk-altimate-base-rotated") expect(urls).toEqual([`${REGISTERED.base_url}/v1/chat/completions`, `${REGISTERED.base_url}/register`]) }) @@ -521,8 +598,11 @@ describe("consent boundary", () => { const first = "a".repeat(64) const second = "b".repeat(64) let registrations = 0 + // Mirrors production: the registration operation itself consumes the one-shot proof, so this + // asserts the real gating path rather than a stub that trusts its caller. const gate = FreeTierConsent.createRegistrationConsentGate({ - register: async () => { + register: async ({ capability, token }) => { + if (!capability.consume(token)) throw new FreeTier.RegistrationError("consent expired", "cancelled") registrations++ }, }) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 3290b0cfd0..927c3bbb51 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -183,7 +183,7 @@ test("an Altimate Base-only provider block cannot select an unrelated provider", } }) -test("an empty provider object remains unrestricted for Altimate Base", async () => { +test("a connected provider outranks registered Altimate Base as the implicit default", async () => { const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ apiKey: "sk-altimate-base", baseURL: ALTIMATE_BASE_GATEWAY_URL, @@ -194,11 +194,14 @@ test("an empty provider object remains unrestricted for Altimate Base", async () await provideProviderTestInstance({ directory: tmp.path, fn: async () => { + // Altimate Base logs requests, so it is only ever the LAST resort. Anything the user has + // actually connected wins, and `provider: {}` still does not act as an allowlist. const model = await Provider.defaultModel() - expect(model).toEqual({ + expect(model).not.toEqual({ providerID: ProviderID.make(FreeTier.PROVIDER_ID), modelID: ModelID.make(FreeTier.MODEL_ID), }) + expect(model.providerID).toBe(ProviderID.make("opencode")) }, }) } finally { @@ -206,7 +209,7 @@ test("an empty provider object remains unrestricted for Altimate Base", async () } }) -test("a registered Altimate Base replaces a persisted Big Pickle default", async () => { +test("a persisted Big Pickle default is not silently migrated headlessly", async () => { const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ apiKey: "sk-altimate-base", baseURL: ALTIMATE_BASE_GATEWAY_URL, @@ -221,9 +224,11 @@ test("a registered Altimate Base replaces a persisted Big Pickle default", 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(FreeTier.PROVIDER_ID), - modelID: ModelID.make(FreeTier.MODEL_ID), + providerID: ProviderID.make("opencode"), + modelID: ModelID.make("big-pickle"), }) }, }) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 679e0148d1..f19d5674b8 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -606,6 +606,14 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi return } + // A previous decline is checked FIRST, before registration state. Registering Altimate Base + // for one task is not consent to move a Big Pickle default that the user already refused to + // move; without this the decline is silently overridden on every later launch. + if (kv.get(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false)) { + legacyModelMigrationHandled = true + return + } + const altimateBaseAvailable = sync.data.provider.some( (provider) => provider.id === "altimate-free" && Boolean(provider.models?.["altimate-base"]), ) @@ -615,7 +623,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi return } - if (!sdk.altimateBaseRegistration || kv.get(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false)) { + if (!sdk.altimateBaseRegistration) { legacyModelMigrationHandled = true return } diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index 6319713426..9dd9ae0423 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -343,7 +343,7 @@ export function DialogModelWelcome(props: { } export const ALTIMATE_BASE_DISCLOSURE = - "Altimate Base is free and requires no signup. Requests and responses are logged and may be used to improve Altimate's products and services. Don't send secrets or confidential code. Usage is rate limited." + "Altimate Base is free and requires no signup. Requests and responses are logged and may be used to improve Altimate's products and services. Those logs are linked to a permanent identifier for this installation, which /providers logout does not reset. Don't send secrets or confidential code. Usage is rate limited." type RegisterOutcome = | { ok: true } @@ -408,6 +408,10 @@ export function DialogAltimateBaseConfirm(props: { onCleanup(() => { releaseCloseGuard() disposed = true + // Escape and click-away are handled by DialogProvider and never reach no(), but they are just + // as much a refusal. Persisting the decline here too keeps a dismissed migration prompt from + // reappearing on every launch forever. + if (!decided && props.origin === "migration") props.onDecline?.() decided = true recordChoice("cancel") }) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index a02563209c..b94bf9e4fa 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -392,9 +392,9 @@ export function Prompt(props: PromptProps) { // Keep command line --agent if specified. if (!args.agent) local.agent.set(msg.agent) if (msg.model) { - // altimate_change start — old sessions inherit the new consented free default - const restored = local.model.restoreSession(msg.model) - if (restored?.providerID === msg.model.providerID && restored.modelID === msg.model.modelID) { + // 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) } // altimate_change end diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 4ccb37f47a..7b40ad0a5f 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -55,11 +55,6 @@ export function isLegacyBigPickleModel(model: unknown): model is ModelRef { return model.providerID === LEGACY_BIG_PICKLE_MODEL.providerID && model.modelID === LEGACY_BIG_PICKLE_MODEL.modelID } -export function migrateLegacySessionModel(model: T, altimateBaseAvailable: boolean): T | ModelRef { - if (!altimateBaseAvailable || !isLegacyBigPickleModel(model)) return model - return { ...ALTIMATE_BASE_MODEL } -} - export function isExistingBigPickleSelection(current: unknown, recent: readonly unknown[], explicit: boolean) { if (!isLegacyBigPickleModel(current)) return false return explicit || recent.some(isLegacyBigPickleModel) @@ -451,18 +446,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) return true }, + // Opening an old session restores the model that session was recorded with, verbatim. + // 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. restoreSession(model: ModelRef) { - const shouldMigrate = - !hasExplicitModel() && - allowsManagedBaseDefault(sync.data.config.provider) && - isModelValid(ALTIMATE_BASE_MODEL) - const selected = migrateLegacySessionModel(model, shouldMigrate) - if (!selectModel(selected)) return undefined - if (selected !== model) { - setModelStore("recent", migrateLegacyRecentModels(modelStore.recent)) - save() - } - return selected + if (!selectModel(model)) return undefined + return model }, // altimate_change end toggleFavorite(model: { providerID: string; modelID: string }) { diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index 7ead765a96..517bc0692c 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -5,7 +5,6 @@ import { isExistingBigPickleSelection, LEGACY_BIG_PICKLE_MODEL, migrateLegacyRecentModels, - migrateLegacySessionModel, parseModel, recentModels, shouldMigrateLegacyDefault, @@ -31,16 +30,6 @@ test("moves a model to the front, deduplicates, and limits recents", () => { ]) }) -test("migrates only the exact Big Pickle model when Altimate Base is available", () => { - expect(migrateLegacySessionModel(LEGACY_BIG_PICKLE_MODEL, true)).toEqual(ALTIMATE_BASE_MODEL) - expect(migrateLegacySessionModel(LEGACY_BIG_PICKLE_MODEL, false)).toBe(LEGACY_BIG_PICKLE_MODEL) - - const unrelated = { providerID: "opencode", modelID: "gpt-5" } - expect(migrateLegacySessionModel(unrelated, true)).toBe(unrelated) - const sameIDOtherProvider = { providerID: "custom", modelID: "big-pickle" } - expect(migrateLegacySessionModel(sameIDOtherProvider, true)).toBe(sameIDOtherProvider) -}) - test("distinguishes an existing Big Pickle user from a fresh catalogue fallback", () => { expect(isExistingBigPickleSelection(LEGACY_BIG_PICKLE_MODEL, [], false)).toBe(false) expect(isExistingBigPickleSelection(LEGACY_BIG_PICKLE_MODEL, [LEGACY_BIG_PICKLE_MODEL], false)).toBe(true) diff --git a/research/altimate-base-release-2026-08-30/README.md b/research/altimate-base-release-2026-08-30/README.md new file mode 100644 index 0000000000..5c77ab3c47 --- /dev/null +++ b/research/altimate-base-release-2026-08-30/README.md @@ -0,0 +1,17 @@ +# Altimate Base release — 2026-08-30 + +The release-readiness report, the security review snapshot, and the security fix verification for +this release are **not** kept in this repository. + +They describe gateway deployment topology, secret storage, service-account scoping, rollback +procedure, and incident response for infrastructure that lives outside this repo. This repository +is public, so those documents belong in the internal research vault instead: + + Research/Altimate Base Release 2026-08-30/ + +What is publicly documented about Altimate Base lives where users will actually look for it: + +- `docs/docs/configure/providers.md` — setup, gateway configuration, credential storage +- `docs/docs/reference/security-faq.md` — what is sent, what is logged, what is retained +- `docs/docs/reference/network.md` — outbound destinations +- `docs/docs/reference/telemetry.md` — onboarding events diff --git a/research/altimate-base-release-2026-08-30/release-readiness.md b/research/altimate-base-release-2026-08-30/release-readiness.md deleted file mode 100644 index 7891a4b882..0000000000 --- a/research/altimate-base-release-2026-08-30/release-readiness.md +++ /dev/null @@ -1,161 +0,0 @@ -# Altimate Base release readiness - -Date: 2026-08-30 -Scope: hosted Qwen 3.8 release, free-tier gateway, Altimate Base client integration, and harness reliability work - -## Release decision - -The implementation is code-complete and exact gateway commit `ba6d7ad` is deployed on the live public gateway. The public acceptance suite, forced streamed tool call, both serving tiers, autoscaler transitions, accounting signal, and abuse controls have been exercised against the deployed system. All observed checks on the three release PRs are green. It is not yet a completed client release: the PRs still need human approval and merge, and the client artifact must be built and published from the approved release commit. - -The intended public contract is now stable: - -- `altimate-base` is the implicit managed default for users who have not configured a model. -- Big Pickle is no longer an implicit default. It remains available only when a user explicitly selects or persists that legacy model. -- The public alias is insulated from the hosted implementation. The gateway currently maps `altimate-base` to the Qwen 3.8 deployment, so a future backend change does not require a client release. -- Registration and inference remain consented, metered, rate-limited, and fail-closed. - -## Pull-request map and release order - -| Repository | PR | Purpose | Current disposition | -| --- | --- | --- | --- | -| `AltimateAI/altimate-gateway` | #1 | Free-tier issuer, accounting, abuse controls, routing, autoscaler, deployment safety, and configurable bootstrap URL | Release branch pushed through `ba6d7ad`; tests, shell/config, environment hygiene, secret scan, and Kilo review are green; human approval required | -| `AltimateAI/altimate-code` | #1199 | Altimate Base provider, consent flow, managed credentials, default-model replacement, ACP/session persistence, and safety tests | Synced with current main; all observed hosted checks and reviews are green; human approval required | -| `AltimateAI/altimate-code` | #1171 | Harness/session reliability improvements developed alongside the model work | All observed hosted checks and reviews are green; human approval required | -| `AltimateAI/altimate-code` | #1115 | Earlier free Gemini experiment | Superseded by #1199; branch is conflicting and its secret scan is failing | - -Recommended order: - -1. Completed: roll gateway commit `ba6d7ad` to the live gateway and repeat the public, tier, autoscaler, and abuse-control checks. -2. Approve and merge gateway PR #1. -3. Approve and merge client PR #1199, then build the release artifact with the official gateway endpoint embedded. -4. Merge harness PR #1171 independently once its approval is available. -5. Close #1115 as superseded only after an owner explicitly confirms that action. - -## Client behavior - -Altimate Base is isolated behind provider ID `altimate-free` and model ID `altimate-base`. The managed credential never enters public provider metadata: provider discovery receives a placeholder while inference injects the real credential only at the authorized transport boundary. - -Registration can occur only after the TUI disclosure is accepted. A worker-local, one-shot capability crosses the private parent/worker RPC boundary; it is bounded, expires quickly, and is consumed exactly once. Provider discovery, startup, rejected inference, and session restoration do not register silently. - -The default-selection rules are deliberately narrow: - -- An install with no usable configured model offers Altimate Base and shows the disclosure before registration. -- Declining the disclosure performs no registration and leaves setup recoverable. -- An explicitly configured Big Pickle session continues to load, fork, and resume as Big Pickle. -- A persisted Altimate Base session continues to load, fork, and resume as Altimate Base. -- Other providers retain their existing selection, headers, credentials, and runtime paths. - -Altimate Base can also be disconnected through the normal `providers logout` flow. The command recognizes both the internal provider ID and the public `altimate-base` name, removes every usable gateway credential field, cleans only the reserved legacy Base auth entry, and leaves all unrelated provider credentials unchanged. It intentionally retains the opaque local install identity used to enforce the free allowance; an install-secret-only record neither activates the model nor appears as a connected credential. An explicit Base logout can still revoke that partial state and rotate its nonce, which prevents a pending first registration from reconnecting after logout. A malformed or truncated Base record is recoverably replaced with disconnected state; genuine permission or I/O errors still fail visibly. - -ACP validates a configured provider/model pair against the available provider snapshot before using it as the default. An unavailable pair cannot be persisted into a new session; if no supported fallback remains after excluding Big Pickle from implicit selection, ACP returns the typed no-supported-model error before session creation. Valid explicit model choices, including Big Pickle, remain supported. - -The registration dialog cannot be dismissed or replaced while registration is active. Escape, Ctrl-C, backdrop clicks, and programmatic replacement all honor the same close guard, without discarding a selection when a close is vetoed. - -## Configurable gateway URL - -The client and bootstrap tools do not depend on a permanently fixed source URL. - -Client URL precedence is: - -1. `ALTIMATE_BASE_GATEWAY_URL` -2. legacy `ALTIMATE_FREE_GATEWAY_URL` -3. the release-time embedded official endpoint - -Source builds intentionally fail closed when none is configured. Accepted endpoints must use HTTPS, cannot contain user information, query strings, or fragments, and are normalized without a trailing slash. Registration responses are rejected if they attempt to redirect the managed credential to a different origin or path. - -The standalone gateway bootstrap uses `ALTIMATE_BASE_GATEWAY_URL`, with `ALTIMATE_GATEWAY_URL` retained as a compatibility fallback. Its cached credential now records the registration endpoint separately from the returned inference base. This matters for local and future topologies where `/register` and `/v1` are served on different ports or origins; rerunning the bootstrap no longer rotates a valid key merely because those endpoints differ. - -## Gateway and abuse controls - -The live gateway uses a public alias, a per-install registration secret hash, short-lived managed keys, a lifetime wallet allowance, request and registration rate limits, a kill switch, and an issuer-owned accounting ledger. Unsupported models and endpoints are denied. - -Release hardening completed during this pass includes: - -- The serving API key moved out of instance metadata into Google Secret Manager. The attached VM service account has only the required secret access, and the actual key is absent from startup metadata. -- A legacy `llama-server.service` was found recreating an unauthenticated container on the old port. The service was disabled on the live WARM VM, and tier startup now removes only the explicitly configured legacy service/container names. -- The bundled registration proxy was initially configured with zero trusted hops, which would have collapsed all public clients into one rate-limit bucket. The live proxy trust topology was corrected and the preflight now rejects that unsafe configuration. -- A master credential present in the Claude trace was rotated. The trace value is revoked and the replacement was rolled across gateway services. -- Billing reconciliation now waits on the same advisory lock as the usage-debit pipeline, eliminating false drift reports between spend capture and ledger materialization. -- Autoscaler schedule edits are validated before becoming the last-good configuration. Invalid time zones, weekdays, time windows, and alert cooldowns are rejected. -- Repeated thrash, budget-refusal, and wake-timeout alerts use a persisted per-event cooldown instead of posting once per poll. -- Monthly GPU spend is written to a redundant atomic checkpoint. If the main state file is missing or corrupt, spend is recovered; if neither copy is trustworthy, new scale-ups are refused. -- Rolling deployment now mounts the generated upstream directory rather than an individual file, so an atomic rename cannot leave Nginx pinned to a stale inode. The live repair exposed a roughly 1.3-second health-check gap while the stable proxy was recreated; the permanent path uses graceful reloads and the route verifier rejects a stale bind mount. -- BURST Flex-start creation now supplies the required delete-at-expiry action, termination maintenance policy, maximum run duration, and bounded request-validity window. The live allocation was observed as Flex-start with the expected two GPUs and deletion contract. -- SGLang's lifetime-cumulative TTFT histogram is checkpointed and converted to per-poll deltas. A single slow WARM cold-start sample had previously left the cumulative p50 in the 40-second bucket and prevented BURST teardown indefinitely; the deployed controller now treats an unchanged histogram as no fresh TTFT signal. -- The Postgres arrivals source no longer targets an ephemeral container address. Compose publishes the database strictly on loopback for the host autoscaler, and the scaler DSN targets that stable loopback listener. The live source changed from connection-refused to `arrivals_known=true` without exposing Postgres on the public or VPC interfaces. -- Exact commit `ba6d7ad` was copied from a clean Git archive, checksum-verified against the local release tree, and marked only after the database, controller, and watchdog recovered. The deployment preserved the secret environment file and rolling state; its temporary secret-bearing backup was securely removed after success, while exact source archives remain available for code rollback. - -These controls are scoped to the Altimate Base gateway and its named serving tiers. They do not change routing or credentials for other client models. - -## Verification evidence - -### Live public path - -The live serving path passed the end-to-end smoke suite 8/8 during the release rollout: - -- first registration -- same-install key rotation -- real streamed Qwen completion -- terminal usage and stream completion marker -- denied unsupported model -- denied embeddings endpoint -- denied unauthenticated inference -- correct registration response shape - -A separate forced streamed-tool-call request through the public `altimate-base` alias emitted the requested function with valid structured JSON arguments across 24 stream events, emitted one terminal marker, and finished with `tool_calls`. Its temporary key was revoked and then rejected. The release script itself also required a real completion from the candidate color before switching traffic. - -WARM and BURST each returned exactly `qwen3.8-27b` from authenticated direct model inventory. Both ran the exact pinned serving image with the expected GPU count, `on-failure:5`, zero container restarts, no legacy container, and the legacy service disabled or inactive. BURST then carried public completion traffic before WARM was refreshed and verified. - -A long proxy-log window contained successful completions, the expected authentication/policy denials, and two client-aborted requests, with no gateway 5xx responses. A replacement continuity verifier produced ten fully valid accepted completions and two correct per-key 429 responses; there were no JSON, usage, or terminal-result failures among accepted requests. After the final autoscaler-only deployment, public liveness returned 200 without redirect. A new registration was correctly rejected with 429 because the configured registration window was already full; the limiter was respected rather than bypassed. - -The final live autoscaler proof is signal-driven, not merely configured: queue and streams were zero on three direct samples from both tiers, TTFT became unknown when the cumulative histogram did not advance, the BURST idle counter began accumulating, and Postgres arrivals were known. A temporary operator `force warm` was used only after those quiescence checks to avoid another 30 minutes of H100 cost; BURST was deleted, the override was cleared, and the checked-in configuration checksum was restored exactly. The final state is WARM with no BURST coordinates or cleanup latch, and both the service and watchdog timer are active. - -### Gateway repository - -- Complete pytest suite: green, with only the expected integration skips. -- New TTFT-delta, counter-reset, multi-series, and loopback-only Postgres contract regressions: green. -- CI-pinned Ruff 0.9.10: green for `issuer` and the autoscaler files changed in the final fix. -- ShellCheck and Bash syntax checks: green for both bootstrap copies. -- Compose renders successfully with the example environment, `git diff --check` is clean, and local Gitleaks reports no leaks. -- Both copies of the bootstrap script are byte-identical. -- Gateway PR #1 hosted checks are green: tests, shell/config, environment hygiene, secrets, and Kilo review. - -### Client repository - -- Altimate Base transport, registration, consent, and credential tests: 28 green. -- Altimate Base TUI disclosure and close-guard tests: 6 green. -- Earlier full focused client run: 37 green with one intentional todo. -- Complete security diff review: 43 changed source files reviewed. It found two medium-severity regressions caused by the branch being behind main, not by the Base provider changes. -- The mainline Windows path/TLS fixes were merged. Follow-up variant checks closed Yarn unplugged and symlink-alias MCP discovery, dbt-clickhouse `secure: true` enforcement, and serialized `secure: "false"` parsing at the driver boundary. -- Post-fix focused verification: 342 tests green across glob pruning, MCP/Datamate discovery, ClickHouse transport, dbt import, and normalization; one existing MCP todo remains. -- Broader compatibility verification: 144 driver/SDK resolver tests and 166 MCP lifecycle/discovery tests green, plus all changed Base/model suites and 6 TUI consent tests green. -- Root typecheck: 13/13 workspaces green after the final security edits. -- Final release-review regressions: locale-independent MCP ordering passed 23/23 discovery tests; Datamate passed 15 tests with one existing todo; the real provider-logout CLI test passed all 12 security, recovery, and isolation assertions; a cross-process lock-order simulation proves stale registration is cancelled before gateway access; unavailable ACP configured-model tests pass at both function and session boundaries; the package typecheck remained green. -- Local pre-release build with a non-production HTTPS endpoint: built successfully and the compiled binary passed its startup smoke check. -- Branding, changed-file marker, and strict required-marker audits: green after the final report was added. -- PR #1199 hosted TypeScript, driver E2E, Windows ripgrep, compliance, standards, anti-slop, marker, secret, and automated-review checks are green; intentionally out-of-scope jobs are skipped rather than failed. - -The historical scan body is preserved in `security-review-snapshot.md` with a prominent resolved-status banner; it describes the stale scanned head. The current remediation state and proof are in `security-fix-verification.md`. - -### Harness repository - -- Focused harness regression set: 53 green. -- Earlier expanded harness set: 219 green. -- Root typecheck: green. -- Strict fork-marker analysis: green. -- Full repository run completed with 11,998 passes; the seven failures were pre-existing MCP contamination/timeouts outside the harness diff. -- PR #1171 hosted TypeScript, Windows ripgrep, compliance, standards, anti-slop, marker, secret, and automated-review checks are green; intentionally out-of-scope jobs are skipped rather than failed. - -## Rollback and operational safety - -Gateway deployment uses the checked-in blue/green rolling script. It validates configuration and database readiness, starts the inactive color, switches Nginx atomically, recreates the issuer/background jobs, verifies them, and restores both traffic and application services on failure. Live rolls exercised this path repeatedly, including after credential rotation. The final autoscaler-only archive deployment used an explicit prior-archive rollback trap, preserved the live environment and rolling directory, and did not rebuild or reroute the unchanged inference proxy. - -If the client release must be rolled back, users with explicit models remain unaffected. The prior client artifact can be restored without changing the gateway alias. If the hosted backend must change, update the gateway mapping behind `altimate-base`; do not change the public model ID. - -Exact source archives are retained for rollback. Temporary copies of secret environment data created during the final deployment were securely removed. No raw gateway key, master key, endpoint, infrastructure identity, or trace credential is recorded in this document. - -## Remaining release actions - -- Obtain the required human approvals on #1, #1199, and #1171. -- Merge in the order above and publish the client artifact with the official endpoint embedded. -- Observe registration 429s, wallet debits/high-water progress, backend health, and autoscaler decisions during the initial release window. diff --git a/research/altimate-base-release-2026-08-30/security-fix-verification.md b/research/altimate-base-release-2026-08-30/security-fix-verification.md deleted file mode 100644 index 668c1a855b..0000000000 --- a/research/altimate-base-release-2026-08-30/security-fix-verification.md +++ /dev/null @@ -1,129 +0,0 @@ -# Altimate Base security fix verification - -Date: 2026-08-30 -Branch: `codex/altimate-base-release-final` -Security scan: `c6abd4b7-e12a-4d69-8b88-0dc1e57dabf7` - -## Outcome - -The two medium-severity findings reported against stale client head `04fceafcf8` are fixed on the release branch. The branch first merged the current mainline implementations for ClickHouse TLS enforcement and dependency-tree pruning, then closed two variants found during the required post-fix review: - -1. Yarn Plug'n'Play extracts some installed packages under `.yarn/unplugged`; those trees are now pruned as dependencies. -2. An authored-looking MCP path could be a symlink to a dependency or an out-of-project file; every candidate is now resolved canonically and checked against the project boundary and ignore policy before parsing. -3. dbt-clickhouse expresses TLS intent as `secure: true`; the ClickHouse driver now treats `secure`, `tls`, and `ssl` identically at the final transport boundary. - -No Altimate Base model, provider, credential, or routing code was changed by the two original scan remediations. The final release-review follow-ups below add explicit managed-provider disconnect behavior and reject unavailable ACP configuration; valid configured models and inference behavior are unchanged. - -## Gateway release verification - -Gateway commit `ba6d7ad` is deployed on the live public gateway. This is a separate repository and deployment boundary from the two historical client findings, but it was rechecked before declaring the combined release ready: - -- The complete gateway pytest suite passed with only expected integration skips; CI-pinned Ruff, Compose rendering, diff hygiene, and local Gitleaks passed. -- Hosted PR #1 checks passed for tests, shell/config, environment hygiene, secret scanning, and Kilo review. -- Public acceptance previously passed 8/8, and a forced streamed function call completed with valid JSON arguments, a terminal marker, and `tool_calls`; its temporary key was revoked and rejected afterward. -- WARM and BURST both served only the expected Qwen model from the exact pinned image, with authenticated direct inventory, expected GPU counts, and no container restarts. -- Flex-start BURST was live-observed with bounded request validity, maximum runtime, terminate maintenance policy, and delete-at-expiry behavior. -- The final autoscaler fix derives TTFT from new Prometheus histogram observations rather than lifetime-cumulative counters. Live TTFT became unknown when counters did not move, and the BURST idle counter advanced instead of remaining pinned by one historical slow request. -- The Postgres arrivals query now uses a loopback-only host bind instead of an ephemeral container address. Live `arrivals_known` is true, while the database remains unbound from public and VPC interfaces. -- After three zero-queue/zero-stream samples on both tiers, BURST was drained and deleted. The temporary override was cleared, the checked-in configuration checksum was restored exactly, and the service/watchdog remain active in WARM with no cleanup latch. - -No raw endpoint, serving key, gateway master key, infrastructure identity, or trace credential is included in this evidence. - -## Final release-review follow-ups - -The review of the remediated branch found ten additional release-quality issues, all now closed in the current tree: - -1. MCP file ordering used `localeCompare`, which could make the first Datamate transport differ by host locale. Discovery now uses a locale-independent code-unit comparison and has a regression using paths whose relative order differs under common locales. -2. The new discovery helper did not expose the repository-required namespace projection. It now exports `DiscoveryFiles`, and both MCP discovery and Datamate consume that projection. -3. The managed Altimate Base credential lived outside the shared provider auth store, so the normal `providers logout` command could not disconnect it. The command now lists a complete managed credential, accepts `altimate-free`, `altimate-base`, or `Altimate Base`, strips its API key, gateway URL, expiry, and rejection marker, and removes only the reserved stale shared-auth entry. Ordinary provider logout keeps the previous path and does not touch Base. -4. A first logout implementation also removed the install secret, which would have made the supported CLI flow a way to mint a fresh free-allowance principal. The final behavior retains only that opaque local identity. An install-secret-only record is deliberately not listed as connected and cannot activate the model; registration sends only its SHA-256 hash. -5. Serialized dbt/Jinja values such as `secure: "false"` were truthy in JavaScript and could force HTTPS unexpectedly. The driver now parses common disabled strings case-insensitively while treating enabled or unrecognized non-empty values as secure intent, preserving the fail-closed TLS boundary. -6. A registration waiting on the file lock could acquire it after logout and recreate usable credentials. Each consented registration now captures a persisted logout nonce before waiting and rechecks it after acquiring the lock. Logout rotates the nonce while stripping credentials, so any registration that predates it aborts before gateway access regardless of cross-process lock order. -7. A malformed or truncated managed credential made CLI logout fail before it could disconnect the provider. Logout now distinguishes schema corruption from real filesystem errors: invalid content is atomically replaced with a fresh install identity and logout nonce, while permission and I/O failures still propagate. -8. An install-secret-only record was correctly hidden from the interactive connected-provider list, but that also made an explicit Base logout unable to revoke setup while first registration was pending. Explicit `altimate-free`, `altimate-base`, and `Altimate Base` requests now detect valid partial state and rotate its logout nonce; partial state remains absent from the interactive list. -9. ACP could fall back to a configured provider/model pair that was not present after provider discovery, including when Big Pickle was the only advertised model. Unavailable configured pairs are now rejected, producing the existing typed no-supported-model error before a session is created. Valid explicitly configured models, including Big Pickle, retain their existing behavior. -10. The consent boundary imported Base error classes directly, bypassing the package's required ESM namespace projection. It now consumes `FreeTier.RegistrationError` and `FreeTier.ConfigurationError` through the public `FreeTier` namespace without changing runtime behavior. - -The logout behavior is covered through the real CLI entrypoint in both directions: Base logout reduces a complete credential to the same install-secret-only identity while preserving an unrelated provider credential, a repeated explicit Base logout rotates the nonce of that partial state, ordinary provider logout preserves the byte-identical complete Base credential record, and malformed Base state is repaired into non-connected state. A separate lock-order regression models another process winning logout after registration captured the old generation; the stale registration is cancelled without calling the gateway and the disconnected record remains unchanged. - -## Security invariants - -### MCP and Datamate discovery - -Only authored project configuration may contribute executable MCP commands. A discovery path must satisfy both checks: - -- its visible path is inside the project and outside dependency/build trees; and -- its canonical target, after resolving symlinks, is inside the project and outside those trees. - -The shared resolver is used by recursive `mcp.json` discovery, Datamate transport selection, and exact project-scoped `.mcp.json` / `.gemini/settings.json` sources. Home-scoped user configuration keeps its previous behavior. - -### ClickHouse transport - -Any enabled secure intent (`tls`, `ssl`, `secure`, or HTTPS protocol) must result in HTTPS or fail before the ClickHouse client receives credentials. Plain HTTP remains available only when no secure intent is present; serialized false-like flags (`false`, `0`, `no`, `off`, or blank) remain disabled. - -### Altimate Base disconnect - -Logout must retain the opaque install identity used for fair-use accounting while removing every usable credential field. It rotates a separate local logout nonce under the same cross-process lock used by registration. Registration captures that nonce before waiting and must observe the same value after it acquires the lock; a mismatch cancels setup before any network request. If corruption makes the prior identity untrustworthy, logout replaces it with fresh disconnected state rather than leaving an unusable credential file behind. - -## Reproduction proof - -The original and follow-up bypasses no longer reproduce: - -- `.yarn/unplugged/pkg/mcp.json` is absent from both general MCP discovery and Datamate transport selection. -- `.vscode/mcp.json` and `.mcp.json` symlinks targeting `node_modules/pkg/mcp.json` are rejected, while a real authored `.cursor/mcp.json` is still discovered. -- The existing adversarial test proving an in-project symlink to authored configuration remains green. -- `secure: true` produces `https://...:8443` by default. -- Serialized `tls`, `ssl`, and `secure` false values preserve the existing `http://...:8123` default, while serialized `secure: "true"` enables HTTPS. -- `secure: true` combined with an explicit `http://` connection string throws before the mocked client factory sees a new configuration. -- A configuration with no secure intent still produces the existing `http://...:8123` default. -- A registration carrying a pre-logout nonce aborts before `POST /register` after a logout nonce wins the file lock, and the install-secret-only disconnected state remains intact. -- An explicit Base logout against install-secret-only state succeeds and rotates its nonce without touching an unrelated provider; the same partial state is still not advertised as connected. -- A truncated managed credential can be logged out through the normal CLI and becomes a valid install-secret-and-nonce-only record. -- An unavailable configured ACP model is not persisted as a session default; when Big Pickle is the only remaining implicit option, session creation fails with the typed no-supported-model error before writing a session. - -## Files changed - -- `packages/core/src/util/glob.ts` -- `packages/core/test/util/glob.test.ts` -- `packages/opencode/src/mcp/discovery-files.ts` -- `packages/opencode/src/mcp/discover.ts` -- `packages/opencode/src/altimate/datamate-transport.ts` -- `packages/opencode/src/altimate/free/client.ts` -- `packages/opencode/src/altimate/free/consent.ts` -- `packages/opencode/src/altimate/free/store.ts` -- `packages/opencode/src/cli/cmd/providers.ts` -- `packages/opencode/test/mcp/discover.test.ts` -- `packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts` -- `packages/opencode/test/cli/providers-logout.test.ts` -- `packages/opencode/test/altimate/altimate-base.test.ts` -- `packages/opencode/src/acp/service.ts` -- `packages/opencode/test/acp/default-model.test.ts` -- `packages/opencode/test/acp/service-session.test.ts` -- `packages/drivers/src/clickhouse.ts` -- `packages/drivers/test/clickhouse-unit.test.ts` -- `packages/opencode/test/altimate/connections.test.ts` -- `packages/opencode/test/altimate/driver-normalize.test.ts` - -## Verification commands and results - -- `packages/core`: `bun test test/util/glob.test.ts` — 8 passed. -- `packages/opencode`: discovery and Datamate release tests — 37 passed, 1 existing todo. -- `packages/drivers`: ClickHouse unit tests — 78 passed. -- `packages/opencode`: dbt connection import and driver normalization — 219 passed. -- `packages/drivers`: ClickHouse plus optional-driver resolver compatibility — 144 passed. -- `packages/opencode`: expanded MCP discovery/config/lifecycle compatibility — 166 passed, 3 existing skips, 1 existing todo. -- Changed Altimate Base/provider/session/ACP/telemetry suites — green. -- `packages/tui`: Altimate Base dialog suite — 6 passed. -- Repository `bun run typecheck` — 13 of 13 tasks successful. -- `git diff --check` — clean. -- Final MCP discovery regression — 23 passed, including locale-independent ordering. -- Final Datamate release regression — 15 passed, 1 existing todo. Its home-directory isolation was corrected so developer MCP configuration cannot contaminate the result. -- Real CLI managed logout regression — 1 passed with 12 assertions covering Base credential stripping, install-identity retention, explicit partial-state revocation, interactive partial-state invisibility, malformed-state recovery, legacy Base cleanup, ordinary-provider preservation, and the inverse isolation check. -- Final Base/CLI/ACP regression set — 78 passed with 198 assertions, including function-level and service-level unavailable-configured-model coverage. -- Final namespace-boundary correction — the 28-test Base registration/consent suite, package typecheck, and targeted lint passed with zero errors. -- Altimate Base registration/logout lock-order regression — passed; stale setup was cancelled with zero gateway calls and retained the disconnected install identity. -- `packages/opencode` typecheck after the final release-review fixes — passed. - -## Residual uncertainty - -The symlink integration regression is skipped on Windows hosts where ordinary file-symlink creation is not a reliable CI capability. The production boundary itself uses Node's cross-platform `realpath` and `path.relative`, and the mainline Windows separator regression remains covered. As documented in the repository policy, a same-user process able to race local filesystem changes already has equivalent operating-system authority; this change does not claim to provide an OS sandbox. diff --git a/research/altimate-base-release-2026-08-30/security-review-snapshot.md b/research/altimate-base-release-2026-08-30/security-review-snapshot.md deleted file mode 100644 index 6995b55826..0000000000 --- a/research/altimate-base-release-2026-08-30/security-review-snapshot.md +++ /dev/null @@ -1,247 +0,0 @@ -> **Historical scan snapshot — both findings are resolved.** This report was generated against client head `04fceafcf8`, not current release head `7d5d9b22b8`. Commit `755b410787` fixed both findings and their reviewed variants. Gateway release commit `ba6d7ad` was separately verified in the live environment; it does not reopen either client finding. See `security-fix-verification.md` for the current status and passing proof. The original report body is preserved below as historical evidence and must not be read as the current release state. - -# Security Review: altimate-base-release-final - -## Scope - -Complete security review of all 43 changed source files in the immutable release-head tree delta. - -- Scan mode: branch_diff -- Target kind: git_diff -- Target ID: target_sha256_ce9196c8c4fc064e865357233037ec3170ac4b77d019f78e148a2076e457e349 -- Revision range: babc7cb2e4147f65d8120946d8179ca7e06631ce...04fceafcf88df2becc6dd4b23cb2f561c8a48056 -- Snapshot digest: codex-security-snapshot/v1:sha256:9f60352e7871bdd3c8ec8feca1b9425df5952986ebf4105d92f3da61862fe064 -- Inventory strategy: diff -- Included paths: . -- Excluded paths: none -- Runtime or test status: Focused reproductions confirmed both findings; existing Altimate Base tests were reviewed separately as part of release validation. -- Artifacts reviewed: artifacts/01_context/threat_model.md, 43 changed-source review receipts, two focused Bun runtime reproductions -- Scan context: The generated threat model focused on managed registration/auth, configurable gateway routing, consent/privacy, telemetry, and isolation from other providers. The scan also followed changed behavior into command-discovery and warehouse transport sinks. - -Limitations and exclusions: -- The immutable two-dot tree delta compares a stale feature head to a base two commits ahead. Both findings are present in the exact head but originate from missing mainline fixes, not from Altimate Base-authored PR hunks. - -### Scan Summary - -| Field | Value | -| --- | --- | -| Scan outcome | completed | -| Reportable findings | 2 | -| Severity mix | medium: 2 | -| Confidence mix | high: 2 | -| Coverage | complete | -| Validation mode | Static source-to-sink review plus focused mocked runtime reproduction. | - -Canonical artifacts: `scan-manifest.json`, `findings.json`, and `coverage.json`. This report is a deterministic projection of those files. - -## Threat Model - -Text: # Altimate Base client threat model ## Summary Altimate Code is a local coding assistant that can send prompts, repository context, tool results, and model output to configured LLM providers. This change adds the consent-gated hosted `altimate-free/altimate-base` provider. The TUI first shows a logging and privacy disclosure, then calls a narrow host operation. The parent creates a one-use capability, the TUI worker consumes it, and only then may the worker register with the configured HTTPS gateway. The gateway-issued credential is stored in a dedicated local record and injected by a custom fetch only for same-origin Altimate Base inference. Provider discovery remains read-only. Evidence: `packages/tui/src/component/altimate-onboarding.tsx:345-550`, `packages/opencode/src/cli/cmd/tui.ts:250-257`, `packages/opencode/src/cli/tui/worker.ts:68-83`, `packages/opencode/src/altimate/free/client.ts:53-371`, `packages/opencode/src/provider/provider.ts:379-395`. ## Assets - The local installation secret and gateway-issued API credential. Registration sends only a SHA-256 hash of the installation secret. Evidence: `packages/opencode/src/altimate/free/client.ts:71-76,181-252`. - Gateway destination integrity: a registration response must return the configured normalized base URL, and the bearer key must be injected only for the registered origin. Evidence: `packages/opencode/src/altimate/free/client.ts:218-238,324-371`. - User prompts, repository context, model responses, and the user's explicit decision to send them to a hosted service that logs requests and responses. Evidence: `packages/tui/src/component/altimate-onboarding.tsx:345-346,372-550`, `packages/opencode/src/session/llm.ts:260-287`. - The consent invariant: provider discovery and ordinary inference must not mint or rotate credentials. Evidence: `packages/opencode/src/altimate/free/consent.ts:16-109`, `packages/opencode/src/altimate/free/client.ts:97-108,259-300,346-371`. - Session-scoped abuse-control identity. Only Altimate Base receives the authoritative current session ID; provider headers cannot override it. Evidence: `packages/opencode/src/session/llm.ts:55-66`. - Privacy of the credential across public provider metadata, TUI state, logs, and categorical onboarding telemetry. Evidence: `packages/opencode/src/altimate/free/client.ts:10-17`, `packages/opencode/src/provider/provider.ts:379-395`, `packages/opencode/src/altimate/telemetry/index.ts:992-1048`. - Release routing and provider isolation: the official endpoint is release configuration; project provider configuration and the shared Auth store cannot steer the managed provider. Evidence: `.github/workflows/release.yml:91-102`, `packages/opencode/script/build.ts:32-46,611-617`, `packages/opencode/src/provider/provider.ts:1179-1184,1687-1700,1748-1787`. ## Trust boundaries - \*\*User/TUI -\> registration authority.\*\* The disclosure defaults to No. Acceptance invokes a host-injected callback that creates a random capability. The worker accepts 64-character hex tokens, expires them after 30 seconds, caps pending tokens at 16, and consumes each once. Evidence: `packages/tui/src/component/altimate-onboarding.tsx:372-550`, `packages/opencode/src/cli/cmd/tui.ts:250-257`, `packages/opencode/src/altimate/free/consent.ts:4-58`. - \*\*Parent -\> TUI worker.\*\* Registration exists on private parent/worker RPC and a narrow SDK-context callback. The provider HTTP handler exposes metadata and connection state, not registration or raw credentials. Evidence: `packages/opencode/src/cli/tui/worker.ts:68-83`, `packages/tui/src/context/sdk.tsx:11-32,203-213`, `packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts:44-79`. - \*\*Worker -\> local credential store.\*\* The record is `altimate-base.json` under the application's XDG data directory. Writes use a same-directory exclusive temporary file, requested 0600 mode, synchronization, atomic rename, and final chmod. Evidence: `packages/opencode/src/global/index.ts:7-28`, `packages/opencode/src/altimate/free/store.ts:22-24,57-107`. - \*\*Client -\> registration gateway.\*\* URL precedence is `ALTIMATE_BASE_GATEWAY_URL`, legacy `ALTIMATE_FREE_GATEWAY_URL`, then the embedded release default. The client accepts only HTTPS without userinfo, query, or fragment. Registration rejects redirects, submits the installation-secret hash and sanitized CLI version, and validates the returned key, complete base URL, expiry, and optional model identifier. Evidence: `packages/opencode/src/altimate/free/client.ts:53-69,115-127,190-257`, `packages/opencode/src/altimate/free/url.ts:1-14`. - \*\*Provider SDK -\> inference gateway.\*\* The provider uses the stored base plus `/v1`, exposes a non-secret placeholder through provider metadata, and injects the bearer key only after a same-origin check. Redirects are manual, and a 401 marks a credential rejected without registering in the background. Evidence: `packages/opencode/src/provider/provider.ts:379-395`, `packages/opencode/src/altimate/free/client.ts:324-371`. - \*\*Managed provider -\> extensibility surfaces.\*\* Project configuration for `altimate-free` is filtered, shared Auth entries are ignored for it, and the managed model record is pinned after catalog construction. Evidence: `packages/opencode/src/provider/provider.ts:1179-1184,1509-1544,1687-1700,1768-1787`. - \*\*TUI -\> product telemetry.\*\* Selection, disclosure impression, accept/cancel, and registration result are categorical events. Provider IDs are allowlisted and existing telemetry opt-outs govern delivery. This is separate from hosted request/response logging. Evidence: `packages/opencode/src/altimate/telemetry/index.ts:800-833,992-1048,1696-1751`, `docs/docs/reference/security-faq.md:12-20`, `docs/docs/reference/telemetry.md:54-73`. - \*\*Release operator -\> shipped binary.\*\* Release CI passes the repository variable into the build. Release builds fail when it is absent or invalid, and the normalized value is compiled as the fallback behind runtime overrides. Evidence: `.github/workflows/release.yml:91-102`, `packages/opencode/script/build.ts:32-46,611-617`. ## Attacker capabilities - Untrusted repository content and model output can influence prompts and normal tool flows, but the public provider API does not expose the registration mutation. A boundary failure would add registration without the disclosure decision. - A remote network attacker without a trusted TLS position cannot select an HTTP destination. A compromised configured gateway can observe registration metadata and, after consent, inference content, but cannot make the client persist a different normalized base URL or unexpected model ID through the registration response. - A caller-controlled request destination would disclose the key only if it bypassed the same-origin and manual-redirect controls in `authorizedFetch`. - A same-user local process already shares OS-level authority and may be able to read environment variables or user files. The repository policy explicitly says the application permission system is not an OS sandbox. - A privileged release or runtime operator can select any structurally valid HTTPS gateway. That is trusted deployment authority; malicious user-controlled configuration is outside the repository's accepted vulnerability boundary. ## Security objectives - Do not create an installation identifier, register, erase a malformed record, or rotate a rejected credential until the current disclosure has been accepted through a fresh one-use capability. - Keep the real managed API key and raw installation secret out of public provider objects, shared Auth storage, TUI state, logs, and telemetry. - Accept only normalized HTTPS configuration, bind registration to the complete configured URL, and inject Authorization only for the registered origin without automatically following redirects. - Keep discovery read-only. Missing, expired, rejected, malformed, or wrong-gateway state must not trigger registration. - Preserve informed consent: disclose request/response logging and product-improvement use, warn against secrets/confidential code, default to No, and prevent closure while registration is pending. - Keep Base onboarding telemetry categorical and honor existing telemetry opt-outs. - Scope managed behavior to `altimate-free`; preserve explicit model configuration and recent-model behavior for other providers. Big Pickle may remain an explicit choice but must not remain the implicit free fallback. - Fail release builds without a valid official gateway while leaving source/development execution host-free until an operator supplies one. ## Assumptions and open questions - Provider-side storage and use of inference content is outside the supplied repository policy, but the recipient and disclosure are modeled because the user explicitly asked for that boundary. - The literal production gateway is not in source and is deliberately omitted. Offline review verified only the variable-to-binary configuration chain. - Requested 0600/0700 modes are meaningful on POSIX; effective Windows or unusual-filesystem ACL behavior remains platform-dependent. - `registerAfterConsent` does not itself accept the capability. Its only production caller is behind the worker-local consent gate; future callers must preserve this topology. - The normalizer permits an HTTPS path prefix. Stored credential and registration validation use the complete normalized URL; bearer injection is origin-scoped. This is an architectural distinction, not a finding. - Managed credential, endpoint, and session-header branches are keyed to `altimate-free`, but the diff intentionally changes global implicit model selection and applies provider allowlist checks to custom loaders. Full no-regression coverage for every other provider belongs to diff discovery and tests, not this architecture map. ## Effective resources | Consumer | Configuration chain and safe effective value | Recipients | Enforcing controls | Evidence | | --- | --- | --- | --- | --- | | Official release | Actions variable -\> build validation -\> embedded fallback; runtime env overrides remain first | Configured Altimate Base gateway | Required valid HTTPS release value, full stored-base match, same-origin key injection, manual redirects | `.github/workflows/release.yml:91-102`; `packages/opencode/script/build.ts:32-46,611-617`; `packages/opencode/src/altimate/free/client.ts:53-69,97-103,324-371` | | Source/development | Runtime primary env -\> legacy env -\> empty embedded fallback | Operator-selected HTTPS gateway after consent | Missing configuration fails closed; same URL and transport controls as release | `packages/opencode/src/altimate/free/client.ts:15-17,53-69`; `packages/opencode/src/altimate/free/url.ts:1-14` | | Credential store | XDG data -\> `altimate-code/altimate-base.json` | Registration, provider discovery, managed inference, rejection handling | Dedicated schema, exclusive atomic 0600 write, cross-process registration lock | `packages/opencode/src/global/index.ts:7-28`; `packages/opencode/src/altimate/free/store.ts:6-107`; `packages/opencode/src/altimate/free/client.ts:79-113,265-300` | | Consent capability | Disclosure acceptance -\> random parent token -\> arm RPC -\> consume RPC | TUI host callback and worker only | Token validation, 30-second expiry, 16-entry cap, delete on consume, no HTTP registration route | `packages/opencode/src/altimate/free/consent.ts:4-109`; `packages/opencode/src/cli/cmd/tui.ts:250-257`; `packages/opencode/src/cli/tui/worker.ts:68-83` | | Registration | Consumed capability -\> lock -\> `${gateway}/register` | Gateway receives secret hash/version; local store receives returned credential | Timeout, redirect rejection, response schema/base/expiry/model validation, atomic persistence | `packages/opencode/src/altimate/free/client.ts:181-300` | | Inference | Dedicated store -\> managed loader -\> `${base}/v1` -\> `authorizedFetch` | Registered gateway origin | Managed config pinning, authoritative session header, same-origin Authorization, no automatic redirect or background registration | `packages/opencode/src/provider/provider.ts:379-395,1179-1184,1687-1700`; `packages/opencode/src/session/llm.ts:55-66`; `packages/opencode/src/altimate/free/client.ts:324-371` | | Telemetry | TUI categorical event -\> opt-out-aware telemetry -\> configured ingestion endpoint | Existing Application Insights sink | Provider allowlist, categorical event schema, disable flags/config, bounded request | `packages/opencode/src/altimate/telemetry/index.ts:800-833,992-1048,1696-1751`; `docs/docs/reference/telemetry.md:54-73` | ## Resolved questions - Provider discovery and ordinary inference do not register or rotate credentials; they only read, reject, or reuse a separately consented rotation. - Registration is not exposed by the provider HTTP API; it is a private parent/worker operation. - Project provider config, shared Auth, and catalog input do not control the managed SDK, endpoint, headers, or key source. - Raw credentials do not reach the TUI or provider-list response. - Changing the effective gateway makes an old complete-base match fail and requires consented setup again. - Registration rejects redirects; inference does not automatically follow them and will not inject across origins. - Base-specific product telemetry is categorical and separate from hosted prompt/response logging. - The exact production gateway and cross-platform ACL equivalence remain intentionally unresolved. - -## Findings - -| Finding | Severity | Confidence | Detailed write-up | -| --- | --- | --- | --- | -| [TLS intent silently downgrades ClickHouse connections to plaintext](#finding-1) | medium | high | inline below | -| [Windows dependency discovery can execute commands from a vendored MCP config](#finding-2) | medium | high | inline below | - -### Confidence Scale - -| Label | Meaning | -| --- | --- | -| high | Direct evidence supports the finding with no material unresolved blocker. | -| medium | Evidence supports a plausible issue, but material runtime or reachability proof remains. | -| low | Evidence is incomplete and the item is retained only for explicit follow-up. | - - - -### [1] TLS intent silently downgrades ClickHouse connections to plaintext - -| Field | Value | -| --- | --- | -| Severity | medium | -| Confidence | high | -| Confidence rationale | The source explicitly constructs plaintext transport with credentials, and a focused mocked client-factory reproduction observed that exact configuration under TLS intent. | -| Category | Cleartext transmission of sensitive information | -| CWE | CWE-319 | -| Affected lines | packages/drivers/src/clickhouse.ts:26-28, packages/drivers/src/clickhouse.ts:39-40, packages/drivers/src/clickhouse.ts:43-53, packages/drivers/src/clickhouse.ts:63, packages/drivers/src/clickhouse.ts:87-101 | - -#### Summary - -The release head constructs `http://host:8123` without considering `tls` or `ssl`, attaches the configured username and password, and accepts an explicit plaintext URL even when TLS intent is present. Warehouse credentials, queries, and results can therefore cross the network without the requested transport protection. - -#### Root Cause - -The invariant is that TLS intent must produce an encrypted URL or fail closed. URL construction ignores `tls`/`ssl`, while certificate options merely decorate the client configuration and do not upgrade or validate the scheme. The same client receives credentials and performs queries over the resulting plaintext URL. - -#### Validation - -For `tls: true`, the client factory received `http://secure.example:8123` and a password. An explicitly contradictory plaintext connection string plus TLS intent was also accepted. - -Validation method: Static transport and credential trace plus focused mocked ClickHouse client-factory reproduction. - -Assertions: -- Default URL construction ignores TLS intent. -- Credentials are attached to the plaintext client configuration. -- Contradictory explicit HTTP plus TLS intent does not fail closed. - -Counterevidence and remaining uncertainty: -- The regression is not authored by Altimate Base hunks; current origin/main restores scheme selection, contradiction checks, and tests. - -#### Dataflow - -Connection host/TLS intent/credentials -\> unconditional or unchecked HTTP URL -\> `createClient` -\> metadata and query requests. - -- **Source:** ClickHouse connection credentials and TLS intent. - -- **Sink:** Plaintext ClickHouse HTTP transport. - -- **Outcome:** Credentials and warehouse traffic can be observed or modified. - -#### Reachability - -Reachable whenever the shipping driver attempts a connection for a TLS-intent configuration while an attacker can observe or control the network endpoint. - -- **Attacker:** On-path network attacker or malicious endpoint. - -- **Entry point:** ClickHouse connection creation. - -- **Outcome:** Credential/data disclosure or traffic modification. - -Preconditions: -- TLS or SSL intent -- Connection attempt -- Network or endpoint attacker position - -#### Severity - -**Medium** — The downgrade can disclose warehouse credentials and data or allow traffic modification. Exploitation requires a TLS-intent configuration and an attacker with an on-path or malicious-endpoint position, yielding high impact and medium likelihood. - -Merge current origin/main, then verify HTTPS scheme selection, rejection of contradictory plaintext/TLS configurations, and the focused ClickHouse transport tests. - -Impact assessment: -- **Level:** high -- **Why:** Warehouse credentials, query text, results, and metadata may be exposed or altered. - -Likelihood assessment: -- **Level:** medium -- **Why:** Requires an affected configuration and a network-positioned or endpoint attacker. - -#### Remediation - -Restore the upstream TLS-aware scheme selection and fail closed when explicit plaintext transport contradicts `tls`/`ssl`; merge the current origin/main fixes before release. - -Tests: -- Assert `tls: true` and `ssl: true` choose HTTPS defaults. -- Assert explicit `http://` plus TLS intent is rejected. -- Assert explicit HTTPS remains accepted and non-TLS HTTP behavior remains unchanged. - -Preventive controls: -- Derive transport scheme from a single validated connection policy. -- Reject contradictory security configuration. -- Test credentials only with the intended transport semantics. - - - -### [2] Windows dependency discovery can execute commands from a vendored MCP config - -| Field | Value | -| --- | --- | -| Severity | medium | -| Confidence | high | -| Confidence rationale | A direct source-to-process trace and focused Bun reproduction demonstrate the Windows path bypass and attacker-controlled command selection. | -| Category | OS command injection through unsafe configuration discovery | -| CWE | CWE-20, CWE-78 | -| Affected lines | packages/core/src/util/glob.ts:5-20, packages/opencode/src/altimate/datamate-transport.ts:48-71, packages/opencode/src/altimate/datamate-transport.ts:96-115, packages/opencode/src/altimate/tools/datamate.ts:266-267, packages/opencode/src/mcp/index.ts:541-548 | - -#### Summary - -On Windows, Datamate's forward-slash substring filter does not recognize native backslash paths beneath `node_modules`. Because the release head also lacks the newer glob-level traversal exclusions, a dependency-owned `mcp.json` can be selected and its attacker-controlled `command` and `args` passed to the local MCP process launcher. - -#### Root Cause - -The invariant is that vendored/generated directories must never contribute executable MCP configuration. The release head performs discovery without glob-level traversal exclusions, then post-filters native absolute paths using forward-slash substrings. Windows backslash paths survive, allowing `command` and `args` from a dependency config to reach `MCP.add` and `StdioClientTransport`. - -#### Validation - -A Windows-style `C:\repo\node_modules\malicious\mcp.json` survived filtering and produced the attacker-controlled command array that the downstream MCP transport launches. - -Validation method: Static data-flow review plus focused mocked package-interface reproduction. - -Assertions: -- The exact release head lacks traversal exclusions at the glob boundary. -- The fallback filter fails to match native Windows backslash paths. -- The selected config's command and args flow to the local-process transport. - -Counterevidence and remaining uncertainty: -- The regression is not authored by Altimate Base hunks; the two newer origin/main commits restore the missing controls. - -Limitations: -- The focused reproduction stopped at the exact transport configuration boundary rather than launching a native Windows process. - -#### Dataflow - -Dependency `mcp.json` -\> native Windows glob path -\> ineffective slash filter -\> Datamate config selection -\> `MCP.add` -\> `StdioClientTransport`. - -- **Source:** Untrusted project/dependency `command` and `args`. - -- **Sink:** Local process launch by `StdioClientTransport`. - -- **Outcome:** Attacker-selected code runs with the client user's privileges. - -#### Reachability - -Reachable in the shipping Windows client when a project contains the malicious config and Datamate add/sync is invoked. - -- **Attacker:** Author of an untrusted project dependency or generated subtree. - -- **Entry point:** Datamate discovery/add workflow. - -- **Outcome:** Local command execution. - -Preconditions: -- Windows path semantics -- Malicious matching mcp.json -- Datamate add/sync invocation -- No earlier legitimate config wins - -#### Severity - -**Medium** — Successful exploitation launches an attacker-chosen process in the user's context. It requires Windows, malicious project content, and a Datamate add/sync invocation, so impact is high while likelihood is medium. - -Merge current origin/main, then verify glob traversal exclusions, POSIX path normalization, Windows-path regression coverage, and focused Datamate tests. - -Impact assessment: -- **Level:** high -- **Why:** Arbitrary local process execution in the user's context. - -Likelihood assessment: -- **Level:** medium -- **Why:** Requires a malicious project artifact plus an explicit or model-driven Datamate workflow. - -#### Remediation - -Restore the upstream glob traversal ignore list and normalized POSIX path behavior, retain a defense-in-depth normalized Datamate filter, and merge the current origin/main fixes before release. - -Tests: -- Assert Windows-style `node_modules` and generated paths never become Datamate candidates. -- Assert ignored directories are pruned before recursive traversal. -- Assert a safe project-root `mcp.json` still registers normally. - -Preventive controls: -- Prune untrusted dependency/generated directories at the traversal API. -- Normalize paths before security comparisons. -- Keep process-launch discovery tests on both POSIX and Windows path forms. - -## Reviewed Surfaces - -| Surface | Risk Area | Outcome | Notes | -| --- | --- | --- | --- | -| Altimate Base registration, URL validation, credential storage, and same-origin authorization | Authentication, secret handling, SSRF, redirects, consent | No issue found | Reviewed the managed client, store, URL normalizer, and all registration/inference paths. Controls are fail-closed and scoped only to `altimate-free`. | -| Consent capability and TUI parent/worker boundary | Consent bypass and exposed mutation surfaces | No issue found | Reviewed the disclosure, default-No flow, one-use capability, private RPC, SDK callback, and provider HTTP response. Registration remains private and consent-gated. | -| Provider/model selection and cross-provider isolation | Credential confusion, endpoint steering, regression to other models | No issue found | Reviewed managed-provider pinning, project configuration filtering, Auth isolation, session headers, dialogs, and defaults. Big Pickle remains explicit-only and other providers retain their existing paths. | -| Release build and configurable gateway chain | Unsafe release configuration and destination integrity | No issue found | Reviewed build, publish, and check paths. Release configuration is required and validated; runtime override precedence remains explicit and HTTPS-only. | -| Onboarding telemetry and privacy signals | Secret or prompt leakage through product telemetry | No issue found | Reviewed categorical event schemas, provider allowlisting, bounded fields, and opt-out paths; no managed credential or inference content enters these events. | -| Datamate, MCP discovery, project loading, and local tool registration | Untrusted-repository command execution and path handling | Reported | The exact release head is missing two newer mainline traversal controls, creating the reported Windows vendored-config command path. Other reviewed MCP, project, ACP, and registry paths produced no surviving finding. | -| Warehouse drivers and driver resolution | Credential transport, dependency resolution, and command/tool execution | Reported | The exact release head is missing the newer mainline ClickHouse TLS fix, creating the reported cleartext downgrade. The remaining BigQuery, Databricks, DuckDB, MongoDB, MySQL, Oracle, Postgres, Redshift, Snowflake, SQL Server, and Trino driver paths produced no surviving finding. | -| TUI application, dialogs, SDK state, and onboarding UI | UI-triggered authority, model confusion, and consent-state races | No issue found | Reviewed the app, Base onboarding component, model/provider dialogs, SDK context, telemetry context, and dialog primitives; no additional security issue survived validation. |