From 977bb2884a4f29410f0e3e37d437f254e63d12c4 Mon Sep 17 00:00:00 2001 From: Sarav Date: Tue, 8 Sep 2026 13:59:52 +0530 Subject: [PATCH 1/6] feat: expose Altimate Base registration over HTTP for non-TUI hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Altimate Base could only be registered from the TUI. `registerAfterConsent` redeems a token against a module-private consent authority, and the only way to arm one is `FreeTierCapability.issueArmer()`, claimed at TUI worker boot. The VS Code extension talks HTTP to `altimate serve`, so it had no way to mint a Base credential: the model is advertised in `GET /provider`'s `all` but never appears in `connected`, and selecting it failed the next prompt with "model altimate-base not found". - Add `GET /altimate/base/disclosure` — returns the consent text and arms one single-use token. The only place an HTTP caller can obtain a token. - Add `POST /altimate/base/register` — redeems the token through the existing consent gate and returns its result taxonomy (`rate_limited` | `unavailable` | `network` | `error`) as a 200 body, the same values the TUI dialog renders. - Add `altimate/free/host.ts` — the entrypoint injects the gate, consumers read it. A route cannot claim `issueArmer()` itself: it throws on a second call, and the TUI worker already claims it while serving HTTP from the same process, so a route-level claim would break the TUI worker and any test importing both the server and the Base test harness. - `cli/cmd/serve.ts` claims the capability and injects the gate. The TUI worker deliberately does not, so its server answers 501 rather than offering a second registration surface that bypasses its own disclosure dialog. - Move the disclosure text to `@opencode-ai/core/altimate-base-disclosure` so the TUI dialog and the HTTP route share one definition. `packages/tui` depends on core, not on `opencode`, so core is the only home both can import; a new leaf file adds no upstream rebase surface. Registering over HTTP makes "the disclosure was actually shown" an assertion by the caller rather than a property enforced by construction, as it is in the TUI. Mitigated by minting a token only in the disclosure response, single use, and the consent store's existing 30s TTL. This is not a new privilege boundary — anything that can reach this server can already execute tools — but it is a deliberately narrower guarantee. Verified against a running `altimate serve` pointed at a local stub gateway: a forged token, a malformed token and a replayed token are all rejected; an issued token registers, writes the credential, and `altimate-free` joins `connected` after `POST /instance/dispose` (the provider loader caches its credential read, so the dispose is required). Also verified end to end in code-server against the production gateway. `bun turbo typecheck` clean across 13 packages; Base suites 47/47; TUI Base dialog suite 7/7. Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/src/altimate-base-disclosure.ts | 17 ++++++ .../opencode/src/altimate/free/consent.ts | 9 ++++ packages/opencode/src/altimate/free/host.ts | 39 ++++++++++++++ packages/opencode/src/cli/cmd/serve.ts | 21 ++++++++ packages/opencode/src/server/server.ts | 53 +++++++++++++++++++ .../tui/src/component/altimate-onboarding.tsx | 14 ++--- 6 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/altimate-base-disclosure.ts create mode 100644 packages/opencode/src/altimate/free/host.ts diff --git a/packages/core/src/altimate-base-disclosure.ts b/packages/core/src/altimate-base-disclosure.ts new file mode 100644 index 000000000..2bc921233 --- /dev/null +++ b/packages/core/src/altimate-base-disclosure.ts @@ -0,0 +1,17 @@ +// altimate_change start — the single definition of the Altimate Base consent disclosure. +// +// 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. +// +// A new leaf file rather than an addition to an existing core module, so it adds no upstream +// rebase surface. +// +// It discloses that requests are linkable across launches rather than deferring that to +// docs/docs/configure/providers.md, which a user never sees before accepting. Keep it in sync with +// that fuller "Data handling" 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. Logs are linked to a persistent per-installation identifier. Usage is rate limited." +// altimate_change end diff --git a/packages/opencode/src/altimate/free/consent.ts b/packages/opencode/src/altimate/free/consent.ts index cd36510d9..a1bc7f4fd 100644 --- a/packages/opencode/src/altimate/free/consent.ts +++ b/packages/opencode/src/altimate/free/consent.ts @@ -1,6 +1,15 @@ import { FreeTier } from "./client" import { FreeTierStore } from "./store" +/** + * The text a user consents against before any Base credential is minted, served to hosts that + * render their own disclosure (the VS Code extension's chat panel, via GET /altimate/base/disclosure). + * + * Defined once in `@opencode-ai/core/altimate-base-disclosure` and re-exported here, so the TUI + * dialog and this route can never drift apart. + */ +export { ALTIMATE_BASE_DISCLOSURE as DISCLOSURE } from "@opencode-ai/core/altimate-base-disclosure" + export type RegistrationResult = | { ok: true } | { diff --git a/packages/opencode/src/altimate/free/host.ts b/packages/opencode/src/altimate/free/host.ts new file mode 100644 index 000000000..2ef62b06b --- /dev/null +++ b/packages/opencode/src/altimate/free/host.ts @@ -0,0 +1,39 @@ +// altimate_change start — host-injected Altimate Base registration for non-TUI entrypoints. +// +// `FreeTierCapability.issueArmer()` is claimable exactly once per process and throws on a second +// call, so an HTTP route cannot claim one for itself: the TUI worker already claims it at boot for +// its own RPC gate, and that worker also serves HTTP from the same process. A route-level claim +// would therefore break the TUI worker the moment the routes module loaded — and any test that +// imported both the server and the Base test harness. +// +// Instead the entrypoint that owns the process claims the capability once and hands the resulting +// gate here. This is the same shape the TUI already uses for the same operation +// (`packages/tui/src/context/altimate-base-consent.tsx`): the host injects, the consumer checks. +// +// `altimate serve` provides a gate. The TUI worker deliberately does NOT — the TUI owns its own +// disclosure dialog, and a second registration surface inside that process would let a caller +// register without the dialog ever being shown. Consumers must treat `undefined` as "this host +// cannot register Altimate Base" and refuse, exactly as the TUI's provider picker does. +import type { createRegistrationConsentGate } from "./consent" + +export type Registration = ReturnType + +let registration: Registration | undefined + +/** + * Install the process's registration gate. Called once by the entrypoint, before the server starts + * accepting requests. A second call replaces the gate rather than throwing: the capability itself is + * already single-claim, so there is nothing to protect here, and throwing would only turn a + * misordered startup into a crash. + */ +export function provide(value: Registration): void { + registration = value +} + +/** The host-injected gate, or `undefined` when this host cannot register Altimate Base. */ +export function current(): Registration | undefined { + return registration +} + +export * as FreeTierHost from "./host" +// altimate_change end diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index b817cded2..06075d33e 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -8,6 +8,12 @@ import { subscribeTraceConsumer } from "../../altimate/observability/trace-consu // altimate_change start — self-update on headless serve startup import { scheduleStartupUpgradeCheck } from "./serve-upgrade-check" // altimate_change end +// altimate_change start — Altimate Base registration capability for the headless server +import { FreeTier } from "../../altimate/free/client" +import { FreeTierCapability } from "../../altimate/free/capability" +import { FreeTierConsent } from "../../altimate/free/consent" +import { FreeTierHost } from "../../altimate/free/host" +// altimate_change end export const ServeCommand = effectCmd({ command: "serve", @@ -25,6 +31,21 @@ export const ServeCommand = effectCmd({ // because it must be readable from every module realm. process.env["ALTIMATE_CODE_SERVE"] = "1" // altimate_change end + // altimate_change start — claim the process's one Altimate Base consent capability here, at the + // entrypoint, before the server can accept a request. `serve` is the extension's host and has no + // TUI to show the disclosure dialog, so the disclosure + registration routes are how a Base + // credential gets minted in this process. Claiming it here (rather than in the routes module) + // keeps the TUI worker — which claims the same capability for its own dialog — unaffected. + yield* Effect.sync(() => + FreeTierHost.provide( + FreeTierConsent.createRegistrationConsentGate({ + arm: FreeTierCapability.issueArmer(), + register: (token) => FreeTier.registerAfterConsent(token), + onUnexpectedError: (error) => console.error("[altimate-base] registration failed", error), + }), + ), + ) + // altimate_change end const { Server } = yield* Effect.promise(() => import("../../server/server")) if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index d8c9caf23..cf616e09c 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -39,6 +39,11 @@ import { managedWorkspaceLoaded } from "../altimate/workspace/engine-overlay" import { readMcpEntryFromDisk } from "../mcp/config" import { resolveConfigPath } from "../mcp/config" import { enhancePrompt, isAutoEnhanceEnabled } from "../altimate/enhance-prompt" +// altimate_change - Altimate Base disclosure + consent-gated registration for HTTP hosts +import { randomBytes } from "node:crypto" +import { FreeTier } from "../altimate/free/client" +import { FreeTierConsent } from "../altimate/free/consent" +import { FreeTierHost } from "../altimate/free/host" // altimate_change end import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" @@ -671,6 +676,54 @@ export namespace Server { }, ) // altimate_change end + // altimate_change start — Altimate Base disclosure + consent-gated registration + // Registration mints a persistent per-installation identifier and opts the user into request + // logging, so `FreeTier.registerAfterConsent` will only act on a token armed through the + // process's single private consent authority. The TUI arms that token from its disclosure + // dialog; these two routes are the equivalent for a host that renders its own disclosure + // (the VS Code extension's chat panel), and they are the ONLY place an HTTP caller can obtain + // a token: GET returns the exact text the user must see, and arms one token for it. + // + // A host that did not inject a gate (the TUI worker, which owns its own dialog) serves 501 + // rather than a registration surface that bypasses that dialog. + // + // Note this makes "the disclosure was actually shown" an assertion by the caller rather than + // a property enforced by construction, as it is in the TUI. Anything that can reach this + // server can already execute tools, so it is not a new privilege boundary — but it is a + // deliberate, narrower guarantee. + .get("/altimate/base/disclosure", async (c) => { + const gate = FreeTierHost.current() + if (!gate) { + return c.json({ error: "This host cannot register Altimate Base." }, 501) + } + // 32 bytes → the 64-hex shape ConsentCapabilityStore requires. Armed here and nowhere else, + // single-use, and expiring on the store's own TTL. + const token = randomBytes(32).toString("hex") + gate.setToken({ token }) + const registered = await FreeTier.isRegistered().catch((error) => { + log.warn("failed to read Altimate Base registration state", { error }) + return false + }) + return c.json({ disclosure: FreeTierConsent.DISCLOSURE, token, registered }) + }) + .post( + "/altimate/base/register", + validator("json", z.object({ token: z.string() })), + async (c) => { + const gate = FreeTierHost.current() + if (!gate) { + return c.json({ error: "This host cannot register Altimate Base." }, 501) + } + const { token } = c.req.valid("json") + // The gate maps every failure onto its own result taxonomy + // (rate_limited | unavailable | network | error) with a user-facing message, so the + // outcome is returned as a 200 body for the client to branch on — the same values the + // TUI's disclosure dialog renders. + const outcome = await gate.register({ token }) + return c.json(outcome) + }, + ) + // altimate_change end // altimate_change start — POST /altimate/mcp/reload-datamate // Updates the datamate MCP server config from IDE MCP config files and reconnects // the live MCP client so the new transport takes effect without a server restart. diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index 637263e16..74fd79fda 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -20,6 +20,8 @@ import { useSync } from "../context/sync" import { useToast } from "../ui/toast" // altimate_change — onboarding funnel telemetry seam import { useOnboardingTelemetry } from "../context/onboarding-telemetry" +// altimate_change — the Base consent disclosure has one definition, shared with the HTTP route +import { ALTIMATE_BASE_DISCLOSURE } from "@opencode-ai/core/altimate-base-disclosure" // Session-scoped "setup complete" flag. Set when the user picks a ready model, // chooses Altimate Base, or finishes the gateway flow. Combined with @@ -345,13 +347,11 @@ export function DialogModelWelcome(props: { ) } -// altimate_change start — surfaced in the DialogAltimateBaseConfirm consent gate before any Base -// credential is minted. This is the text a user actually consents against before any registration -// request, so it must disclose that requests are linkable across launches — not defer that to -// docs/docs/configure/providers.md, which a user never sees before accepting. Keep this in sync -// with that fuller "Data handling" 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. Logs are linked to a persistent per-installation identifier. Usage is rate limited." +// altimate_change start — surfaced in the DialogAltimateBaseConfirm consent gate below before any +// Base credential is minted. Defined once in core (imported at the top of this file) and +// re-exported here for existing consumers, so this dialog and the HTTP disclosure route +// (packages/opencode, for hosts that render their own dialog) cannot drift apart. +export { ALTIMATE_BASE_DISCLOSURE } // altimate_change end type RegisterOutcome = From cd8b94b5204f6b54d3418c9ab1379baaaa1578bc Mon Sep 17 00:00:00 2001 From: Sarav Date: Tue, 8 Sep 2026 14:36:12 +0530 Subject: [PATCH 2/6] fix: address multi-model review of the Base HTTP registration routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A three-model review (Claude, Gemini 3.1 Pro, GPT 5.2 Codex) returned NEEDS REVISION unanimously. This addresses every engine-side finding. The important one: `GET /altimate/base/disclosure` armed the consent token, so `ConsentCapabilityStore`'s 30s TTL started when the disclosure was FETCHED rather than when the user accepted it. Anyone who actually read the text before consenting was rejected with "consent expired" — the flow failed precisely for users exercising due diligence on a privacy disclosure, and passed for anyone who clicked through blind. The TUI never had this because it mints and redeems inside its accept handler (`cli/cmd/tui.ts:253-257`). A code comment in the extension claiming a stale card "re-fetches" was also simply false. Adopts the restructure all three reviewers recommended: - `GET /altimate/base/disclosure` is now read-only. It returns the text, the picker hint, `registered`, and the SHA-256 the client must echo back. It arms nothing, which also restores GET's safety semantics and removes the token- eviction race (the store holds at most 16 pending tokens and evicts FIFO). - `POST /altimate/base/register` mints, arms and redeems the token in one operation, so no TTL can elapse mid-flow. It requires the caller to echo the disclosure hash, which upgrades "the user saw the current disclosure" from an assertion by the caller to something the server checks: a client that never fetched the disclosure, or one showing superseded text, cannot register. - The register route now disposes the instance itself. The provider loader caches its credential read, so previously every client had to remember a follow-up `POST /instance/dispose` — and if that call failed, the client reported failure even though the credential was on disk, while other attached windows kept seeing `connected: false`. - Refuse registration when an `Origin` header is present and no server password is set. A page on a CORS-allowed origin can reach this port without being a local process, which is a different reachability class from "can already execute tools here". Native clients send no `Origin`, so they are unaffected. - `FreeTierHost.provide()` is single-shot and throws on reuse, matching `issueArmer`/`issueRedeemer`. Last-write-wins let in-process code swap the gate out from under the routes after `serve` installed it. - Both routes now declare `describeRoute` metadata, so they appear in the generated OpenAPI spec like every other route. - The picker hint joins the disclosure in `@opencode-ai/core/altimate-base-disclosure`. It had drifted into three variants across `dialog-provider.tsx`, `dialog-model.tsx` and the extension. Adds `test/server/altimate-base-registration.test.ts` — the previous revision shipped 146 lines with no tests, and nothing covered the 501-when-no-gate path that is the entire safety story for the TUI worker. 11 tests: both 501 paths, GET arming nothing, mint/arm/redeem identity, stale-hash refusal without touching the gate, case-insensitive hash, the 403 browser refusal, failure passthrough, validator rejection, and `provide()` single-shot. Verification: `bun turbo typecheck` clean across 13 packages; new suite 11/11; Base suites 58/58; TUI Base dialog suite 7/7. Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/src/altimate-base-disclosure.ts | 7 + .../opencode/src/altimate/free/consent.ts | 29 ++- packages/opencode/src/altimate/free/host.ts | 12 +- packages/opencode/src/server/server.ts | 171 +++++++++++++---- .../server/altimate-base-registration.test.ts | 175 ++++++++++++++++++ .../tui/src/component/altimate-onboarding.tsx | 4 +- packages/tui/src/component/dialog-model.tsx | 4 +- .../tui/src/component/dialog-provider.tsx | 4 +- 8 files changed, 362 insertions(+), 44 deletions(-) create mode 100644 packages/opencode/test/server/altimate-base-registration.test.ts diff --git a/packages/core/src/altimate-base-disclosure.ts b/packages/core/src/altimate-base-disclosure.ts index 2bc921233..f71e0a75b 100644 --- a/packages/core/src/altimate-base-disclosure.ts +++ b/packages/core/src/altimate-base-disclosure.ts @@ -14,4 +14,11 @@ // that fuller "Data handling" 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. Logs are linked to a persistent per-installation identifier. Usage is rate limited." + +/** + * The one-line subtitle shown next to Altimate Base in a provider or model picker. Shared for the + * same reason as the disclosure: it previously existed in three drifting variants across the TUI + * pickers and the extension. + */ +export const ALTIMATE_BASE_HINT = "free · no signup · rate limited" // altimate_change end diff --git a/packages/opencode/src/altimate/free/consent.ts b/packages/opencode/src/altimate/free/consent.ts index a1bc7f4fd..d0cc75164 100644 --- a/packages/opencode/src/altimate/free/consent.ts +++ b/packages/opencode/src/altimate/free/consent.ts @@ -1,14 +1,33 @@ +import { createHash } from "node:crypto" +import { ALTIMATE_BASE_DISCLOSURE } from "@opencode-ai/core/altimate-base-disclosure" import { FreeTier } from "./client" import { FreeTierStore } from "./store" /** - * The text a user consents against before any Base credential is minted, served to hosts that - * render their own disclosure (the VS Code extension's chat panel, via GET /altimate/base/disclosure). + * The text a user consents against before any Base credential is minted, plus the picker hint, + * served to hosts that render their own disclosure (the VS Code extension's chat panel, via + * GET /altimate/base/disclosure). * - * Defined once in `@opencode-ai/core/altimate-base-disclosure` and re-exported here, so the TUI - * dialog and this route can never drift apart. + * Both are defined once in `@opencode-ai/core/altimate-base-disclosure` and re-exported here, so + * the TUI dialog and this route can never drift apart. */ -export { ALTIMATE_BASE_DISCLOSURE as DISCLOSURE } from "@opencode-ai/core/altimate-base-disclosure" +export { + ALTIMATE_BASE_DISCLOSURE as DISCLOSURE, + ALTIMATE_BASE_HINT as HINT, +} from "@opencode-ai/core/altimate-base-disclosure" + +/** + * SHA-256 of the canonical disclosure, hex-encoded. + * + * `POST /altimate/base/register` requires the caller to echo this back, which is what turns "the + * user was shown the current disclosure" into something the server can check rather than merely + * trust. Not a secret — it is derived from public text — so a plain comparison is fine; its value + * is that a caller which never fetched the disclosure cannot produce it, and a stale client showing + * superseded text produces the wrong one. + */ +export function disclosureHash(): string { + return createHash("sha256").update(ALTIMATE_BASE_DISCLOSURE, "utf8").digest("hex") +} export type RegistrationResult = | { ok: true } diff --git a/packages/opencode/src/altimate/free/host.ts b/packages/opencode/src/altimate/free/host.ts index 2ef62b06b..707522a8c 100644 --- a/packages/opencode/src/altimate/free/host.ts +++ b/packages/opencode/src/altimate/free/host.ts @@ -22,11 +22,17 @@ let registration: Registration | undefined /** * Install the process's registration gate. Called once by the entrypoint, before the server starts - * accepting requests. A second call replaces the gate rather than throwing: the capability itself is - * already single-claim, so there is nothing to protect here, and throwing would only turn a - * misordered startup into a crash. + * accepting requests. + * + * Single-shot, matching every other capability in this area: a second call throws rather than + * silently replacing the gate. The earlier last-write-wins behaviour let any in-process caller swap + * the gate out from under the routes after `serve` installed the real one. That was never a + * privilege escalation — such code is already trusted and still cannot forge a token the private + * authority accepts — but it was a weaker invariant than `issueArmer()`/`issueRedeemer()` next door, + * for no benefit. */ export function provide(value: Registration): void { + if (registration) throw new Error("Altimate Base registration gate already provided for this process") registration = value } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index cf616e09c..69e34a368 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -678,48 +678,155 @@ export namespace Server { // altimate_change end // altimate_change start — Altimate Base disclosure + consent-gated registration // Registration mints a persistent per-installation identifier and opts the user into request - // logging, so `FreeTier.registerAfterConsent` will only act on a token armed through the - // process's single private consent authority. The TUI arms that token from its disclosure - // dialog; these two routes are the equivalent for a host that renders its own disclosure - // (the VS Code extension's chat panel), and they are the ONLY place an HTTP caller can obtain - // a token: GET returns the exact text the user must see, and arms one token for it. + // logging, so `FreeTier.registerAfterConsent` only acts on a token armed through the process's + // single private consent authority. The TUI arms that token inside its disclosure dialog's + // accept handler; these routes are the equivalent for a host that renders its own disclosure + // (the VS Code extension's chat panel). // - // A host that did not inject a gate (the TUI worker, which owns its own dialog) serves 501 - // rather than a registration surface that bypasses that dialog. + // A host that injected no gate (the TUI worker, which owns its own dialog) serves 501 rather + // than a second registration surface that could bypass that dialog. // - // Note this makes "the disclosure was actually shown" an assertion by the caller rather than - // a property enforced by construction, as it is in the TUI. Anything that can reach this - // server can already execute tools, so it is not a new privilege boundary — but it is a - // deliberate, narrower guarantee. - .get("/altimate/base/disclosure", async (c) => { - const gate = FreeTierHost.current() - if (!gate) { - return c.json({ error: "This host cannot register Altimate Base." }, 501) - } - // 32 bytes → the 64-hex shape ConsentCapabilityStore requires. Armed here and nowhere else, - // single-use, and expiring on the store's own TTL. - const token = randomBytes(32).toString("hex") - gate.setToken({ token }) - const registered = await FreeTier.isRegistered().catch((error) => { - log.warn("failed to read Altimate Base registration state", { error }) - return false - }) - return c.json({ disclosure: FreeTierConsent.DISCLOSURE, token, registered }) - }) + // GET is deliberately READ-ONLY. An earlier revision armed the consent token here, which + // meant the store's 30s TTL began when the disclosure was fetched rather than when the user + // accepted it — so anyone who actually read the text before consenting was rejected. That + // also made GET non-idempotent and let a burst of fetches evict pending tokens. The token is + // now minted, armed and redeemed entirely inside POST, in one operation. + .get( + "/altimate/base/disclosure", + describeRoute({ + summary: "Get the Altimate Base consent disclosure", + description: + "Returns the text a user must accept before Altimate Base is registered, the picker hint, whether this installation is already registered, and the SHA-256 the client must echo back to POST /altimate/base/register. Read-only.", + operationId: "altimateBase.disclosure", + responses: { + 200: { + description: "Disclosure text and its hash", + content: { + "application/json": { + schema: resolver( + z.object({ + disclosure: z.string(), + hint: z.string(), + sha256: z.string(), + registered: z.boolean(), + }), + ), + }, + }, + }, + 501: { + description: "This host cannot register Altimate Base", + content: { "application/json": { schema: resolver(z.object({ error: z.string() })) } }, + }, + }, + }), + async (c) => { + if (!FreeTierHost.current()) { + return c.json({ error: "This host cannot register Altimate Base." }, 501) + } + const registered = await FreeTier.isRegistered().catch((error) => { + log.warn("failed to read Altimate Base registration state", { error }) + return false + }) + return c.json({ + disclosure: FreeTierConsent.DISCLOSURE, + hint: FreeTierConsent.HINT, + sha256: FreeTierConsent.disclosureHash(), + registered, + }) + }, + ) .post( "/altimate/base/register", - validator("json", z.object({ token: z.string() })), + describeRoute({ + summary: "Register Altimate Base after consent", + description: + "Mints the managed Altimate Base credential. The caller must echo the SHA-256 of the disclosure it displayed, which is verified against the canonical text, so a caller that never fetched the current disclosure cannot register. Disposes the instance on success so the provider loader re-reads the new credential.", + operationId: "altimateBase.register", + responses: { + 200: { + description: "Registration outcome", + content: { + "application/json": { + schema: resolver( + z.union([ + z.object({ ok: z.literal(true) }), + z.object({ + ok: z.literal(false), + result: z.enum(["rate_limited", "unavailable", "network", "error"]), + message: z.string(), + }), + ]), + ), + }, + }, + }, + ...errors(400), + 403: { + description: "Refused: browser origin on an unsecured server", + content: { "application/json": { schema: resolver(z.object({ error: z.string() })) } }, + }, + 501: { + description: "This host cannot register Altimate Base", + content: { "application/json": { schema: resolver(z.object({ error: z.string() })) } }, + }, + }, + }), + validator("json", z.object({ acceptedDisclosureSha256: z.string() })), async (c) => { const gate = FreeTierHost.current() if (!gate) { return c.json({ error: "This host cannot register Altimate Base." }, 501) } - const { token } = c.req.valid("json") - // The gate maps every failure onto its own result taxonomy - // (rate_limited | unavailable | network | error) with a user-facing message, so the - // outcome is returned as a 200 body for the client to branch on — the same values the - // TUI's disclosure dialog renders. + + // A browser on a CORS-allowed origin can reach this port without being a local process, + // which is a different reachability class from "can already execute tools here". Native + // clients (the extension host, curl) send no Origin, so refusing an Origin-bearing + // request on an unsecured server closes that vector without affecting them. When a server + // password is set the global basicAuth middleware has already authenticated the caller. + if (c.req.header("origin") && !Flag.OPENCODE_SERVER_PASSWORD) { + log.warn("refused browser-originated Altimate Base registration on an unsecured server", { + origin: c.req.header("origin"), + }) + return c.json( + { + error: + "Altimate Base cannot be registered from a browser origin on an unsecured server. Set OPENCODE_SERVER_PASSWORD.", + }, + 403, + ) + } + + const { acceptedDisclosureSha256 } = c.req.valid("json") + if (acceptedDisclosureSha256.toLowerCase() !== FreeTierConsent.disclosureHash()) { + // Either a stale client showing superseded text, or a caller that never fetched the + // disclosure at all. Both must fail: this hash is what makes "the user saw the current + // disclosure" checkable rather than merely asserted. + return c.json( + { + ok: false as const, + result: "error" as const, + message: "The accepted disclosure is out of date. Reopen setup and try again.", + }, + 200, + ) + } + + // Mint, arm and redeem in one operation, mirroring the TUI's accept handler. Nothing + // outside this handler can obtain a token that the private authority will accept. + const token = randomBytes(32).toString("hex") + gate.setToken({ token }) const outcome = await gate.register({ token }) + + // The provider loader caches its credential read, so a freshly registered Base stays out + // of `/provider`'s `connected` list until the instance is dropped. Doing it here rather + // than making every client remember keeps that invariant server-side, and means other + // attached windows see the new provider too. + if (outcome.ok) { + await Instance.dispose().catch((error) => + log.error("Altimate Base registered but instance dispose failed", { error }), + ) + } return c.json(outcome) }, ) diff --git a/packages/opencode/test/server/altimate-base-registration.test.ts b/packages/opencode/test/server/altimate-base-registration.test.ts new file mode 100644 index 000000000..58556e2a5 --- /dev/null +++ b/packages/opencode/test/server/altimate-base-registration.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeAll, describe, expect, test } from "bun:test" +import { Server } from "../../src/server/server" +import { FreeTierConsent } from "../../src/altimate/free/consent" +import { FreeTierHost } from "../../src/altimate/free/host" +import { resetDatabase } from "./db" +import { disposeAllInstances } from "../fixture/fixture" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function app() { + return Server.Default() +} + +// TOPOLOGY NOTE — this file is order-dependent, deliberately. +// +// `FreeTierHost.provide()` installs process-wide module state and is single-shot (a second call +// throws, matching `issueArmer`/`issueRedeemer` next door). `bun test` can load several suite files +// into ONE worker process, so the "no gate injected" case can only be observed before anything in +// the process provides one. The 501 block therefore runs first, and the gate is installed exactly +// once in `beforeAll` of the block after it. +// +// No other file calls `provide` — `cli/cmd/serve.ts` does it inside its command handler, not at +// import — so importing the server here does not arm anything on its own. + +describe("Altimate Base registration — no gate injected (the TUI-worker shape)", () => { + // Ordering: must precede the provided-gate block below. + test("GET /altimate/base/disclosure serves 501 rather than a disclosure", async () => { + const response = await app().request("/altimate/base/disclosure") + expect(response.status).toBe(501) + expect(await response.json()).toMatchObject({ error: expect.stringContaining("cannot register") }) + }) + + test("POST /altimate/base/register serves 501 rather than registering", async () => { + const response = await app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ acceptedDisclosureSha256: FreeTierConsent.disclosureHash() }), + }) + expect(response.status).toBe(501) + }) +}) + +describe("Altimate Base registration — gate injected (the `serve` shape)", () => { + // Records what the route asked the gate to do, so the test can assert the route mints/arms/ + // redeems in one operation instead of handing a token to the client. + const armed: string[] = [] + const redeemed: string[] = [] + let outcome: Awaited> = { ok: true } + + beforeAll(() => { + FreeTierHost.provide({ + setToken({ token }) { + armed.push(token) + }, + async register({ token }) { + redeemed.push(token) + return outcome + }, + }) + }) + + test("provide() is single-shot", () => { + expect(() => + FreeTierHost.provide({ setToken() {}, register: async () => ({ ok: true }) }), + ).toThrow(/already provided/) + }) + + test("GET disclosure is read-only: returns text, hint and hash, and arms no token", async () => { + const before = armed.length + const response = await app().request("/altimate/base/disclosure") + expect(response.status).toBe(200) + const body = (await response.json()) as { + disclosure: string + hint: string + sha256: string + registered: boolean + } + expect(body.disclosure).toBe(FreeTierConsent.DISCLOSURE) + expect(body.hint).toBe(FreeTierConsent.HINT) + expect(body.sha256).toBe(FreeTierConsent.disclosureHash()) + expect(typeof body.registered).toBe("boolean") + // The regression this guards: arming here started the consent store's 30s TTL when the + // disclosure was fetched, so a user who read it before consenting was rejected. + expect(armed.length).toBe(before) + }) + + test("register mints, arms and redeems the same token in one operation", async () => { + armed.length = 0 + redeemed.length = 0 + outcome = { ok: true } + const response = await app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ acceptedDisclosureSha256: FreeTierConsent.disclosureHash() }), + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + expect(armed).toHaveLength(1) + expect(armed[0]).toMatch(/^[0-9a-f]{64}$/) + expect(redeemed).toEqual(armed) + }) + + test("a stale or absent disclosure hash is refused without touching the gate", async () => { + armed.length = 0 + redeemed.length = 0 + const response = await app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ acceptedDisclosureSha256: "0".repeat(64) }), + }) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ ok: false, result: "error" }) + expect(armed).toHaveLength(0) + expect(redeemed).toHaveLength(0) + }) + + test("the hash comparison is case-insensitive on the client's hex", async () => { + armed.length = 0 + outcome = { ok: true } + const response = await app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ acceptedDisclosureSha256: FreeTierConsent.disclosureHash().toUpperCase() }), + }) + expect(await response.json()).toEqual({ ok: true }) + expect(armed).toHaveLength(1) + }) + + test("a browser-originated request is refused on an unsecured server", async () => { + armed.length = 0 + const response = await app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://localhost:3000" }, + body: JSON.stringify({ acceptedDisclosureSha256: FreeTierConsent.disclosureHash() }), + }) + expect(response.status).toBe(403) + // Nothing was minted: a CORS-allowed page cannot opt the installation into request logging. + expect(armed).toHaveLength(0) + }) + + test("a gate failure is passed through with its result taxonomy intact", async () => { + outcome = { ok: false, result: "rate_limited", message: "Too many requests." } + const response = await app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ acceptedDisclosureSha256: FreeTierConsent.disclosureHash() }), + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: false, + result: "rate_limited", + message: "Too many requests.", + }) + outcome = { ok: true } + }) + + test("a malformed body is rejected by the validator", async () => { + const response = await app().request("/altimate/base/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nope: true }), + }) + expect(response.status).toBe(400) + }) +}) + +describe("disclosure hash", () => { + test("is a stable hex sha256 of the canonical text", () => { + expect(FreeTierConsent.disclosureHash()).toMatch(/^[0-9a-f]{64}$/) + expect(FreeTierConsent.disclosureHash()).toBe(FreeTierConsent.disclosureHash()) + }) +}) diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index 74fd79fda..8485b1646 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -21,7 +21,7 @@ import { useToast } from "../ui/toast" // altimate_change — onboarding funnel telemetry seam import { useOnboardingTelemetry } from "../context/onboarding-telemetry" // altimate_change — the Base consent disclosure has one definition, shared with the HTTP route -import { ALTIMATE_BASE_DISCLOSURE } from "@opencode-ai/core/altimate-base-disclosure" +import { ALTIMATE_BASE_DISCLOSURE, ALTIMATE_BASE_HINT } from "@opencode-ai/core/altimate-base-disclosure" // Session-scoped "setup complete" flag. Set when the user picks a ready model, // chooses Altimate Base, or finishes the gateway flow. Combined with @@ -187,7 +187,7 @@ export function DialogModelWelcome(props: { ? [ { name: "Altimate Base", - note: "free · no signup · rate limited", + note: ALTIMATE_BASE_HINT, tone: "warning" as const, providerID: "altimate-free", modelID: "altimate-base", diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 80c896d39..92fe96a42 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -25,6 +25,8 @@ import { useConnected } from "./use-connected" 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 — one definition of the Base picker hint +import { ALTIMATE_BASE_HINT } from "@opencode-ai/core/altimate-base-disclosure" // altimate_change start — DialogModel restructured from the upstream flat // favorites/recent/provider list into READY / NEEDS-SETUP sections with an Altimate Base @@ -175,7 +177,7 @@ export function DialogModel(props: { const altimateBase = { value: "altimate-base" as { providerID: string; modelID: string } | string, title: "Altimate Base", - description: "free, no signup — rate limited", + description: ALTIMATE_BASE_HINT, category: "NEEDS SETUP", footer: undefined as string | undefined, onSelect() { diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index 1e0ec165a..64c63fbb6 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -30,6 +30,8 @@ import { // altimate_change end // altimate_change start — first-run provider selection telemetry import { useOnboardingTelemetry } from "../context/onboarding-telemetry" +// altimate_change — one definition of the Base picker hint +import { ALTIMATE_BASE_HINT } from "@opencode-ai/core/altimate-base-disclosure" // altimate_change end export const PROVIDER_PRIORITY: Record = { @@ -94,7 +96,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 · rate limited", + "altimate-free": ALTIMATE_BASE_HINT, opencode: "Bring your own Zen key", "opencode-go": "Low cost subscription for everyone", }[provider.id], From b2c282411c47633ec8b4e2aca567edff8b54c40b Mon Sep 17 00:00:00 2001 From: Sarav Date: Tue, 8 Sep 2026 14:49:53 +0530 Subject: [PATCH 3/6] fix: use the project logger for Base registration errors in serve Review P2: the gate's onUnexpectedError used console.error while the routes use log.warn. Both now go through Log.create({ service: "serve" }), matching serve-upgrade-check.ts next door. Co-Authored-By: Claude Opus 5 (1M context) --- packages/opencode/src/cli/cmd/serve.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 06075d33e..d4061612d 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -13,8 +13,11 @@ import { FreeTier } from "../../altimate/free/client" import { FreeTierCapability } from "../../altimate/free/capability" import { FreeTierConsent } from "../../altimate/free/consent" import { FreeTierHost } from "../../altimate/free/host" +import { Log } from "../../util/log" // altimate_change end +const log = Log.create({ service: "serve" }) + export const ServeCommand = effectCmd({ command: "serve", builder: (yargs) => withNetworkOptions(yargs), @@ -41,7 +44,7 @@ export const ServeCommand = effectCmd({ FreeTierConsent.createRegistrationConsentGate({ arm: FreeTierCapability.issueArmer(), register: (token) => FreeTier.registerAfterConsent(token), - onUnexpectedError: (error) => console.error("[altimate-base] registration failed", error), + onUnexpectedError: (error) => log.error("Altimate Base registration failed", { error }), }), ), ) From 6eb2b09b67e97e442552dc14fc18661522b35dd4 Mon Sep 17 00:00:00 2001 From: Sarav Date: Tue, 8 Sep 2026 16:34:43 +0530 Subject: [PATCH 4/6] fix: correct two false claims and harden the Base registration invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two statements in the previous commits on this branch were wrong, and are corrected both in the code and in the record: 1. "server-side dispose also refreshes other windows attached to the same `serve` process" — false. `Instance.dispose()` only evicts `cache.delete(directory)` for the directory the request carried, so a multi-root workspace or a second window kept a provider list in which Base was still disconnected. The route now calls `Instance.disposeAll()`, and returns `staleProviders: true` when disposal fails rather than reporting plain success over a stale list. 2. The disclosure-hash check "converts 'the user was shown the current disclosure' from an assertion by the caller into something checkable" — overstated. It makes *"the caller holds the current text"* checkable: a client rendering superseded wording cannot register, which is worth having, but any caller can GET the disclosure and echo the hash. Whether a human read it remains an assertion, as the first reviewer originally said. `consent.ts` and `server.ts` now say so. Also, the claim about who may arm the consent authority had been paraphrased in three files and gone stale in two when `serve` became a second claimer. `capability.ts` now owns the canonical statement and lists both entrypoints; `client.ts` and `worker.ts` point at it instead of restating it. `test/altimate/altimate-base-armer-callsites.test.ts` enforces that list against the source, so adding a claimer fails there and forces the docstring to be revised with it. It strips comments before matching — the first version counted prose in `host.ts` as a call site. `test/altimate/altimate-base-disclosure-claims.test.ts` covers the other axis that had no mechanism: the gate versus the fuller "Data handling" note in docs/docs/configure/providers.md. It asserts the gate carries logging, model use, don't-send-secrets and rate limits whatever the wording, that the docs remain a superset, and it pins the known "may be logged" / "are logged" deviation so it cannot drift further unnoticed. The TUI dialog test now asserts WHICH option is default by checking the cursor position. It previously checked only that the string "(default)" appeared somewhere, so it passed unchanged through an inversion of the default and back — it asserted its own name away. A companion test covers Return declining; that path was exercised nowhere, and `pressKey("return")` sends the literal characters r,e,t,u,r,n (the Enter key is the uppercase `RETURN` KeyCodes name). Verification: `bun turbo typecheck` clean across 13 packages; Base and route suites 67/67; TUI Base dialog suite 8/8. Co-Authored-By: Claude Opus 5 (1M context) --- .../opencode/src/altimate/free/capability.ts | 16 +++- packages/opencode/src/altimate/free/client.ts | 12 ++- .../opencode/src/altimate/free/consent.ts | 12 +-- packages/opencode/src/cli/tui/worker.ts | 5 +- packages/opencode/src/server/server.ts | 36 ++++++--- .../altimate-base-armer-callsites.test.ts | 74 +++++++++++++++++++ .../altimate-base-disclosure-claims.test.ts | 74 +++++++++++++++++++ .../cli/tui/dialog-altimate-base.test.tsx | 28 ++++++- 8 files changed, 233 insertions(+), 24 deletions(-) create mode 100644 packages/opencode/test/altimate/altimate-base-armer-callsites.test.ts create mode 100644 packages/opencode/test/altimate/altimate-base-disclosure-claims.test.ts diff --git a/packages/opencode/src/altimate/free/capability.ts b/packages/opencode/src/altimate/free/capability.ts index d61f7301b..e692c1abf 100644 --- a/packages/opencode/src/altimate/free/capability.ts +++ b/packages/opencode/src/altimate/free/capability.ts @@ -66,9 +66,19 @@ let redeemerIssued = false /** * Hands out the ability to arm Altimate Base's production consent authority. Callable exactly - * once per process: a second call throws. The sole legitimate caller is the registration consent - * gate built once at TUI worker boot (`cli/tui/worker.ts`), before any plugin, tool, or session - * code has a chance to run. Because this is the only way to arm the authority that + * once per process: a second call throws. + * + * THIS DOCSTRING IS THE CANONICAL DESCRIPTION of who may claim it. `client.ts` and the entrypoints + * point here rather than restating it — the claim was previously paraphrased in three files and went + * stale in two of them when a second entrypoint was added. + * + * Legitimate callers, one per process, each owning a surface that shows a disclosure: + * - `cli/tui/worker.ts` — the terminal consent dialog + * - `cli/cmd/serve.ts` — the consent-gated HTTP routes the VS Code extension drives + * `test/altimate/altimate-base-armer-callsites.test.ts` asserts that list against the source, so + * adding a claimer fails there and forces this comment to be revisited. + * + * Because this is the only way to arm the authority that * `registerAfterConsent` checks against, no other in-process code — however it constructs its * own `ConsentCapabilityStore` or calls this function again — can mint a token that will ever be * accepted; a self-armed store only ever validates against itself. diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 6ac672d7d..751ab181c 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -345,9 +345,15 @@ async function registerOnce( * by the discipline of its callers. A caller cannot forge a token by constructing their own * `ConsentCapabilityStore`: that class's `arm`/`consume` only ever validate against the instance * you built, and the ONE instance this function actually checks is never exported — the only way - * to arm it is `FreeTierCapability.issueArmer()`, claimed once by the TUI worker's consent gate at - * boot. A future CLI, HTTP route, or plugin cannot register by importing this: it would have to - * obtain a token minted by that gate. Provider discovery and inference never call it. + * to arm it is `FreeTierCapability.issueArmer()`, which is claimable exactly once per process. See + * that function's docstring for which entrypoints may claim it — deliberately not repeated here. + * + * So importing this function is not enough to register: a caller must obtain a token minted by + * whichever gate claimed the armer in its process. Provider discovery and inference never call it. + * + * What this guarantees: the token is authentic. What it does not: that a human read anything. Over + * HTTP that remains an assertion by the caller, narrowed only by the disclosure-hash check in the + * register route. */ export async function registerAfterConsent( token: string, diff --git a/packages/opencode/src/altimate/free/consent.ts b/packages/opencode/src/altimate/free/consent.ts index d0cc75164..721fbf86f 100644 --- a/packages/opencode/src/altimate/free/consent.ts +++ b/packages/opencode/src/altimate/free/consent.ts @@ -19,11 +19,13 @@ export { /** * SHA-256 of the canonical disclosure, hex-encoded. * - * `POST /altimate/base/register` requires the caller to echo this back, which is what turns "the - * user was shown the current disclosure" into something the server can check rather than merely - * trust. Not a secret — it is derived from public text — so a plain comparison is fine; its value - * is that a caller which never fetched the disclosure cannot produce it, and a stale client showing - * superseded text produces the wrong one. + * `POST /altimate/base/register` requires the caller to echo this back. This is a **text-version + * agreement, not proof of consent**: it establishes that the caller holds the current disclosure, + * so a client still rendering superseded wording cannot register people against text they were + * never shown. It does NOT establish that a human read anything — any caller can GET the disclosure + * and echo the hash. Whether a person actually saw the text remains an assertion by the caller. + * + * Not a secret (it is derived from public text), so a plain comparison is fine. */ export function disclosureHash(): string { return createHash("sha256").update(ALTIMATE_BASE_DISCLOSURE, "utf8").digest("hex") diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index d32f93a9b..b3ff5b558 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -68,8 +68,9 @@ GlobalBus.on("event", (event) => { let server: Awaited> | undefined // altimate_change start — worker-local, expiring capabilities gate every registration mutation. -// `issueArmer()` can succeed exactly once per process; this is that one legitimate call — see -// capability.ts for why that makes the resulting token unforgeable by any other in-process code. +// `issueArmer()` can succeed exactly once per process; this is this process's claim — see +// capability.ts for the canonical list of entrypoints that may claim it, and for why that makes the +// resulting token unforgeable by any other in-process code. const altimateBaseRegistration = FreeTierConsent.createRegistrationConsentGate({ arm: FreeTierCapability.issueArmer(), register: (token) => FreeTier.registerAfterConsent(token), diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 69e34a368..4efb705e9 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -686,6 +686,9 @@ export namespace Server { // A host that injected no gate (the TUI worker, which owns its own dialog) serves 501 rather // than a second registration surface that could bypass that dialog. // + // The register route additionally requires the caller to echo the disclosure's SHA-256. That + // is a text-version check, not consent enforcement — see the comment at the comparison below. + // // GET is deliberately READ-ONLY. An earlier revision armed the consent token here, which // meant the store's 30s TTL began when the disclosure was fetched rather than when the user // accepted it — so anyone who actually read the text before consenting was rejected. That @@ -750,7 +753,7 @@ export namespace Server { "application/json": { schema: resolver( z.union([ - z.object({ ok: z.literal(true) }), + z.object({ ok: z.literal(true), staleProviders: z.literal(true).optional() }), z.object({ ok: z.literal(false), result: z.enum(["rate_limited", "unavailable", "network", "error"]), @@ -799,9 +802,11 @@ export namespace Server { const { acceptedDisclosureSha256 } = c.req.valid("json") if (acceptedDisclosureSha256.toLowerCase() !== FreeTierConsent.disclosureHash()) { - // Either a stale client showing superseded text, or a caller that never fetched the - // disclosure at all. Both must fail: this hash is what makes "the user saw the current - // disclosure" checkable rather than merely asserted. + // A text-version agreement, NOT proof of consent. It establishes that the caller holds + // the current disclosure, so a client rendering superseded wording cannot register + // people against text they were never shown. It does not establish that a human read + // anything — any caller can GET the disclosure and echo the hash back. "A human saw + // this" remains an assertion by the caller, exactly as it was. return c.json( { ok: false as const, @@ -819,13 +824,26 @@ export namespace Server { const outcome = await gate.register({ token }) // The provider loader caches its credential read, so a freshly registered Base stays out - // of `/provider`'s `connected` list until the instance is dropped. Doing it here rather - // than making every client remember keeps that invariant server-side, and means other - // attached windows see the new provider too. + // of `/provider`'s `connected` list until the instance cache is dropped. Doing it here + // rather than making every client remember keeps the invariant server-side. + // + // `disposeAll()`, not `dispose()`: the Base credential is a single global file, but + // `Instance.dispose()` only evicts `cache.delete(Instance.directory)` — the directory this + // request happened to carry. A multi-root workspace, or several windows against one + // `serve`, would keep every other instance's provider list showing Base as disconnected. + // Invalidation has to be as wide as the state that changed. + // + // A failure here leaves the credential written but provider lists stale, so it is reported + // rather than swallowed: the client needs to know its picker may be out of date. if (outcome.ok) { - await Instance.dispose().catch((error) => - log.error("Altimate Base registered but instance dispose failed", { error }), + const disposed = await Instance.disposeAll().then( + () => true, + (error) => { + log.error("Altimate Base registered but instance disposal failed", { error }) + return false + }, ) + return c.json(disposed ? outcome : { ...outcome, staleProviders: true as const }) } return c.json(outcome) }, diff --git a/packages/opencode/test/altimate/altimate-base-armer-callsites.test.ts b/packages/opencode/test/altimate/altimate-base-armer-callsites.test.ts new file mode 100644 index 000000000..e4aa69002 --- /dev/null +++ b/packages/opencode/test/altimate/altimate-base-armer-callsites.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +// `FreeTierCapability.issueArmer()` is the one way to arm the consent authority that +// `registerAfterConsent` checks, so *which entrypoints claim it* is a security-relevant fact. It +// was described in prose in three files, and when a second entrypoint was added the prose in two of +// them silently became false — the same failure happened repeatedly across four review rounds. +// +// Prose cannot police itself, so this does. If someone adds a claimer, this fails and points at the +// canonical docstring that has to be updated with it. + +const SRC = path.join(import.meta.dir, "../../src") + +/** Entrypoints allowed to claim the armer, each owning a surface that shows a disclosure. */ +const EXPECTED_CLAIMERS = ["cli/cmd/serve.ts", "cli/tui/worker.ts"] + +/** + * Strips comments before matching, because several files legitimately *discuss* these functions in + * prose — `host.ts` explains the capability model in its header. A naive grep counted those as call + * sites, which is exactly the kind of false signal that makes a lint-style test worse than none. + */ +function code(file: string): string { + return fs + .readFileSync(file, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/(^|[^:])\/\/.*$/gm, "$1") +} + +function sourceFiles(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) return sourceFiles(full) + return entry.isFile() && /\.tsx?$/.test(entry.name) ? [full] : [] + }) +} + +describe("Altimate Base consent armer call sites", () => { + test("only the documented entrypoints claim issueArmer()", () => { + const claimers = sourceFiles(SRC) + .filter((file) => { + // The declaration in capability.ts is not a call site. + if (file.endsWith(path.join("altimate", "free", "capability.ts"))) return false + return /\bissueArmer\s*\(/.test(code(file)) + }) + .map((file) => path.relative(SRC, file).split(path.sep).join("/")) + .sort() + + expect( + claimers, + "A file now claims the Base consent armer that the canonical docstring in " + + "src/altimate/free/capability.ts does not list. Add it there (and to EXPECTED_CLAIMERS here) " + + "only if it genuinely owns a surface that shows the disclosure first.", + ).toEqual([...EXPECTED_CLAIMERS].sort()) + }) + + test("capability.ts names exactly those entrypoints in its canonical docstring", () => { + // Keeps the prose and the enforced list from drifting apart in the other direction. + const capability = fs.readFileSync(path.join(SRC, "altimate/free/capability.ts"), "utf8") + for (const claimer of EXPECTED_CLAIMERS) { + expect(capability, `capability.ts does not mention ${claimer}`).toContain(claimer) + } + }) + + test("the redeemer stays claimed by registerAfterConsent alone", () => { + const redeemers = sourceFiles(SRC) + .filter((file) => { + if (file.endsWith(path.join("altimate", "free", "capability.ts"))) return false + return /\bissueRedeemer\s*\(/.test(code(file)) + }) + .map((file) => path.relative(SRC, file).split(path.sep).join("/")) + expect(redeemers).toEqual(["altimate/free/client.ts"]) + }) +}) diff --git a/packages/opencode/test/altimate/altimate-base-disclosure-claims.test.ts b/packages/opencode/test/altimate/altimate-base-disclosure-claims.test.ts new file mode 100644 index 000000000..04745f306 --- /dev/null +++ b/packages/opencode/test/altimate/altimate-base-disclosure-claims.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test" +import fs from "node:fs" +import path from "node:path" +import { ALTIMATE_BASE_DISCLOSURE, ALTIMATE_BASE_HINT } from "@opencode-ai/core/altimate-base-disclosure" + +// Requirement 5 gave the disclosure ONE definition, which removed drift between the TUI gate and the +// HTTP disclosure route. It did nothing for the other consistency axis: the gate versus the fuller +// "Data handling" note in docs/docs/configure/providers.md. That axis had no mechanism except a +// comment saying "keep in sync" — and comments saying that had already failed three times in this +// feature. This file is the mechanism. +// +// The gate is deliberately a short summary, so it is NOT required to repeat everything the docs say +// (the per-installation identifier detail lives in docs only, per #1268). What it must never do is +// drop or weaken a *core* data term, because it is the only text a user reads before consenting. +// The terminal gate defaults to "No", so a stray Return declines rather than accepts — asserted in +// packages/tui/test/cli/tui/dialog-altimate-base.test.tsx. + +const DOCS = path.join(import.meta.dir, "../../../../docs/docs/configure/providers.md") + +/** The claims the consent gate must carry, whatever the wording. */ +const REQUIRED = [ + { name: "logging", pattern: /logged/i }, + { name: "used to train or improve models", pattern: /train|improve/i }, + { name: "do not send secrets", pattern: /secret|confidential/i }, + { name: "rate limiting", pattern: /rate.?limit/i }, +] + +describe("Altimate Base consent gate", () => { + test("carries every core data term", () => { + for (const claim of REQUIRED) { + expect( + claim.pattern.test(ALTIMATE_BASE_DISCLOSURE), + `the consent gate no longer states: ${claim.name}`, + ).toBe(true) + } + }) + + test("is a single sentence-per-term summary, not a wall of text", () => { + // A gate nobody reads is worse than a short one. If it grows past this, the extra belongs in + // the docs note instead. + expect(ALTIMATE_BASE_DISCLOSURE.length).toBeLessThan(400) + }) + + test("does not repeat the per-installation identifier detail (#1268 — docs own it)", () => { + expect(ALTIMATE_BASE_DISCLOSURE).not.toContain("per-installation identifier") + }) + + test("the docs note still discloses everything the gate summarises, plus the linkage detail", () => { + // If this fails, the docs were trimmed below the gate — the wrong direction. The gate is the + // summary; the docs must remain the superset. + const docs = fs.readFileSync(DOCS, "utf8") + for (const claim of REQUIRED) { + expect(claim.pattern.test(docs), `the docs no longer state: ${claim.name}`).toBe(true) + } + expect(docs).toContain("per-installation identifier") + }) + + test("KNOWN DEVIATION: the gate hedges logging where the docs state it unconditionally", () => { + // Accepted deliberately by the product owner. Recorded as a test rather than a comment so it + // stays visible and cannot drift further by accident. + // + // Gate: "Your requests may be logged ..." Docs: "Requests and responses are logged ..." + // + // If the gate is ever strengthened to match the docs, DELETE this test — do not relax it. If it + // starts failing, someone changed the hedge without deciding what it should now say. + const docs = fs.readFileSync(DOCS, "utf8") + expect(docs).toContain("are logged") + expect(ALTIMATE_BASE_DISCLOSURE).toContain("may be logged") + }) + + test("the picker hint stays short enough for one line", () => { + expect(ALTIMATE_BASE_HINT.length).toBeLessThan(60) + }) +}) diff --git a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx index a25e0bd18..c9dd24171 100644 --- a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx +++ b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx @@ -205,13 +205,21 @@ test.serial("Altimate Base shows the privacy disclosure before registration and const confirm = await mountConfirm() try { const frame = confirm.app.captureCharFrame() + const flat = frame.replace(/\s+/g, " ") expect(confirm.disclosure).toContain("Requests and responses may be logged and used") // The persistent per-install-id linkage line is intentionally not in the gate (it lives in docs). expect(confirm.disclosure).not.toContain("per-installation identifier") expect(frame).toContain("Use Altimate Base?") - expect(frame.replace(/\s+/g, " ")).toContain("Requests and responses may be logged and used") + expect(flat).toContain("Requests and responses may be logged and used") + // Both options are always visible. expect(frame).toContain("No — pick something else") - expect(frame).toContain("(default)") + expect(frame).toContain("Yes — use Altimate Base") + // Assert WHICH option is default, not merely that the word appears. The previous version of + // this test checked only `toContain("(default)")` and the presence of the No label, so it + // passed both before and after the default was inverted — it asserted its own name away. + // The cursor glyph marks the selected row, and Return runs it. + expect(flat).toContain("› No — pick something else (default)") + expect(flat).not.toContain("› Yes — use Altimate Base") expect(confirm.registrations()).toHaveLength(0) expect(confirm.events).toEqual([{ name: "altimate_base_confirm_shown", origin: "welcome" }]) } finally { @@ -219,6 +227,22 @@ test.serial("Altimate Base shows the privacy disclosure before registration and } }) +test.serial("Return declines, because No is the default — it must never register", async () => { + const confirm = await mountConfirm() + try { + // NOTE: KeyInput is `string | keyof typeof KeyCodes`, so a lowercase "return" would be sent as + // the literal characters r,e,t,u,r,n. The Enter key is the uppercase KeyCodes name — nothing + // else in this suite exercises it, so this path was previously unverified in either direction. + confirm.app.mockInput.pressKey("RETURN") + await waitUntil(() => confirm.events.some((event) => event.name === "altimate_base_choice")) + expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "cancel" }) + // The property that matters: an unread Return cannot opt the installation into request logging. + expect(confirm.registrations()).toHaveLength(0) + } finally { + confirm.cleanup() + } +}) + test.serial( "the Big Pickle migration reuses consent, stays out of first-run telemetry, and routes No to the picker", async () => { From 3692084a9606fc00bdb850c93e28266e867a42f9 Mon Sep 17 00:00:00 2001 From: Sarav Date: Tue, 8 Sep 2026 19:00:37 +0530 Subject: [PATCH 5/6] fix: narrow the Base registration gate and invalidate both instance registries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the two blocking review findings on this PR. `FreeTierHost.current()` returned the whole gate — `setToken`, closing over the real armer, and `register`, redeeming against the real authority — so any module importing it held a raw mint primitive and could register without going near the disclosure, in any order. It is replaced by `canRegister()` plus `registerWithAcceptedDisclosure()`, which hash-verifies and then mints, arms and redeems in a single call inside `host.ts`. There is no ordering for a caller to get wrong and no primitive to borrow. This is deliberately not claimed as a trust boundary: `registerWithAcceptedDisclosure` is exported and the hash is a SHA-256 of public text, so in-process code can still cause a registration. What it closes is accidental misuse and the drift of re-implementing the check per call site. The module comment says so plainly. The register route disposed only the legacy `Instance` registry. `/api/*` is forwarded to the typed HttpApi bridge before that middleware runs and is backed by a separate `InstanceStore`, so a directory reached only through `/api/*` kept its stale provider state — exactly the "other window still shows Base as disconnected" case the disposal exists to prevent. Both registries are now disposed, and `staleProviders: true` is returned if either fails. `describeRoute` now states the blast radius: disposal is process-wide and tears down instance-scoped state (sessions, LSPs, PTYs, MCP connections, file watchers) in every attached window. Narrowing that to a targeted `Provider.state` invalidation needs changes in `provider.ts` and `state.ts`, outside this feature; tracked separately. Also corrects a docstring in `client.ts` that still located the disclosure-hash check in the register route after it moved into `host.ts`. Verified: 20 Altimate Base tests and 8 TUI dialog tests pass; no type errors in the changed files. End-to-end against a freshly built binary — a stale hash is refused without minting, and a successful registration connects `altimate-free` immediately. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HSTHWcVTkaVuxwto85ggrh --- packages/opencode/src/altimate/free/client.ts | 5 +- packages/opencode/src/altimate/free/host.ts | 58 ++++++++++++++-- packages/opencode/src/server/server.ts | 66 +++++++++++-------- 3 files changed, 95 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/altimate/free/client.ts b/packages/opencode/src/altimate/free/client.ts index 751ab181c..899c210d6 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -352,8 +352,9 @@ async function registerOnce( * whichever gate claimed the armer in its process. Provider discovery and inference never call it. * * What this guarantees: the token is authentic. What it does not: that a human read anything. Over - * HTTP that remains an assertion by the caller, narrowed only by the disclosure-hash check in the - * register route. + * HTTP that remains an assertion by the caller, narrowed only by the disclosure-hash check in + * `FreeTierHost.registerWithAcceptedDisclosure` — and that hash is derived from public text, so it + * proves the caller holds the current wording, not that anyone read it. */ export async function registerAfterConsent( token: string, diff --git a/packages/opencode/src/altimate/free/host.ts b/packages/opencode/src/altimate/free/host.ts index 707522a8c..1799d423f 100644 --- a/packages/opencode/src/altimate/free/host.ts +++ b/packages/opencode/src/altimate/free/host.ts @@ -12,9 +12,26 @@ // // `altimate serve` provides a gate. The TUI worker deliberately does NOT — the TUI owns its own // disclosure dialog, and a second registration surface inside that process would let a caller -// register without the dialog ever being shown. Consumers must treat `undefined` as "this host -// cannot register Altimate Base" and refuse, exactly as the TUI's provider picker does. -import type { createRegistrationConsentGate } from "./consent" +// register without the dialog ever being shown. Consumers must treat "cannot register" as final +// and refuse, exactly as the TUI's provider picker does. +// +// The gate itself is NEVER handed back out. An earlier revision exposed `current()`, which returned +// the whole gate — including `setToken` (closing over the real armer) and `register` (redeeming +// against the real authority) — so any importer held a raw mint primitive and could register +// without going near the disclosure, in any order it liked. The check now happens *inside* this +// module, in the same call that mints, arms and redeems: there is no ordering for a caller to get +// wrong and no primitive to borrow. +// +// What this is NOT: a trust boundary against in-process code. `registerWithAcceptedDisclosure` is +// exported, and the hash it demands is a SHA-256 of public text that any caller can recompute via +// `FreeTierConsent.disclosureHash()`. In-process code can therefore still cause a registration — +// it simply cannot do so while bypassing the documented precondition, and there is now one +// audited path instead of a capability handed to every importer. The real boundary is the process: +// anything running here is already trusted to execute tools. What this closes is accidental +// misuse and the drift that comes from re-implementing the check at each call site. +import { randomBytes } from "node:crypto" +import type { createRegistrationConsentGate, RegistrationResult } from "./consent" +import { FreeTierConsent } from "./consent" export type Registration = ReturnType @@ -36,9 +53,38 @@ export function provide(value: Registration): void { registration = value } -/** The host-injected gate, or `undefined` when this host cannot register Altimate Base. */ -export function current(): Registration | undefined { - return registration +/** Whether this host can register Altimate Base at all, i.e. whether an entrypoint provided a gate. */ +export function canRegister(): boolean { + return registration !== undefined +} + +export type RegisterOutcome = + /** No gate was provided; this host cannot register Altimate Base. */ + | { kind: "unavailable" } + /** The caller echoed a hash that is not the current disclosure's. */ + | { kind: "staleDisclosure" } + /** The gate ran; `result` carries its success or its classified failure. */ + | { kind: "done"; result: RegistrationResult } + +/** + * Verify the caller accepted the current disclosure text, then mint, arm and redeem in one step. + * + * The hash comparison lives here rather than in the caller so that holding the current disclosure + * text is a precondition of minting, not a convention the caller is trusted to follow. It is a + * **text-version agreement, not proof of consent**: it establishes that the caller holds the + * current wording, so a client still rendering superseded text cannot register people against text + * they were never shown. Any caller can fetch the disclosure and echo the hash, so "a human read + * this" remains an assertion by the caller. + */ +export async function registerWithAcceptedDisclosure(acceptedDisclosureSha256: string): Promise { + const gate = registration + if (!gate) return { kind: "unavailable" } + if (acceptedDisclosureSha256.toLowerCase() !== FreeTierConsent.disclosureHash()) { + return { kind: "staleDisclosure" } + } + const token = randomBytes(32).toString("hex") + gate.setToken({ token }) + return { kind: "done", result: await gate.register({ token }) } } export * as FreeTierHost from "./host" diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 4efb705e9..3ba664da1 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -40,9 +40,12 @@ import { readMcpEntryFromDisk } from "../mcp/config" import { resolveConfigPath } from "../mcp/config" import { enhancePrompt, isAutoEnhanceEnabled } from "../altimate/enhance-prompt" // altimate_change - Altimate Base disclosure + consent-gated registration for HTTP hosts -import { randomBytes } from "node:crypto" import { FreeTier } from "../altimate/free/client" import { FreeTierConsent } from "../altimate/free/consent" +// altimate_change start — Altimate Base registration must invalidate BOTH instance registries. +import { InstanceStore } from "@/project/instance-store" +import { AppRuntime } from "@/effect/app-runtime" +// altimate_change end import { FreeTierHost } from "../altimate/free/host" // altimate_change end import { FileRoutes } from "./routes/file" @@ -724,7 +727,7 @@ export namespace Server { }, }), async (c) => { - if (!FreeTierHost.current()) { + if (!FreeTierHost.canRegister()) { return c.json({ error: "This host cannot register Altimate Base." }, 501) } const registered = await FreeTier.isRegistered().catch((error) => { @@ -744,7 +747,7 @@ export namespace Server { describeRoute({ summary: "Register Altimate Base after consent", description: - "Mints the managed Altimate Base credential. The caller must echo the SHA-256 of the disclosure it displayed, which is verified against the canonical text, so a caller that never fetched the current disclosure cannot register. Disposes the instance on success so the provider loader re-reads the new credential.", + "Mints the managed Altimate Base credential. The caller must echo the SHA-256 of the disclosure it displayed, which is verified against the canonical text, so a caller that never fetched the current disclosure cannot register. On success this disposes EVERY cached instance in the process — both registries — so provider loaders re-read the new credential. That is deliberately process-wide because the credential is a single global file, and it is disruptive: instance-scoped state elsewhere on this server (sessions, LSPs, PTYs, MCP connections, file watchers) is torn down and re-created, and `server.instance.disposed` is emitted for each. `staleProviders: true` in the response means the credential was written but at least one registry could not be invalidated, so provider lists may still show Altimate Base as disconnected.", operationId: "altimateBase.register", responses: { 200: { @@ -777,8 +780,7 @@ export namespace Server { }), validator("json", z.object({ acceptedDisclosureSha256: z.string() })), async (c) => { - const gate = FreeTierHost.current() - if (!gate) { + if (!FreeTierHost.canRegister()) { return c.json({ error: "This host cannot register Altimate Base." }, 501) } @@ -801,12 +803,14 @@ export namespace Server { } const { acceptedDisclosureSha256 } = c.req.valid("json") - if (acceptedDisclosureSha256.toLowerCase() !== FreeTierConsent.disclosureHash()) { - // A text-version agreement, NOT proof of consent. It establishes that the caller holds - // the current disclosure, so a client rendering superseded wording cannot register - // people against text they were never shown. It does not establish that a human read - // anything — any caller can GET the disclosure and echo the hash back. "A human saw - // this" remains an assertion by the caller, exactly as it was. + // Hash verification, mint, arm and redeem all happen inside `FreeTierHost`, so this route + // cannot mint without checking and no other module can borrow the mint primitive. See + // `altimate/free/host.ts` for why the gate is never handed back out. + const attempt = await FreeTierHost.registerWithAcceptedDisclosure(acceptedDisclosureSha256) + if (attempt.kind === "unavailable") { + return c.json({ error: "This host cannot register Altimate Base." }, 501) + } + if (attempt.kind === "staleDisclosure") { return c.json( { ok: false as const, @@ -816,12 +820,7 @@ export namespace Server { 200, ) } - - // Mint, arm and redeem in one operation, mirroring the TUI's accept handler. Nothing - // outside this handler can obtain a token that the private authority will accept. - const token = randomBytes(32).toString("hex") - gate.setToken({ token }) - const outcome = await gate.register({ token }) + const outcome = attempt.result // The provider loader caches its credential read, so a freshly registered Base stays out // of `/provider`'s `connected` list until the instance cache is dropped. Doing it here @@ -833,16 +832,31 @@ export namespace Server { // `serve`, would keep every other instance's provider list showing Base as disconnected. // Invalidation has to be as wide as the state that changed. // - // A failure here leaves the credential written but provider lists stale, so it is reported - // rather than swallowed: the client needs to know its picker may be out of date. + // BOTH registries, because there are two. Legacy `Instance` backs the Hono routes below, + // while `/api/*` is forwarded to the typed HttpApi bridge before that middleware runs and + // is backed by a separate `InstanceStore`. Disposing only the legacy one left a directory + // reached exclusively through `/api/*` holding its old provider state — precisely the + // "other window still shows Base as disconnected" case this is here to prevent. + // + // A failure in either leaves the credential written but provider lists possibly stale, so + // it is reported rather than swallowed: the client needs to know its picker may be wrong. if (outcome.ok) { - const disposed = await Instance.disposeAll().then( - () => true, - (error) => { - log.error("Altimate Base registered but instance disposal failed", { error }) - return false - }, - ) + const disposed = await Promise.all([ + Instance.disposeAll().then( + () => true, + (error) => { + log.error("Altimate Base registered but legacy instance disposal failed", { error }) + return false + }, + ), + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll())).then( + () => true, + (error) => { + log.error("Altimate Base registered but InstanceStore disposal failed", { error }) + return false + }, + ), + ]).then((results) => results.every(Boolean)) return c.json(disposed ? outcome : { ...outcome, staleProviders: true as const }) } return c.json(outcome) From 6d58266429bc15fbb5e9f924ef239b1ae51592da Mon Sep 17 00:00:00 2001 From: Sarav Date: Tue, 8 Sep 2026 19:29:50 +0530 Subject: [PATCH 6/6] fix: wrap the serve logger in altimate_change markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marker Guard failed on this PR. `const log = Log.create({ service: "serve" })` sat just after the marker block that imports `Log`, so it read as unmarked custom code in an upstream-shared file and would not be protected from an upstream overwrite. Gives it its own marker pair rather than folding it into the import block, so the reason it exists — the Base registration gate's `onUnexpectedError` hook — is recorded where the code is. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HSTHWcVTkaVuxwto85ggrh --- packages/opencode/src/cli/cmd/serve.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index d4061612d..200c7eaac 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -16,7 +16,9 @@ import { FreeTierHost } from "../../altimate/free/host" import { Log } from "../../util/log" // altimate_change end +// altimate_change start — logger for the Base registration gate's onUnexpectedError hook const log = Log.create({ service: "serve" }) +// altimate_change end export const ServeCommand = effectCmd({ command: "serve",