diff --git a/CHANGELOG.md b/CHANGELOG.md index ecbec1ea1..49fbcfa86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.12.3] - 2026-09-23 + +**Heads-up before upgrading (every user):** + +- **Altimate Base now registers automatically, with no dialog to accept.** Since 2026-09-17, OpenCode's own free tier (Zen) has rejected keyless requests from Altimate Code ("OpenCode's free tier can only be used from within OpenCode"), so every install that had silently fallen back to it lost its free model. A fresh install — or one with no other usable model configured — now registers the free, no-signup Altimate Base automatically at startup and shows a one-time notice instead of a confirmation dialog; only the confirmation step is gone, not the disclosure. Opt out with `ALTIMATE_BASE_AUTO_REGISTER=0`, `altimate providers logout altimate-base`, or `disabled_providers` in config. (#1361) + +### Changed + +- **Altimate Base replaces keyless Zen as the automatic fallback model** when nothing else is configured. See heads-up above. (#1361) + +### Fixed + +- **`altimate agent create` and `altimate review` no longer fail on a fresh install with no model configured.** Neither command registered Altimate Base before resolving a provider, unlike every other entrypoint (`run`, `tui`, `serve`, `acp`, `web`): `agent create` leaked a raw upstream error mentioning "OpenCode", a brand the user has never seen, with no remediation; `review`'s AI lane silently produced zero findings with no visible signal. Found in this release's review. +- **The TUI's startup auto-register wait no longer reads as a hang on a fresh install.** A "Connecting to Altimate Base…" status line appears if registration takes more than 300ms; the common already-registered path is unaffected. Found in this release's review. +- **The pinned-workspace routing section follows the pinned workspace, not the project's own link.** (#1357) + ## [0.12.2] - 2026-09-22 Promotes [0.12.2-beta.1] to `latest` — the six bug fixes below — plus four small fixes from this release's review and one workspace-pilot fix that landed alongside (#1353). No new features. The beta was published earlier the same day and did not soak before promotion; the review below is what stood in for that. diff --git a/README.md b/README.md index fd0a2fb03..313cac85b 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ installing it in your own repository. Then — in order: -**Step 1: Choose an LLM provider** (required before anything works): +**Step 1: Choose an LLM provider** (optional — Altimate Base is used automatically if you skip this): ```bash altimate # Launch the TUI /connect # Interactive setup — choose Altimate Base, sign in, or bring an API key diff --git a/packages/core/src/altimate-base-disclosure.ts b/packages/core/src/altimate-base-disclosure.ts index 4afce361b..652afc916 100644 --- a/packages/core/src/altimate-base-disclosure.ts +++ b/packages/core/src/altimate-base-disclosure.ts @@ -1,17 +1,19 @@ -// altimate_change start — the single definition of the Altimate Base consent disclosure. +// altimate_change start — the single definition of the Altimate Base disclosure notice. // -// This text is what a user actually consents against before any Base credential is minted, so it -// must be identical everywhere it is shown. Two packages render it and neither can import from the -// other: the TUI's disclosure dialog (`packages/tui`) and the HTTP disclosure route that serves -// hosts rendering their own dialog (`packages/opencode`, for the VS Code extension's chat panel). -// `packages/core` is the only module both already depend on, so the constant lives here. +// Altimate Base auto-registers with no consent gate — this text is shown once, as a notice, either +// right after registration or (for a headless surface) alongside it, not as a blocking prompt +// before any credential is minted. It must still be identical everywhere it's shown. Two packages +// render it and neither can import from the other: the TUI's onboarding notice (`packages/tui`) +// and the HTTP disclosure route that serves hosts rendering their own notice (`packages/opencode`, +// for the VS Code extension's chat panel). `packages/core` is the only module both already depend +// on, so the constant lives here. // // A new leaf file rather than an addition to an existing core module, so it adds no upstream // rebase surface. // // It states the core data terms up front. The persistent per-install-id linkage detail is // disclosed in docs/docs/configure/providers.md ("Data handling") rather than repeated in the -// gate (see #1268); keep the core terms in sync with that note. +// notice (see #1268); keep the core terms in sync with that note. export const ALTIMATE_BASE_DISCLOSURE = "Altimate Base is free and requires no signup. Requests and responses may be logged and used to improve Altimate's products, including the model. Secrets are automatically masked before storage, but don't rely on it — avoid sending secrets or confidential code. Usage can be rate limited." diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index f31bf206e..ad242ca3a 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -162,6 +162,17 @@ const AgentCreateCommand = effectCmd({ description = query } + // altimate_change start — auto-register Altimate Base before provider state is first built, + // mirroring cli/cmd/run.ts. Without this, a fresh install's first `agent create` resolves + // the default model before any provider is registered and fails with a raw upstream error. + { + const { FreeTier } = await import("../../altimate/free/client") + const { FreeTierConsent } = await import("../../altimate/free/consent") + const result = await FreeTier.autoRegisterWithin(undefined, () => void FreeTierConsent.printDisclosureOnceForHeadless(true)) + await FreeTierConsent.printDisclosureOnceForHeadless(result.status === "registered") + } + // altimate_change end + // Generate agent const spinner = prompts.spinner() spinner.start("Generating agent configuration...") diff --git a/packages/opencode/src/cli/cmd/review.ts b/packages/opencode/src/cli/cmd/review.ts index 717588c3d..88d6395b4 100644 --- a/packages/opencode/src/cli/cmd/review.ts +++ b/packages/opencode/src/cli/cmd/review.ts @@ -76,6 +76,19 @@ export const ReviewCommand = cmd({ ) } await bootstrap(cwd, async () => { + // altimate_change start — auto-register Altimate Base before the AI lane resolves a + // provider, mirroring cli/cmd/run.ts. Without this, a fresh install's `altimate review` + // (including the `--post --mode gate` CI path) fails the AI lane's Provider.defaultModel() + // silently — no provider was ever registered — and the deterministic verdict ships with + // zero AI findings and no visible signal. Kept outside the latency timer below. + { + const { FreeTier } = await import("../../altimate/free/client") + const { FreeTierConsent } = await import("../../altimate/free/consent") + const result = await FreeTier.autoRegisterWithin(undefined, () => void FreeTierConsent.printDisclosureOnceForHeadless(true)) + await FreeTierConsent.printDisclosureOnceForHeadless(result.status === "registered") + } + // altimate_change end + // altimate_change — time the engine only. Output writing and posting happen after this and // must not be counted as review latency, nor turn a computed review into a failed one. const startedAt = Date.now() diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index b57888284..af00b9e6b 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -170,7 +170,16 @@ export const TuiThreadCommand = cmd({ // altimate_change start — auto-register Altimate Base before the worker is spawned. The // worker starts loading instance/provider state as soon as it boots (worker.ts's // `traceReady` chain), so this has to land on the parent thread first. - await FreeTier.autoRegisterWithin() + // + // A fresh install's first launch can take up to the 3s wait with zero terminal output, + // which reads as a hang. Gate the status line behind a short delay so the common + // already-registered path (near-instant) never flashes it. + const registerFeedback = setTimeout(() => UI.println("Connecting to Altimate Base…"), 300) + try { + await FreeTier.autoRegisterWithin() + } finally { + clearTimeout(registerFeedback) + } // altimate_change end // altimate_change start — hand the launch correlation id to the worker explicitly. A Bun // Worker does not see runtime mutations to process.env, so without this the worker mints its diff --git a/packages/opencode/test/altimate/entrypoint-late-notice-wiring.test.ts b/packages/opencode/test/altimate/entrypoint-late-notice-wiring.test.ts index b678dfc20..dddc03831 100644 --- a/packages/opencode/test/altimate/entrypoint-late-notice-wiring.test.ts +++ b/packages/opencode/test/altimate/entrypoint-late-notice-wiring.test.ts @@ -44,15 +44,18 @@ function autoRegisterWithinArgs(source: string): string | null { const REAL_CALLBACK = /\(\)\s*=>[\s\S]{0,80}?printDisclosureOnceForHeadless\(true\)/ describe("autoRegisterWithin() late-notice callback wiring per entrypoint", () => { - test.each(["run.ts", "acp.ts", "web.ts"])("%s always passes a real onLateRegistration callback", (file) => { - const source = read(file) - const args = autoRegisterWithinArgs(source) - expect(args, `${file} must call autoRegisterWithin()`).not.toBeNull() - expect(args, `${file}'s autoRegisterWithin() call`).toMatch(REAL_CALLBACK) - // Guards against a regression that passes the callback conditionally (that's serve.ts's job, - // not these three) — none of them may reference ALTIMATE_CLI_CLIENT or ternary out. - expect(args, `${file} must not gate its callback like serve.ts does`).not.toMatch(/\?\s*\(\)\s*=>/) - }) + test.each(["run.ts", "acp.ts", "web.ts", "agent.ts", "review.ts"])( + "%s always passes a real onLateRegistration callback", + (file) => { + const source = read(file) + const args = autoRegisterWithinArgs(source) + expect(args, `${file} must call autoRegisterWithin()`).not.toBeNull() + expect(args, `${file}'s autoRegisterWithin() call`).toMatch(REAL_CALLBACK) + // Guards against a regression that passes the callback conditionally (that's serve.ts's + // job, not these five) — none of them may reference ALTIMATE_CLI_CLIENT or ternary out. + expect(args, `${file} must not gate its callback like serve.ts does`).not.toMatch(/\?\s*\(\)\s*=>/) + }, + ) test("serve.ts passes a real callback when NOT serving the datamates (VS Code) client", () => { const source = read("serve.ts") diff --git a/packages/opencode/test/skill/release-v0.12.3-adversarial.test.ts b/packages/opencode/test/skill/release-v0.12.3-adversarial.test.ts new file mode 100644 index 000000000..b409ff0dc --- /dev/null +++ b/packages/opencode/test/skill/release-v0.12.3-adversarial.test.ts @@ -0,0 +1,123 @@ +/** + * Adversarial coverage for the v0.12.3 release payload (v0.12.2..HEAD): the Altimate Base + * no-consent-gate PR (#1361, 429/49 tests of its own — see the entrypoint-late-notice-wiring + * pin) plus the routing-pin fix, PLUS the two P0s and P1 found by this release's own + * multi-persona review and fixed here: + * + * - `altimate agent create` and `altimate review` never called `FreeTier.autoRegisterWithin()` + * before resolving a provider (Chaos Gremlin/Support Engineer persona, both P0): a fresh + * install's first `agent create` or `review` run failed with a raw upstream error / silently + * produced zero AI findings, because no provider had ever been registered. + * - `tui.ts`'s up-to-3s auto-register wait gave zero terminal feedback on a fresh install + * (End User persona, P1): reads as a hang on the very first launch. + * + * This file does NOT re-test PR #1361's own extensive coverage (stale-zen-cycle, + * altimate-base-auto-register, altimate-base-headless-disclosure, routing-pin, etc. — all + * re-run and confirmed green during this release's Step 5/5c). It targets only the NEW code + * from this release's fixes, following the source-assertion pattern already established in + * test/altimate/entrypoint-late-notice-wiring.test.ts for these same Effect-based / heavy CLI + * command files. + */ +import { describe, test, expect } from "bun:test" +import { readFileSync } from "fs" +import { join, resolve } from "path" + +const cmdDir = resolve(import.meta.dir, "..", "..", "src", "cli", "cmd") + +function read(file: string): string { + return readFileSync(join(cmdDir, file), "utf-8") +} + +describe("v0.12.3: agent.ts / review.ts auto-register ordering", () => { + test("agent.ts registers Altimate Base BEFORE resolving/calling Agent.generate", () => { + const source = read("agent.ts") + const registerIdx = source.indexOf("autoRegisterWithin(") + const generateIdx = source.indexOf("agentSvc.generate(") + expect(registerIdx, "agent.ts must call autoRegisterWithin()").toBeGreaterThan(-1) + expect(generateIdx, "agent.ts must call agentSvc.generate()").toBeGreaterThan(-1) + // Registering AFTER the model/provider is already being resolved defeats the fix: a fresh + // install would still hit the unregistered path on its first LLM call. + expect(registerIdx, "registration must precede the generate() call, not follow it").toBeLessThan(generateIdx) + }) + + test("agent.ts's registration call is not swallowed inside the generate() error handler", () => { + const source = read("agent.ts") + // The existing `.catch((error) => { spinner.stop(...) ... })` on agentSvc.generate() must + // stay scoped to the generate call only — folding registration into that same try/catch + // would mask a registration failure as a generic "LLM failed to generate agent" message. + const catchIdx = source.indexOf(".catch((error) => {") + const registerIdx = source.indexOf("autoRegisterWithin(") + expect(catchIdx, "the existing generate() catch handler must still be present").toBeGreaterThan(-1) + expect(registerIdx).toBeLessThan(catchIdx) + }) + + test("review.ts registers Altimate Base BEFORE reviewPullRequest AND outside the latency timer", () => { + const source = read("review.ts") + const registerIdx = source.indexOf("autoRegisterWithin(") + const reviewCallIdx = source.indexOf("await reviewPullRequest({") + const timerIdx = source.indexOf("const startedAt = Date.now()") + expect(registerIdx, "review.ts must call autoRegisterWithin()").toBeGreaterThan(-1) + expect(reviewCallIdx, "review.ts must call reviewPullRequest()").toBeGreaterThan(-1) + expect(timerIdx, "review.ts must still time the engine via startedAt").toBeGreaterThan(-1) + expect(registerIdx, "registration must precede the review call").toBeLessThan(reviewCallIdx) + // A registration wait folded into the timed region would inflate every review_run latency + // metric on a fresh install (or after a credential rotation) with startup cost that has + // nothing to do with the review engine itself. + expect(registerIdx, "registration must be excluded from the engine latency timer").toBeLessThan(timerIdx) + }) + + test("review.ts's registration call runs even when --no-ai is set (matches R1: registers regardless of the caller's own model)", () => { + const source = read("review.ts") + const registerIdx = source.indexOf("autoRegisterWithin(") + const noAiCheckIdx = source.indexOf("noAi:") + expect(registerIdx).toBeGreaterThan(-1) + // The registration call must be unconditional — not gated behind `!args.noAi` — so the + // CI `--post --mode gate` deterministic-only path still benefits the NEXT invocation that + // does use the AI lane. Asserted by requiring no `noAi`/`args.ai` reference appears between + // the register call and its own closing brace. + const blockEnd = source.indexOf("// altimate_change end", registerIdx) + const between = source.slice(registerIdx, blockEnd) + expect(between, "the registration block must not branch on noAi/args.ai").not.toMatch(/noAi|args\.ai\b/) + expect(noAiCheckIdx).toBeGreaterThan(blockEnd) + }) +}) + +describe("v0.12.3: tui.ts startup-feedback timer", () => { + function readTui(): string { + return readFileSync(join(cmdDir, "tui.ts"), "utf-8") + } + + test("the feedback timer is gated behind a short, non-zero delay (never fires instantly)", () => { + const source = readTui() + const match = source.match(/setTimeout\(\(\) => UI\.println\("Connecting to Altimate Base…"\), (\d+)\)/) + expect(match, "tui.ts must set a delayed status line before autoRegisterWithin()").not.toBeNull() + const delayMs = Number(match![1]) + // Zero (or missing) would flash on every launch, including the fast/already-registered + // path this delay exists to protect; too long would defeat the "no feedback during a + // fresh-install hang" fix End User flagged. Bounded to a sane window around the ~250-300ms + // suggestion rather than pinned to one exact value. + expect(delayMs).toBeGreaterThan(0) + expect(delayMs).toBeLessThanOrEqual(500) + }) + + test("the feedback timer is always cleared, even if autoRegisterWithin() throws", () => { + const source = readTui() + const start = source.indexOf("const registerFeedback = setTimeout(") + expect(start, "must find the registerFeedback timer declaration").toBeGreaterThan(-1) + const scope = source.slice(start, start + 400) + // Must be a try/finally around the await, not a bare await — a throw from + // autoRegisterWithin() (it currently never throws, but must not be relied upon) would + // otherwise leak the timer and could still print the status line after the process is + // already tearing down. + expect(scope).toMatch(/try\s*\{[\s\S]*?await FreeTier\.autoRegisterWithin\(\)[\s\S]*?\}\s*finally\s*\{[\s\S]*?clearTimeout\(registerFeedback\)/) + }) + + test("autoRegisterWithin() is still called with no arguments in tui.ts (unchanged contract: TUI renders its own onboarding, no headless callback)", () => { + const source = readTui() + // Regression guard: wrapping the call in a delayed-feedback block must not have also, + // accidentally, started passing a headless disclosure callback — the TUI's own + // useAltimateBaseDisclosureNotice() owns that surface; a second notice source here would + // double-print. + expect(source).toMatch(/await FreeTier\.autoRegisterWithin\(\)\s*$/m) + }) +})