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/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..73dd89cddc 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -46,6 +46,42 @@ 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. + +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://your-gateway.example +altimate +``` + +The URL must use HTTPS. Credentials, +query strings, and fragments in the URL are rejected. `ALTIMATE_FREE_GATEWAY_URL` is retained as a +legacy fallback, but `ALTIMATE_BASE_GATEWAY_URL` takes precedence. If the configured gateway host +changes, credentials issued by the previous host are not loaded and the consented registration +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. + +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/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..4f1bc30588 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.) | +| 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/docs/docs/reference/security-faq.md b/docs/docs/reference/security-faq.md index 74399b31e4..dbd67d579b 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -13,6 +13,25 @@ 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. + +**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? @@ -90,6 +109,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/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..d6da71b1d0 100644 --- a/packages/drivers/src/clickhouse.ts +++ b/packages/drivers/src/clickhouse.ts @@ -8,10 +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 { - 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 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) { @@ -29,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 = @@ -76,9 +90,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 (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 123a24a96d..ae6acbc8fa 100644 --- a/packages/drivers/test/clickhouse-unit.test.ts +++ b/packages/drivers/test/clickhouse-unit.test.ts @@ -74,6 +74,33 @@ 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") + }) + + 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() @@ -117,6 +144,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/script/build.ts b/packages/opencode/script/build.ts index e5e28e2bc4..5516041102 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 { 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. @@ -28,6 +29,21 @@ 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 +const rawAltimateBaseGatewayUrl = process.env.ALTIMATE_BASE_GATEWAY_URL?.trim() ?? "" +const altimateBaseGatewayUrl = 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") + 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" @@ -608,6 +624,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/acp/service.ts b/packages/opencode/src/acp/service.ts index a65ea7a308..cf23b1e832 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) - const selected = selectDefaultModel(snapshot) + // 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,13 +224,19 @@ export function make(input: { "session", ) const restored = restoreFromMessages(messages.map((item) => item.info)) - const model = restored.model ?? selectDefaultModel(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), + // 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) @@ -307,13 +315,19 @@ export function make(input: { "session", ) const restored = restoreFromMessages(messages.map((item) => item.info)) - const model = restored.model ?? selectDefaultModel(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), + // 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) @@ -359,6 +373,20 @@ 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( + () => + 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 = availableModel(snapshot, sourceRestored.model) ?? (yield* requireDefaultModel(snapshot)) + // altimate_change end const forked = yield* request( () => input.sdk.session.fork( @@ -376,13 +404,19 @@ export function make(input: { "session", ) const restored = restoreFromMessages(messages.map((item) => item.info)) - const model = restored.model ?? selectDefaultModel(snapshot) + // 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), + // 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) @@ -426,7 +460,9 @@ export function make(input: { } if (params.configId === "effort") { - const model = current.model ?? selectDefaultModel(snapshot) + // 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 }) @@ -445,10 +481,15 @@ export function make(input: { if (!snapshot.availableModes.some((mode) => mode.id === params.value)) { return yield* new ACPError.InvalidModeError({ mode: params.value }) } + // 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, { - model: state.model ?? selectDefaultModel(snapshot), + // altimate_change start — mode selection cannot fabricate an ACP model + model: state.model ?? model, + // altimate_change end variant: state.variant, modeId: state.modeId, }), @@ -498,7 +539,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) - const selected = current.model ?? selectDefaultModel(snapshot) + // 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) } @@ -744,12 +787,33 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { ProviderV2.ID, Provider.Info > + // altimate_change start — keep the managed provider out of ACP unless this project allows it + 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 : 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 const defaultModel = defaultModelFromConfig( - configResponse?.data?.model, - providers, - configResponse?.data?.provider as Record | undefined, + 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) @@ -773,7 +837,9 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { return Directory.build({ directory, - providers, + // 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)), @@ -797,6 +863,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 @@ -805,7 +874,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") } } @@ -813,23 +882,68 @@ 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 opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined + const providerAllowed = (id: string) => + 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") + : 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) + .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 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 } -function selectDefaultModel(snapshot: Directory.Snapshot) { +// 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 - 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 } + 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 + ? 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 function detectSlashCommand(parts: ReturnType) { const text = parts @@ -875,6 +989,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), @@ -1083,3 +1217,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/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 7b9382f353..daad669027 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 { DiscoveryFiles } 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 DiscoveryFiles.scanProjectMcpJsonFiles(projectRootDir)).map((file) => file.path) } catch { log.warn("findAllMcpJsonFiles: glob scan failed", { cwd: projectRootDir }) return [] 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 new file mode 100644 index 0000000000..9e1bbd6c97 --- /dev/null +++ b/packages/opencode/src/altimate/free/client.ts @@ -0,0 +1,544 @@ +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" +import { FreeTierUrl } from "./url" + +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 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 + baseURL: string + expiresAt?: string + installSecret: string + rejected?: boolean +} + +export type RegistrationFailureKind = "network" | "http" | "response" | "cancelled" + +export class RegistrationError extends Error { + constructor( + message: string, + readonly kind: RegistrationFailureKind, + readonly status?: number, + ) { + super(message) + this.name = "AltimateBaseRegistrationError" + } +} + +export class ConfigurationError extends Error { + constructor(message: string) { + super(message) + this.name = "AltimateBaseConfigurationError" + } +} + +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() || + embedded + const normalized = FreeTierUrl.normalizeGatewayUrl(configured) + if (!normalized) { + throw new ConfigurationError( + configured + ? "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.", + ) + } + return normalized +} + +function mintInstallSecret(): string { + return randomBytes(32).toString("hex") +} + +export function hashInstallSecret(secret: string): string { + return createHash("sha256").update(secret).digest("hex") +} + +function credentialsFromStored(stored: FreeTierStore.Record | undefined): Credentials | undefined { + if (!stored?.apiKey || !stored.baseURL) return undefined + return { + apiKey: stored.apiKey, + baseURL: stored.baseURL, + expiresAt: stored.expiresAt, + installSecret: stored.installSecret, + ...(stored.rejected ? { rejected: true } : {}), + } +} + +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) + return !Number.isFinite(timestamp) || timestamp <= Date.now() +} + +export async function credentialsForLoad(): Promise { + const stored = await credentials() + if (!stored || stored.baseURL !== gatewayUrl()) return undefined + // Provider discovery must remain read-only. Refreshing here would mint credentials without the + // current launch's explicit TUI disclosure/consent operation. + if (stored.rejected || expired(stored)) return undefined + return stored +} + +export async function isRegistered(): Promise { + return (await credentialsForLoad()) !== undefined +} + +/** + * 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, async () => { + let stored: FreeTierStore.Record | 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 + // 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"), + }) + }) +} + +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 "" + } +} + +function credentialFingerprint(value: Pick): string { + return createHash("sha256").update(`${value.baseURL}\0${value.apiKey}`).digest("hex") +} + +function markCredentialRejectedInMemory(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 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 + 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 }) + }) +} + +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, + ...(expectedLogoutNonce ? { logoutNonce: expectedLogoutNonce } : {}), + }) + return installSecret +} + +async function registerOnce( + configuredGateway: string, + expectedLogoutNonce: string | undefined, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted() + const installSecret = await installSecretForRegistration(expectedLogoutNonce) + signal?.throwIfAborted() + 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: signal + ? AbortSignal.any([signal, AbortSignal.timeout(REGISTER_TIMEOUT_MS)]) + : 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.", "network") + } + + if (!response.ok) { + log.warn("Altimate Base registration rejected", { status: response.status }) + throw new RegistrationError(describeRegistrationFailure(response.status), "http", 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" ? 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 + 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.", "response") + } + + const result: Credentials = { + apiKey, + baseURL, + installSecret, + ...(expiresAt ? { expiresAt } : {}), + } + await FreeTierStore.write({ + version: 1, + installSecret, + ...(expectedLogoutNonce ? { logoutNonce: expectedLogoutNonce } : {}), + apiKey, + baseURL, + ...(result.expiresAt ? { expiresAt: result.expiresAt } : {}), + }) + // 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 +} + +/** + * Register only after consuming a one-shot consent capability. + * + * 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( + 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) + if (pending) return pending + + const started = (async () => { + let expectedLogoutNonce: string | undefined + try { + expectedLogoutNonce = (await FreeTierStore.read())?.logoutNonce + } catch (error) { + if (!(error instanceof FreeTierStore.InvalidCredentialStoreError)) throw error + // The existing explicit-consent repair path below owns malformed records. + } + + 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) + 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 credentialsForLoad() + 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" }) + } + + const active = initial + const response = await send(active)! + // A success cannot prove that a concurrent 401 was stale: the key may have + // been revoked after this request was authorized. Only explicit consent and + // registration rotate/clear rejected credentials, keeping the ordinary + // inference path lock-free after its initial credential read. + // + // It does, however, prove the credential is not dead right now, so the consecutive-401 counter + // 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 + + // 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 + } + const retryResponse = await retried + if (retryResponse.status === 401) await markCredentialRejected(next) + return retryResponse +} + +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 + } 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 { + 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 { message: `Too many requests to Altimate Base right now.${wait}`, retryable: true } + } + if (kind === "budget_exceeded") { + if (detail.includes("ExceededBudget: User=")) { + 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 { + 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 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/consent.ts b/packages/opencode/src/altimate/free/consent.ts new file mode 100644 index 0000000000..a9c2bff3d8 --- /dev/null +++ b/packages/opencode/src/altimate/free/consent.ts @@ -0,0 +1,62 @@ +import { ConsentCapabilityStore } from "./capability" +import { FreeTier } from "./client" +import { FreeTierStore } from "./store" + +export { ConsentCapabilityStore } + +export type RegistrationResult = + | { ok: true } + | { + ok: false + result: "network" | "rate_limited" | "unavailable" | "error" + message: string + } + +export function createRegistrationConsentGate(input: { + /** 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 +}) { + const capabilities = input.capabilities ?? new ConsentCapabilityStore() + return { + setToken(value: { token: string }): void { + capabilities.arm(value.token) + }, + async register(value: { token: string }): Promise { + try { + 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, + result: + error.status === 429 + ? "rate_limited" + : error.status === 503 + ? "unavailable" + : error.kind === "network" + ? "network" + : "error", + message: error.message, + } + } + if (error instanceof FreeTier.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.", + } + } + }, + } +} + +export * as FreeTierConsent from "./consent" diff --git a/packages/opencode/src/altimate/free/store.ts b/packages/opencode/src/altimate/free/store.ts new file mode 100644 index 0000000000..dd9575701d --- /dev/null +++ b/packages/opencode/src/altimate/free/store.ts @@ -0,0 +1,116 @@ +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 + logoutNonce?: string + apiKey?: string + baseURL?: string + expiresAt?: string + rejected?: boolean +} + +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 ["logoutNonce", "apiKey", "baseURL", "expiresAt"] as const) { + if (input[field] !== undefined && typeof input[field] !== "string") { + 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 + const logoutNonce = typeof input.logoutNonce === "string" ? input.logoutNonce : undefined + return { + version: 1, + installSecret: input.installSecret, + ...(logoutNonce ? { logoutNonce } : {}), + ...(apiKey ? { apiKey } : {}), + ...(baseURL ? { baseURL } : {}), + ...(expiresAt ? { expiresAt } : {}), + ...(input.rejected === true ? { rejected: true } : {}), + } +} + +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 + 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) + 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(() => {}) + if (ownsTemporary) 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/free/url.ts b/packages/opencode/src/altimate/free/url.ts new file mode 100644 index 0000000000..3681a555a5 --- /dev/null +++ b/packages/opencode/src/altimate/free/url.ts @@ -0,0 +1,16 @@ +/** 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 + } +} + +export * as FreeTierUrl from "./url" diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 0528c0a825..ffa865375e 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -820,7 +820,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" @@ -829,7 +829,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. */ @@ -839,17 +839,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 @@ -1016,6 +1022,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", @@ -1048,6 +1055,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", @@ -1059,7 +1067,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 = 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") - 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 || hasAltimateBaseState) { + 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 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 +569,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.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") + return + } + // altimate_change end yield* Effect.orDie(authSvc.remove(provider)) yield* Prompt.outro("Logout successful") }), diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index fb22e17251..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" @@ -174,7 +176,6 @@ export const TuiThreadCommand = cmd({ const reload = () => { client.call("reload", undefined).catch(() => {}) } - process.on("SIGUSR2", reload) let stopped = false const stop = async () => { @@ -190,6 +191,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() @@ -244,6 +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. 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 @@ -310,7 +321,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 8d4dd58e45..b0ef0ff40e 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -27,6 +27,10 @@ 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 — register Altimate Base only across the private parent/worker RPC boundary +import { FreeTier } from "@/altimate/free/client" +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 // is enforced by the compiler rather than by a comment. @@ -62,8 +66,22 @@ GlobalBus.on("event", (event) => { }) let server: Awaited> | undefined +// altimate_change start — worker-local, expiring capabilities gate every registration mutation +const altimateBaseRegistration = FreeTierConsent.createRegistrationConsentGate({ + register: (consent) => FreeTier.registerAfterConsent(consent), + onUnexpectedError: (error) => console.error("[altimate-base] registration failed", error), +}) +// altimate_change end export const rpc = { + // altimate_change start — install and consume a private capability only after disclosure acceptance + setAltimateBaseConsentToken(input: { token: string }) { + altimateBaseRegistration.setToken(input) + }, + async registerAltimateBase(input: { token: string }) { + return altimateBaseRegistration.register(input) + }, + // altimate_change 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/mcp/discover.ts b/packages/opencode/src/mcp/discover.ts index e0d32dd9f5..cb45af672f 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 { DiscoveryFiles } from "./discovery-files" const log = Log.create({ service: "mcp.discover" }) @@ -450,67 +450,51 @@ 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: DiscoveryFiles.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 DiscoveryFiles.scanProjectMcpJsonFiles(projectDir) + const rank = (file: DiscoveryFiles.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 DiscoveryFiles.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..f378ef2538 --- /dev/null +++ b/packages/opencode/src/mcp/discovery-files.ts @@ -0,0 +1,68 @@ +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 < b.relative ? -1 : a.relative > b.relative ? 1 : 0)) +} + +export * as DiscoveryFiles from "./discovery-files" diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index d1c2b9e171..1de0f4ceb0 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -2,6 +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 @@ -326,6 +329,23 @@ 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) { + const described = FreeTier.describeRequestTooLarge(input.error.responseBody) + if (described) { + return { + type: "api_error", + message: described, + statusCode: 413, + isRetryable: false, + responseHeaders: input.error.responseHeaders, + metadata: input.error.url ? { url: maskInternalHost(input.error.url) } : undefined, + } + } + } + // altimate_change end if (isOverflow(m) || input.error.statusCode === 413 || codeFromBody === "context_length_exceeded") { return { type: "context_overflow", @@ -336,6 +356,25 @@ 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, + retryAfter: input.error.responseHeaders?.["retry-after"], + }) + if (described) { + return { + type: "api_error", + message: described.message, + statusCode: 429, + isRetryable: described.retryable, + responseHeaders: input.error.responseHeaders, + metadata: input.error.url ? { url: maskInternalHost(input.error.url) } : undefined, + } + } + } + // 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 // 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..95fedc9717 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -29,6 +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" @@ -373,6 +376,23 @@ 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`, + // 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, + }, + } + }, // altimate_change end openai: async () => { return { @@ -1156,7 +1176,12 @@ export namespace Provider { log.info("init") - const configProviders = Object.entries(config.provider ?? {}) + // 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"]) { @@ -1481,6 +1506,44 @@ 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 = { + [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, + } + // altimate_change end + function mergeProvider(providerID: ProviderID, provider: Partial) { const existing = providers[providerID] if (existing) { @@ -1622,6 +1685,9 @@ export namespace Provider { // load apikeys for (const [id, provider] of Object.entries(await Auth.all())) { + // 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") { @@ -1681,7 +1747,9 @@ export namespace Provider { for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) { const providerID = ProviderID.make(id) - if (disabled.has(providerID)) continue + // 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) @@ -1714,7 +1782,9 @@ export namespace Provider { continue } - const configProvider = config.provider?.[providerID] + // 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 @@ -2049,7 +2119,9 @@ export namespace Provider { return undefined } - const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] + // 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, @@ -2059,22 +2131,51 @@ export namespace Provider { ) } + // 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" + } + // altimate_change end + export async function defaultModel() { const cfg = await Config.get() 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 + // 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 baseProviderID = ProviderID.make(FreeTier.PROVIDER_ID) + const baseModelID = ModelID.make(FreeTier.MODEL_ID) + const baseProvider = providers[baseProviderID] + const registeredBaseAvailable = Boolean(baseProvider?.models[baseModelID]) && !hasProviderAllowlist const recent = (await Filesystem.readJson<{ recent?: { providerID: ProviderID; modelID: ModelID }[] }>( path.join(Global.Path.state, "model.json"), ) - .then((x) => (Array.isArray(x.recent) ? x.recent : [])) + .then((x) => (Array.isArray(x.recent) ? x.recent.filter(isModelReference) : [])) .catch(() => [])) as { providerID: ProviderID; modelID: ModelID }[] for (const entry of recent) { + // A recent entry is the user's own last pick, so it is never rewritten here — not even a + // legacy Big Pickle one. The TUI owns the migration because it owns the disclosure, and + // `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 + // 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 } } + // altimate_change end // altimate_change start — default to altimate-backend when configured and no model chosen yet const altimateProviderID = ProviderID.make("altimate-backend") @@ -2082,7 +2183,7 @@ export namespace Provider { if ( altimateProvider && altimateProvider.models[ModelID.make("altimate-default")] && - (!cfg.provider || Object.keys(cfg.provider).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)") @@ -2094,14 +2195,31 @@ 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, + // altimate_change start — select registered Altimate Base and never select Big Pickle implicitly + // Altimate Base owns the free fallback role that used to belong to Big Pickle, 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 } export function parseModel(model: string) { @@ -2168,6 +2286,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 @@ -2198,6 +2319,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/provider/schema.ts b/packages/opencode/src/provider/schema.ts index e069d70284..ec25090aeb 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -42,3 +42,7 @@ export const ModelID = modelIdSchema.pipe( zod: z.string().pipe(z.custom()), })), ) + +// altimate_change start — expose the module through the repository's namespace projection convention +export * as ProviderSchema from "./schema" +// altimate_change end 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..7370c05871 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,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 { ProviderSchema } from "@/provider/schema" +// altimate_change end function mapProviderAuthError(self: Effect.Effect) { return self.pipe( @@ -40,6 +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 = {} @@ -47,12 +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[ProviderSchema.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 a4c826d5f5..72540a7c88 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -52,6 +52,20 @@ export namespace LLM { export type StreamOutput = StreamTextResult + // altimate_change start — Altimate Base gateway session-scoped abuse control + 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) { const l = log .clone() @@ -241,7 +255,8 @@ export namespace LLM { maxOutputTokens: params.maxOutputTokens, // altimate_change end abortSignal: input.abort, - headers: { + // altimate_change start — bind Altimate Base requests to the current session + headers: withManagedSessionHeaders(input.model.providerID, input.sessionID, { ...(input.model.providerID.startsWith("opencode") ? { "x-opencode-project": Instance.project.id, @@ -251,14 +266,14 @@ 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, ...headers, - }, + }), + // altimate_change end 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 a3aaa89f3b..701628f5ee 100644 --- a/packages/opencode/test/acp/default-model.test.ts +++ b/packages/opencode/test/acp/default-model.test.ts @@ -5,11 +5,12 @@ import { describe, expect, test } from "bun:test" 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 { ProviderSchema } from "@/provider/schema" +import { ACPService } from "@/acp/service" +import { Directory } from "@/acp/directory" -const model = (providerID: ProviderID, id: string): Provider.Model => ({ - id: ModelID.make(id), +const model = (providerID: ProviderSchema.ProviderID, id: string): Provider.Model => ({ + id: ProviderSchema.ModelID.make(id), providerID, api: { id, url: "https://example.com", npm: "@ai-sdk/openai-compatible" }, name: id, @@ -32,14 +33,14 @@ const model = (providerID: ProviderID, id: string): Provider.Model => ({ }) const provider = (id: string, modelIDs: string[]): Provider.Info => { - const providerID = ProviderID.make(id) + const providerID = ProviderSchema.ProviderID.make(id) return { id: providerID, name: id, source: "config", env: [], options: {}, - models: Object.fromEntries(modelIDs.map((m) => [ModelID.make(m), model(providerID, m)])), + models: Object.fromEntries(modelIDs.map((m) => [ProviderSchema.ModelID.make(m), model(providerID, m)])), } as Provider.Info } @@ -48,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"])), ) @@ -58,34 +59,147 @@ 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 = ACPService.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("treats an empty provider object as unrestricted", () => { + const result = ACPService.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( + ACPService.defaultModelFromConfig(undefined, providers(provider("opencode", ["big-pickle"]))), + ).toBeUndefined() + }) + + test("rejects a configured model that is not available", () => { + expect( + ACPService.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", + 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 Directory.Snapshot + + 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 = ACPService.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( + const result = ACPService.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")) }) + 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, + providers(provider("altimate-free", ["altimate-base"]), provider("opencode", ["big-pickle"])), + { opencode: {} }, + ) + expect(result).toBeUndefined() + }) + + test("an Altimate Base-only provider block cannot force the managed provider", () => { + const result = ACPService.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( + const result = ACPService.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")) }) 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"])), ) @@ -94,5 +208,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 Directory.Snapshot + + expect(ACPService.selectDefaultModel(snapshot)).toBeUndefined() + }) }) // altimate_change end diff --git a/packages/opencode/test/acp/event.test.ts b/packages/opencode/test/acp/event.test.ts index 8a72754f0e..48cc461527 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, @@ -471,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: () => @@ -481,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/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 852ef81795..ed8db2c47a 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -148,7 +148,13 @@ 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[] + providerConfig?: Record + configModel?: string + configFails?: boolean + }, ) => { const updates: SessionNotification[] = [] const mcpAdds: string[] = [] @@ -158,6 +164,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,8 +173,16 @@ describe("ACP service sessions", () => { })) const sdk = { config: { - providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }), - get: () => Promise.resolve({ data: {} }), + providers: () => + Promise.resolve({ + data: { + providers: options?.providers ?? [provider], + }, + }), + get: () => + options?.configFails + ? Promise.reject(new Error("config unavailable")) + : Promise.resolve({ data: { provider: options?.providerConfig, model: options?.configModel } }), }, app: { agents: () => @@ -190,7 +205,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 +290,7 @@ describe("ACP service sessions", () => { commands, summarizes, usageUpdates, + creates, } } @@ -299,6 +318,247 @@ 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] }) + + 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("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("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, + 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("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 closed for Altimate Base when the project config lookup fails", 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, creates } = makeService([], { + providers: [baseProvider], + configFails: true, + }) + + 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("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("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("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([ { @@ -320,6 +580,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([ { 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..607a1f9361 --- /dev/null +++ b/packages/opencode/test/altimate/altimate-base.test.ts @@ -0,0 +1,652 @@ +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 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") +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 { FreeTierConsent } = await import("../../src/altimate/free/consent") +const { Flock } = await import("@opencode-ai/core/util/flock") + +const GATEWAY_URL = "https://gateway.test" +const REGISTERED = { + api_key: "sk-altimate-base-1", + base_url: 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 + // 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 +}) + +afterEach(() => { + fetchSpy?.mockRestore() + fetchSpy = undefined +}) + +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 }) +}) + +// 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 + expect(() => FreeTier.gatewayUrl()).toThrow(FreeTier.ConfigurationError) + 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") + }) + + 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 + 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 + 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.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")) + 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) + // 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("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(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(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(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(consented())).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(consented()) + 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(consented()) + 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.registerAfterConsent(consented())).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.registerAfterConsent(consented()) + expect(secondHash).toBe(firstHash) + }) + + test("distinguishes network failures from invalid gateway responses", async () => { + mockFetch(() => { + throw new Error("connection reset") + }) + 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(consented()).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 + const requestStarted = new Promise((resolve) => { + started = resolve + }) + let requestAborted = false + mockFetch((_input, init) => { + started() + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => { + requestAborted = true + reject(init.signal?.reason) + }, + { once: true }, + ) + }) + }) + + const pending = FreeTier.registerAfterConsent(consented(), { signal: controller.signal }) + await requestStarted + controller.abort() + + 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(consented()) + 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", () => { + 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({ baseURL: `${GATEWAY_URL}/old-path` }) + + expect(await FreeTier.credentialsForLoad()).toBeUndefined() + expect(await FreeTier.isRegistered()).toBe(false) + }) + + 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) => { + 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" }) + }) + + expect(await FreeTier.credentialsForLoad()).toBeUndefined() + expect(registrations).toBe(0) + }) + + 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("a successful request reads the credential store exactly once", 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)[] = [] + 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}`]) + }) + + 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 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[] = [] + 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`]) + 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("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[] = [] + 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(consented()) + expect(rotated.apiKey).toBe("sk-altimate-base-rotated") + expect(urls).toEqual([`${REGISTERED.base_url}/v1/chat/completions`, `${REGISTERED.base_url}/register`]) + }) +}) + +describe("consent boundary", () => { + test("overlapping one-shot capabilities survive mismatches and remain independent", async () => { + const first = "a".repeat(64) + const second = "b".repeat(64) + let registrations = 0 + // 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 ({ capability, token }) => { + if (!capability.consume(token)) throw new FreeTier.RegistrationError("consent expired", "cancelled") + registrations++ + }, + }) + + gate.setToken({ token: first }) + gate.setToken({ token: second }) + expect((await gate.register({ token: "c".repeat(64) })).ok).toBe(false) + expect((await gate.register({ token: first })).ok).toBe(true) + expect((await gate.register({ token: first })).ok).toBe(false) + expect((await gate.register({ token: second })).ok).toBe(true) + expect(registrations).toBe(2) + }) + + test("pending capabilities are bounded and expire", () => { + let now = 1_000 + const capabilities = new FreeTierConsent.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("only transport failures are surfaced as network failures", async () => { + const token = "d".repeat(64) + const network = FreeTierConsent.createRegistrationConsentGate({ + register: async () => { + throw new FreeTier.RegistrationError("offline", "network") + }, + }) + network.setToken({ token }) + expect(await network.register({ token })).toMatchObject({ ok: false, result: "network" }) + + const invalidResponse = FreeTierConsent.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/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/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/cli/providers-logout.test.ts b/packages/opencode/test/cli/providers-logout.test.ts new file mode 100644 index 0000000000..ef80e4e3ea --- /dev/null +++ b/packages/opencode/test/cli/providers-logout.test.ts @@ -0,0 +1,100 @@ +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 disconnectedBase = { + version: 1, + installSecret: "test-install-secret", + } + const registeredBase = { + ...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 disconnectedBase | 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(registeredBase)) + 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") + // 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, + logoutNonce: expect.stringMatching(/^[0-9a-f]{32}$/), + }) + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({ anthropic }) + + 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, 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)) + 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({}) + + 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/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/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/mcp/discover.test.ts b/packages/opencode/test/mcp/discover.test.ts index ad4e243dbf..d93f91a55d 100644 --- a/packages/opencode/test/mcp/discover.test.ts +++ b/packages/opencode/test/mcp/discover.test.ts @@ -1,8 +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, unresolvedEnvVars } from "../../src/mcp/discover" +import { DiscoveryFiles } from "../../src/mcp/discovery-files" + +const testSymlink = process.platform === "win32" ? test.skip : test let tempDir: string let homeDir: string @@ -25,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( @@ -469,6 +482,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,8 +496,33 @@ 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/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts index 4d887c6d66..df94588d0d 100644 --- a/packages/opencode/test/provider/error.test.ts +++ b/packages/opencode/test/provider/error.test.ts @@ -399,3 +399,71 @@ 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) + expect(result.responseBody).toBeUndefined() + expect(JSON.stringify(result)).not.toContain("throttling_error") + } + }) + + 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) + expect(result.responseBody).toBeUndefined() + expect(JSON.stringify(result)).not.toContain("179608") + } + }) + + 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..927c3bbb51 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" @@ -11,6 +11,11 @@ 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 { Auth } from "../../src/auth" +import { Global } from "../../src/global" + +const ALTIMATE_BASE_GATEWAY_URL = "https://gateway.test" function provideProviderTestInstance(input: { directory: string @@ -39,6 +44,269 @@ function provideProviderTestInstance(input: { ) } +test("Altimate Base is pinned to the hosted Qwen contract without affecting other providers", 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]: { + 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 () => { + 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(`${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") + 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 { + credentials.mockRestore() + } +}) + +test("a project config cannot make Altimate Base connected before registration", async () => { + 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() + }, + }) + } 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("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("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, + installSecret: "install-secret", + }) + try { + await using tmp = await tmpdir({ config: { provider: {} } }) + 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).not.toEqual({ + providerID: ProviderID.make(FreeTier.PROVIDER_ID), + modelID: ModelID.make(FreeTier.MODEL_ID), + }) + expect(model.providerID).toBe(ProviderID.make("opencode")) + }, + }) + } finally { + credentials.mockRestore() + } +}) + +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, + 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: "opencode", modelID: "big-pickle" }] })) + await using tmp = await tmpdir({ config: { provider: {} } }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + // The TUI owns the migration because it owns the disclosure; rewriting the recent pick + // here would move a user who declined onto the request-logging tier with no prompt. + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make("opencode"), + modelID: ModelID.make("big-pickle"), + }) + }, + }) + } finally { + if (previous === undefined) await fs.rm(stateFile, { force: true }) + else await fs.writeFile(stateFile, previous) + credentials.mockRestore() + } +}) + +test("a persisted Big Pickle default remains until Altimate Base consent exists", async () => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue(undefined) + 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: "opencode", modelID: "big-pickle" }] })) + await using tmp = await tmpdir({ config: { provider: {} } }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make("opencode"), + modelID: ModelID.make("big-pickle"), + }) + }, + }) + } finally { + if (previous === undefined) await fs.rm(stateFile, { force: true }) + else await fs.writeFile(stateFile, previous) + credentials.mockRestore() + } +}) + +test("an explicitly configured Big Pickle model remains authoritative", async () => { + await using tmp = await tmpdir({ config: { model: "opencode/big-pickle" } }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make("opencode"), + modelID: ModelID.make("big-pickle"), + }) + }, + }) +}) + +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/release-validation/mcp-datamate-893-codex.test.ts b/packages/opencode/test/release-validation/mcp-datamate-893-codex.test.ts index 8a08b8d5e8..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 { mkdir, readFile, writeFile } from "fs/promises" +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" @@ -10,6 +11,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 }) @@ -20,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 @@ -171,6 +175,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 +189,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/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 6f01766a71..d628f96992 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, Tool } 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" @@ -83,6 +82,24 @@ describe("session.llm.toolNamesFromMessages", () => { }) }) +// altimate_change start — managed session header must never leak to third-party providers +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 = { + "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 + // Harness reliability / item 3: stub injection must be skipped entirely when the call // exposes zero real tools AND uses the explicit toolChoice "none" no-tool-call // contract (e.g. the compaction summarizer) — the provider-compat fallback path. @@ -346,6 +363,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/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/app.tsx b/packages/tui/src/app.tsx index b7bf10097e..f19d5674b8 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -39,6 +39,7 @@ import { DialogProvider, useDialog } from "./ui/dialog" // + /logout commands import { DialogAltimateAuth } from "./component/dialog-provider" import { + DialogAltimateBaseConfirm, DialogModelWelcome, useReady, useSetupComplete, @@ -99,7 +100,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" @@ -110,6 +111,10 @@ import { cliErrorMessage, errorFormat } from "./util/error" import { detectModeFromCOLORFGBG } from "./terminal-detection" // altimate_change end +// 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", "session.new", @@ -173,6 +178,9 @@ export type TuiInput = { headers?: RequestInit["headers"] events?: EventSource pluginHost: TuiPluginHost + // 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. onTelemetry?: TrackOnboarding @@ -335,6 +343,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 +583,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() @@ -585,6 +594,50 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const trackOnboarding = useOnboardingTelemetry() // altimate_change end + // altimate_change start — move the retired Big Pickle default to Altimate Base + // Already-registered users migrate immediately. Everyone else sees the existing logging + // disclosure first; an explicit No is remembered and leaves their model untouched. + let legacyModelMigrationHandled = false + createEffect(() => { + if (legacyModelMigrationHandled) return + if (!ready() || sync.status !== "complete" || !local.model.ready) return + if (!local.model.usesLegacyDefault()) { + legacyModelMigrationHandled = true + 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"]), + ) + if (altimateBaseAvailable) { + legacyModelMigrationHandled = true + local.model.migrateLegacyDefault() + return + } + + if (!sdk.altimateBaseRegistration) { + legacyModelMigrationHandled = true + return + } + + legacyModelMigrationHandled = true + dialog.replace(() => ( + kv.set(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, true)} + /> + )) + }) + // altimate_change end + // altimate_change start — AI-7774: first-run onboarding gate. On a fresh launch // with no usable model, open the curated provider picker as the entry point (chat // input stays visible; submit is gated in the prompt until setup completes). Fire @@ -604,6 +657,13 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // scan gate (the AI-7774 regression). `sync.status` is the provider-load signal // (same one used for continue/fork above). if (!ready() || sync.status !== "complete") return + // A Big Pickle selection proves this is an existing user, even though that zero-cost + // provider does not satisfy useConnected(). The migration effect above owns any consent + // prompt; never overwrite it with the first-run picker. + if (local.model.hasExistingLegacySelection()) { + firstRunPickerHandled = true + return + } firstRunPickerHandled = true if (onboardingReady()) { // Not necessarily a returning user. The prompt gate (component/prompt/index.tsx) opens the @@ -653,7 +713,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 +1007,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..9dd9ae0423 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -1,9 +1,9 @@ // 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 { 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" @@ -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,14 @@ 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) + createEffect(() => { + const last = rows().length - 1 + if (selected() > last) setSelected(Math.max(0, last)) + }) function move(direction: number) { - setSelected((prev) => (prev + direction + COUNT) % COUNT) + const count = rows().length + setSelected((prev) => (prev + direction + count) % count) } useKeyboard((evt) => { @@ -246,7 +258,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 +331,172 @@ 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: { - 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. */ +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. 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 } + | { 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 register = sdk.altimateBaseRegistration + if (!register) return { ok: false, result: "error", message: REGISTER_FAILURE_MESSAGE } + try { + const data = await register() + if (data.ok) return { ok: true } + return { + ok: false, + result: data.result, + message: 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: { + // altimate_change — returning Big Pickle users reuse the same disclosure before migration + origin: "welcome" | "model" | "migration" viaSearch?: boolean + onDecline?: () => void }) { const { theme } = useTheme() const dialog = useDialog() const 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 + const releaseCloseGuard = dialog.guardClose(() => !busy()) + + function recordChoice(choice: "accept" | "cancel") { + if (choiceRecorded) return + choiceRecorded = true + if (firstRunActive() && props.origin !== "migration") { + trackOnboarding({ name: "altimate_base_choice", choice }) + } + } + onMount(() => { - if (firstRunActive()) trackOnboarding({ name: "big_pickle_confirm_shown", origin: props.origin }) + // Migration is not first-run onboarding and must not enter that funnel. + if (firstRunActive() && props.origin !== "migration") { + 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 + 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 - 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") + if (props.origin === "migration") { + props.onDecline?.() + dialog.clear() + return + } 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 (disposed) return + if (firstRunActive() && props.origin !== "migration") { + trackOnboarding({ + name: "altimate_base_register_result", + result: outcome.ok ? "success" : outcome.result, + }) + } + if (!outcome.ok) { + setBusy(false) + setError(outcome.message) + toast.show({ variant: "error", message: outcome.message }) + return + } + + await sdk.client.instance.dispose().catch(() => {}) + if (disposed) return + await sync.bootstrap().catch(() => {}) + if (disposed) return + const available = sync.data.provider.some( + (provider) => provider.id === "altimate-free" && Boolean(provider.models?.["altimate-base"]), + ) + if (!available) { + const message = "Altimate Base was registered, but the model is not ready yet. Try again in a moment." + setBusy(false) + setError(message) + toast.show({ variant: "error", message }) + return + } + decided = true - if (firstRunActive()) trackOnboarding({ name: "big_pickle_choice", choice: "accept" }) - // altimate_change end + setBusy(false) dialog.clear() - local.model.set({ providerID: "opencode", modelID: "big-pickle" }, { 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() } + const options = [ - { label: "No — pick something else", hint: "(default)", run: no }, - { label: "Yes — continue with Big Pickle", hint: "", run: yes }, + { + label: props.origin === "migration" ? "No — keep Big Pickle" : "No — pick something else", + hint: "(default)", + run: no, + }, + { label: "Yes — use Altimate Base", hint: "", run: () => void yes() }, ] useKeyboard((evt) => { + if (busy()) { + if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) { + evt.preventDefault() + evt.stopPropagation() + } + return + } if (evt.name === "up" || evt.name === "down") { setSelected((prev) => (prev + 1) % 2) evt.preventDefault() @@ -408,7 +510,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 +526,23 @@ export function DialogBigPickleConfirm(props: { - Use Big Pickle? + Use Altimate Base? - dialog.clear()}> + !busy() && 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..d3278f3098 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: { @@ -127,13 +127,53 @@ 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 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 +204,32 @@ 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 + onSelect() { + if (activated) return undefined 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(() => ) + return undefined }, } - // 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 +285,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..6b9bd7737c 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -16,20 +16,29 @@ 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, clearFirstRunActive } from "./altimate-onboarding" +import { + markSetupComplete, + clearFirstRunActive, + DialogAltimateBaseConfirm, + useFirstRunActive, +} from "./altimate-onboarding" +// 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 // 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 +91,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], @@ -112,9 +122,16 @@ export function createDialogProviderOptions() { const toast = useToast() const { theme } = useTheme() const onboarded = useConnected() + // 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 + // altimate_change start — Base-only submit latch; other providers retain their existing selection flow + let altimateBaseActivated = false + // altimate_change end async function promptCustomProviderID(): Promise { const value = await DialogPrompt.show(dialog, "Other", { @@ -140,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 { @@ -169,6 +192,22 @@ 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") { + if (altimateBaseActivated) return + altimateBaseActivated = true + if (firstRunActive()) { + trackOnboarding({ + name: "provider_selected", + providerID: "altimate-free", + modelID: "altimate-base", + via_search: false, + }) + } + dialog.replace(() => ) + return + } + // altimate_change end const methods = sync.data.provider_auth[providerID] ?? [ { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 2ee944c078..b94bf9e4fa 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -392,8 +392,12 @@ export function Prompt(props: PromptProps) { // Keep command line --agent if specified. if (!args.agent) local.agent.set(msg.agent) if (msg.model) { - local.model.set(msg.model) - local.model.variant.set(msg.model.variant) + // 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 39e7bc111d..7b40ad0a5f 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -31,6 +31,54 @@ export function parseModel(model: string) { } } +// altimate_change start — migrate only the retired implicit free-model choice +export type ModelRef = { providerID: string; modelID: string } + +export const LEGACY_BIG_PICKLE_MODEL = { + providerID: "opencode", + modelID: "big-pickle", +} as const satisfies ModelRef + +export const ALTIMATE_BASE_MODEL = { + providerID: "altimate-free", + modelID: "altimate-base", +} as const satisfies ModelRef + +export function isModelRef(model: unknown): model is ModelRef { + if (!model || typeof model !== "object") return false + const value = model as Record + return typeof value.providerID === "string" && typeof value.modelID === "string" +} + +export function isLegacyBigPickleModel(model: unknown): model is ModelRef { + if (!isModelRef(model)) return false + return model.providerID === LEGACY_BIG_PICKLE_MODEL.providerID && model.modelID === LEGACY_BIG_PICKLE_MODEL.modelID +} + +export function isExistingBigPickleSelection(current: unknown, recent: readonly unknown[], explicit: boolean) { + 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( model: { providerID: string; modelID: string }, recent: { providerID: string; modelID: string }[], @@ -47,6 +95,15 @@ export function recentModels( .map((item) => ({ providerID: item.providerID, modelID: item.modelID })) } +// altimate_change start — remove Big Pickle from migrated recents without touching other models +export function migrateLegacyRecentModels(recent: readonly unknown[]) { + return recentModels( + ALTIMATE_BASE_MODEL, + recent.filter((model): model is ModelRef => isModelRef(model) && !isLegacyBigPickleModel(model)), + ) +} +// altimate_change end + export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", init: () => { @@ -179,7 +236,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ .then((x) => { if (!x || typeof x !== "object") return const value = x as Record - if (Array.isArray(value.recent)) setModelStore("recent", value.recent) + // 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) @@ -191,6 +250,23 @@ 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() { + if (args.model || sync.data.config.model) return true + return Boolean(agent.current()?.model) + } + + function hasExplicitLegacyModel() { + const configured = [args.model, sync.data.config.model] + .filter((model): model is string => Boolean(model)) + .some((model) => isLegacyBigPickleModel(parseModel(model))) + return configured || isLegacyBigPickleModel(agent.current()?.model) + } + // altimate_change end + const fallbackModel = createMemo(() => { if (args.model) { const { providerID, modelID } = parseModel(args.model) @@ -241,6 +317,44 @@ 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(() => { + if (!isModelValid(model)) { + toast.show({ + message: `Model ${model.providerID}/${model.modelID} is not valid`, + variant: "warning", + duration: 3000, + }) + return + } + const a = agent.current() + if (!a) return + setModelStore("model", a.name, model) + if (options?.recent) { + setModelStore("recent", recentModels(model, modelStore.recent)) + save() + } + selected = true + }) + return selected + } + + function usesLegacyDefault() { + return shouldMigrateLegacyDefault( + currentModel(), + modelStore.recent, + hasExplicitModel(), + sync.data.config.provider, + ) + } + + function hasExistingLegacySelection() { + return isExistingBigPickleSelection(currentModel(), modelStore.recent, hasExplicitLegacyModel()) + } + // altimate_change end + return { current: currentModel, get ready() { @@ -314,25 +428,33 @@ 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, + migrateLegacyDefault() { + if (!usesLegacyDefault() || !isModelValid(ALTIMATE_BASE_MODEL)) return false batch(() => { - if (!isModelValid(model)) { - toast.show({ - message: `Model ${model.providerID}/${model.modelID} is not valid`, - variant: "warning", - duration: 3000, - }) - return - } const a = agent.current() - if (!a) return - setModelStore("model", a.name, model) - if (options?.recent) { - setModelStore("recent", recentModels(model, modelStore.recent)) - save() - } + if (a) setModelStore("model", a.name, { ...ALTIMATE_BASE_MODEL }) + setModelStore("recent", migrateLegacyRecentModels(modelStore.recent)) + save() }) + 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) { + if (!selectModel(model)) return undefined + return model }, + // altimate_change end toggleFavorite(model: { providerID: string; modelID: string }) { batch(() => { if (!isModelValid(model)) { 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..b2514d14ba 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -8,6 +8,17 @@ 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 + result: "rate_limited" | "unavailable" | "network" | "error" + message: string + } +> +// altimate_change end + export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", init: (props: { @@ -16,6 +27,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ fetch?: typeof fetch headers?: RequestInit["headers"] events?: EventSource + // altimate_change start — narrow host-injected registration operation; no raw credential/token + altimateBaseRegistration?: AltimateBaseRegistration + // altimate_change end }) => { const abort = new AbortController() let sse: AbortController | undefined @@ -194,6 +208,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ event: emitter, fetch: props.fetch ?? fetch, url: props.url, + // altimate_change start — expose only the narrow operation through the TUI SDK context + altimateBaseRegistration: props.altimateBaseRegistration, + // altimate_change end } }, }) diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index b6cd705b1e..5ef1d5d451 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -74,6 +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 @@ -99,6 +106,17 @@ function init() { }, 1) } + // altimate_change start — centralize guarded single-dialog close behavior + function closeTop() { + if (!canClose()) return false + const current = store.stack.at(-1) + current?.onClose?.() + setStore("stack", store.stack.slice(0, -1)) + refocus() + return true + } + // altimate_change end + useBindings(() => ({ enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(), bindings: [ @@ -107,13 +125,12 @@ 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() } - const current = store.stack.at(-1) - current?.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - refocus() + // altimate_change end }, }, { @@ -121,13 +138,12 @@ 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() } - const current = store.stack.at(-1) - current?.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - refocus() + // altimate_change end }, }, ], @@ -135,6 +151,8 @@ 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() } @@ -143,8 +161,12 @@ function init() { setStore("stack", []) }) 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 focus?.blur() @@ -159,6 +181,8 @@ function init() { onClose, }, ]) + return true + // altimate_change end }, get stack() { return store.stack @@ -169,6 +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 } } 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..04d2bb885e --- /dev/null +++ b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx @@ -0,0 +1,348 @@ +/** @jsxImportSource @opentui/solid */ +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { testRender, useRenderer } from "@opentui/solid" +import { expect, test } from "bun:test" +import { onCleanup, onMount } from "solid-js" +import { 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) + } +} + +async function mountConfirm( + input: { + registration?: + | { ok: true } + | { ok: false; result: "rate_limited" | "unavailable" | "network" | "error"; message: string } + | (() => Promise< + { ok: true } | { ok: false; result: "rate_limited" | "unavailable" | "network" | "error"; message: string } + >) + modelAvailable?: boolean + origin?: "welcome" | "migration" + } = {}, +) { + const [ + { DialogProvider, useDialog }, + { + 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 registrations: true[] = [] + const declines: true[] = [] + let replaceDialog = () => false + 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 bigPickle = { + ...model, + id: "big-pickle", + providerID: "opencode", + name: "Big Pickle", + family: "glm", + } + 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") { + return json({ + providers: input.modelAvailable === false ? [openCodeProvider] : [provider, openCodeProvider], + default: {}, + }) + } + if (url.pathname === "/provider") { + return json({ + all: [provider, openCodeProvider], + default: {}, + connected: input.modelAvailable === false ? ["opencode"] : ["altimate-free", "opencode"], + }) + } + return undefined + }) + const source = createEventSource() + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const resolvedConfig = createTuiResolvedConfig({ leader_timeout: 1_000 }) + const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig) + onCleanup(off) + + function OpenConfirm() { + const dialog = useDialog() + replaceDialog = () => dialog.replace(() => Session list replacement) + onMount(() => + dialog.replace(() => ( + declines.push(true)} /> + )), + ) + return null + } + + return ( + + {}}> + + + + + + + { + registrations.push(true) + return typeof input.registration === "function" + ? input.registration() + : (input.registration ?? { ok: true }) + }} + > + + + + + { + events.push(event) + }} + > + + + + + + + + + + + + + + + + + + ) + } + + const app = await testRender(() => , { kittyKeyboard: true }) + await app.renderOnce() + await Bun.sleep(50) + await app.renderOnce() + return { + app, + events, + disclosure: ALTIMATE_BASE_DISCLOSURE, + setupComplete: useSetupComplete(), + registrations: () => registrations, + declines: () => declines, + replaceDialog: () => replaceDialog(), + cleanup() { + app.renderer.destroy() + resetSetupComplete() + }, + } +} + +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() + expect(confirm.disclosure).toContain("Requests and responses are logged") + expect(frame).toContain("Use Altimate Base?") + 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) + expect(confirm.events).toEqual([{ name: "altimate_base_confirm_shown", origin: "welcome" }]) + } finally { + confirm.cleanup() + } +}) + +test.serial("the Big Pickle migration reuses consent, stays out of first-run telemetry, and honors No", async () => { + const confirm = await mountConfirm({ origin: "migration" }) + try { + const frame = confirm.app.captureCharFrame() + expect(frame).toContain("No — keep Big Pickle") + expect(frame.replace(/\s+/g, " ")).toContain("Requests and responses are logged and may be used") + expect(confirm.events).toEqual([]) + + confirm.app.mockInput.pressKey("n") + await waitUntil(() => confirm.declines().length === 1) + expect(confirm.registrations()).toHaveLength(0) + expect(confirm.events).toEqual([]) + } finally { + confirm.cleanup() + } +}) + +test.serial("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) + 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() + } +}) + +test.serial("accepting registers once through the private host operation and completes setup", async () => { + const confirm = await mountConfirm() + try { + confirm.app.mockInput.pressKey("y") + await waitUntil(() => confirm.setupComplete()) + expect(confirm.registrations()).toHaveLength(1) + expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "accept" }) + 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.serial("registration without a usable model remains incomplete and visibly recoverable", async () => { + const confirm = await mountConfirm({ modelAvailable: false }) + try { + confirm.app.mockInput.pressKey("y") + await waitUntil(() => confirm.events.some((event) => event.name === "altimate_base_register_result")) + await Bun.sleep(50) + await confirm.app.renderOnce() + expect(confirm.setupComplete()).toBe(false) + expect(confirm.app.captureCharFrame()).toContain("ready yet. Try again") + } finally { + confirm.cleanup() + } +}) + +test.serial("rate-limited registration stays recoverable and reports a typed outcome", async () => { + const message = "Too many Altimate Base registrations from this network right now. Try again later." + const confirm = await mountConfirm({ + registration: { ok: false, result: "rate_limited", message }, + }) + try { + confirm.app.mockInput.pressKey("y") + await waitUntil(() => confirm.events.some((event) => event.name === "altimate_base_register_result")) + await confirm.app.renderOnce() + expect(confirm.setupComplete()).toBe(false) + expect(confirm.registrations()).toHaveLength(1) + expect(confirm.events).toContainEqual({ name: "altimate_base_register_result", result: "rate_limited" }) + expect(confirm.app.captureCharFrame()).toContain("Too many Altimate Base") + } finally { + confirm.cleanup() + } +}) + +test.serial("dismissal keys and backdrop clicks are ignored while registration is in flight", async () => { + let finish!: (result: { ok: true }) => void + let started!: () => void + const began = new Promise((resolve) => { + started = resolve + }) + const pending = new Promise<{ ok: true }>((resolve) => { + finish = resolve + }) + const confirm = await mountConfirm({ + registration: async () => { + started() + return pending + }, + }) + try { + confirm.app.mockInput.pressKey("y") + await began + expect(confirm.replaceDialog()).toBe(false) + await confirm.app.renderOnce() + expect(confirm.app.captureCharFrame()).not.toContain("Session list replacement") + 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()) + } finally { + confirm.cleanup() + } +}) diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index e2f1e45f75..517bc0692c 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -1,5 +1,14 @@ import { expect, test } from "bun:test" -import { parseModel, recentModels } from "../../src/context/local" +import { + allowsManagedBaseDefault, + ALTIMATE_BASE_MODEL, + isExistingBigPickleSelection, + LEGACY_BIG_PICKLE_MODEL, + migrateLegacyRecentModels, + parseModel, + recentModels, + shouldMigrateLegacyDefault, +} from "../../src/context/local" test("parses model IDs containing slashes", () => { expect(parseModel("provider/family/model")).toEqual({ @@ -20,3 +29,44 @@ test("moves a model to the front, deduplicates, and limits recents", () => { ...recent.slice(6, 10), ]) }) + +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) + expect(isExistingBigPickleSelection(LEGACY_BIG_PICKLE_MODEL, [], true)).toBe(true) + expect( + isExistingBigPickleSelection({ providerID: "openai", modelID: "gpt-5" }, [LEGACY_BIG_PICKLE_MODEL], false), + ).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([ + { providerID: "anthropic", modelID: "claude-sonnet" }, + LEGACY_BIG_PICKLE_MODEL, + { providerID: "openai", modelID: "gpt-5" }, + LEGACY_BIG_PICKLE_MODEL, + ALTIMATE_BASE_MODEL, + null, + "malformed", + { providerID: "missing-model-id" }, + ]), + ).toEqual([ + ALTIMATE_BASE_MODEL, + { providerID: "anthropic", modelID: "claude-sonnet" }, + { providerID: "openai", modelID: "gpt-5" }, + ]) +}) diff --git a/packages/tui/test/util/presentation.test.ts b/packages/tui/test/util/presentation.test.ts index d1aab4ea49..48622ca166 100644 --- a/packages/tui/test/util/presentation.test.ts +++ b/packages/tui/test/util/presentation.test.ts @@ -4,5 +4,6 @@ import { sessionEpilogue } from "../../src/util/presentation" test("formats session continuation summary", () => { const epilogue = sessionEpilogue({ title: "A session", sessionID: "ses_123" }) expect(epilogue).toContain("A session") - expect(epilogue).toContain("opencode -s ses_123") + // altimate_change — the continuation command follows the Altimate CLI branding + expect(epilogue).toContain("altimate -s ses_123") }) 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