diff --git a/packages/core/src/altimate-base-disclosure.ts b/packages/core/src/altimate-base-disclosure.ts new file mode 100644 index 000000000..4afce361b --- /dev/null +++ b/packages/core/src/altimate-base-disclosure.ts @@ -0,0 +1,24 @@ +// 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 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. +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." + +/** + * 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/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..899c210d6 100644 --- a/packages/opencode/src/altimate/free/client.ts +++ b/packages/opencode/src/altimate/free/client.ts @@ -345,9 +345,16 @@ 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 + * `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/consent.ts b/packages/opencode/src/altimate/free/consent.ts index cd36510d9..721fbf86f 100644 --- a/packages/opencode/src/altimate/free/consent.ts +++ b/packages/opencode/src/altimate/free/consent.ts @@ -1,6 +1,36 @@ +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, plus the picker hint, + * served to hosts that render their own disclosure (the VS Code extension's chat panel, via + * GET /altimate/base/disclosure). + * + * 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, + 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. 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") +} + 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..1799d423f --- /dev/null +++ b/packages/opencode/src/altimate/free/host.ts @@ -0,0 +1,91 @@ +// altimate_change start — host-injected Altimate Base registration for non-TUI entrypoints. +// +// `FreeTierCapability.issueArmer()` is claimable exactly once per process and throws on a second +// call, so an HTTP route cannot claim one for itself: the TUI worker already claims it at boot for +// its own RPC gate, and that worker also serves HTTP from the same process. A route-level claim +// would therefore break the TUI worker the moment the routes module loaded — and any test that +// imported both the server and the Base test harness. +// +// Instead the entrypoint that owns the process claims the capability once and hands the resulting +// gate here. This is the same shape the TUI already uses for the same operation +// (`packages/tui/src/context/altimate-base-consent.tsx`): the host injects, the consumer checks. +// +// `altimate serve` provides a gate. The TUI worker deliberately does NOT — the TUI owns its own +// disclosure dialog, and a second registration surface inside that process would let a caller +// register without the dialog ever being shown. Consumers must treat "cannot register" as final +// and refuse, exactly as the TUI's provider picker does. +// +// The gate itself is NEVER handed back out. An earlier revision exposed `current()`, which returned +// the whole gate — including `setToken` (closing over the real armer) and `register` (redeeming +// against the real authority) — so any importer held a raw mint primitive and could register +// without going near the disclosure, in any order it liked. The check now happens *inside* this +// module, in the same call that mints, arms and redeems: there is no ordering for a caller to get +// wrong and no primitive to borrow. +// +// What this is NOT: a trust boundary against in-process code. `registerWithAcceptedDisclosure` is +// exported, and the hash it demands is a SHA-256 of public text that any caller can recompute via +// `FreeTierConsent.disclosureHash()`. In-process code can therefore still cause a registration — +// it simply cannot do so while bypassing the documented precondition, and there is now one +// audited path instead of a capability handed to every importer. The real boundary is the process: +// anything running here is already trusted to execute tools. What this closes is accidental +// misuse and the drift that comes from re-implementing the check at each call site. +import { randomBytes } from "node:crypto" +import type { createRegistrationConsentGate, RegistrationResult } from "./consent" +import { FreeTierConsent } from "./consent" + +export type Registration = ReturnType + +let registration: Registration | undefined + +/** + * Install the process's registration gate. Called once by the entrypoint, before the server starts + * accepting requests. + * + * Single-shot, matching every other capability in this area: a second call throws rather than + * silently replacing the gate. The earlier last-write-wins behaviour let any in-process caller swap + * the gate out from under the routes after `serve` installed the real one. That was never a + * privilege escalation — such code is already trusted and still cannot forge a token the private + * authority accepts — but it was a weaker invariant than `issueArmer()`/`issueRedeemer()` next door, + * for no benefit. + */ +export function provide(value: Registration): void { + if (registration) throw new Error("Altimate Base registration gate already provided for this process") + registration = value +} + +/** Whether this host can register Altimate Base at all, i.e. whether an entrypoint provided a gate. */ +export function canRegister(): boolean { + return registration !== undefined +} + +export type RegisterOutcome = + /** No gate was provided; this host cannot register Altimate Base. */ + | { kind: "unavailable" } + /** The caller echoed a hash that is not the current disclosure's. */ + | { kind: "staleDisclosure" } + /** The gate ran; `result` carries its success or its classified failure. */ + | { kind: "done"; result: RegistrationResult } + +/** + * Verify the caller accepted the current disclosure text, then mint, arm and redeem in one step. + * + * The hash comparison lives here rather than in the caller so that holding the current disclosure + * text is a precondition of minting, not a convention the caller is trusted to follow. It is a + * **text-version agreement, not proof of consent**: it establishes that the caller holds the + * current wording, so a client still rendering superseded text cannot register people against text + * they were never shown. Any caller can fetch the disclosure and echo the hash, so "a human read + * this" remains an assertion by the caller. + */ +export async function registerWithAcceptedDisclosure(acceptedDisclosureSha256: string): Promise { + const gate = registration + if (!gate) return { kind: "unavailable" } + if (acceptedDisclosureSha256.toLowerCase() !== FreeTierConsent.disclosureHash()) { + return { kind: "staleDisclosure" } + } + const token = randomBytes(32).toString("hex") + gate.setToken({ token }) + return { kind: "done", result: await gate.register({ token }) } +} + +export * as FreeTierHost from "./host" +// altimate_change end diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index b817cded2..200c7eaac 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -8,6 +8,17 @@ 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" +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", @@ -25,6 +36,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) => log.error("Altimate Base registration failed", { error }), + }), + ), + ) + // altimate_change end const { Server } = yield* Effect.promise(() => import("../../server/server")) if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") 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 d8c9caf23..3ba664da1 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -39,6 +39,14 @@ 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 { FreeTier } from "../altimate/free/client" +import { FreeTierConsent } from "../altimate/free/consent" +// altimate_change start — Altimate Base registration must invalidate BOTH instance registries. +import { InstanceStore } from "@/project/instance-store" +import { AppRuntime } from "@/effect/app-runtime" +// altimate_change end +import { FreeTierHost } from "../altimate/free/host" // altimate_change end import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" @@ -671,6 +679,190 @@ export namespace Server { }, ) // altimate_change end + // altimate_change start — Altimate Base disclosure + consent-gated registration + // Registration mints a persistent per-installation identifier and opts the user into request + // logging, so `FreeTier.registerAfterConsent` only acts on a token armed through the process's + // single private consent authority. The TUI arms that token inside its disclosure dialog's + // accept handler; these routes are the equivalent for a host that renders its own disclosure + // (the VS Code extension's chat panel). + // + // A host that injected no gate (the TUI worker, which owns its own dialog) serves 501 rather + // than a second registration surface that could bypass that dialog. + // + // The register route additionally requires the caller to echo the disclosure's SHA-256. That + // is a text-version check, not consent enforcement — see the comment at the comparison below. + // + // GET is deliberately READ-ONLY. An earlier revision armed the consent token here, which + // meant the store's 30s TTL began when the disclosure was fetched rather than when the user + // accepted it — so anyone who actually read the text before consenting was rejected. That + // also made GET non-idempotent and let a burst of fetches evict pending tokens. The token is + // now minted, armed and redeemed entirely inside POST, in one operation. + .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.canRegister()) { + return c.json({ error: "This host cannot register Altimate Base." }, 501) + } + const registered = await FreeTier.isRegistered().catch((error) => { + log.warn("failed to read Altimate Base registration state", { error }) + return false + }) + return c.json({ + disclosure: FreeTierConsent.DISCLOSURE, + hint: FreeTierConsent.HINT, + sha256: FreeTierConsent.disclosureHash(), + registered, + }) + }, + ) + .post( + "/altimate/base/register", + 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. 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: { + description: "Registration outcome", + content: { + "application/json": { + schema: resolver( + z.union([ + 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"]), + 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) => { + if (!FreeTierHost.canRegister()) { + return c.json({ error: "This host cannot register Altimate Base." }, 501) + } + + // A browser on a CORS-allowed origin can reach this port without being a local process, + // which is a different reachability class from "can already execute tools here". Native + // clients (the extension host, curl) send no Origin, so refusing an Origin-bearing + // 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") + // Hash verification, mint, arm and redeem all happen inside `FreeTierHost`, so this route + // cannot mint without checking and no other module can borrow the mint primitive. See + // `altimate/free/host.ts` for why the gate is never handed back out. + const attempt = await FreeTierHost.registerWithAcceptedDisclosure(acceptedDisclosureSha256) + if (attempt.kind === "unavailable") { + return c.json({ error: "This host cannot register Altimate Base." }, 501) + } + if (attempt.kind === "staleDisclosure") { + return c.json( + { + ok: false as const, + result: "error" as const, + message: "The accepted disclosure is out of date. Reopen setup and try again.", + }, + 200, + ) + } + const outcome = attempt.result + + // 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 + // 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. + // + // 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 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) + }, + ) + // 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/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/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 4cdd65ca4..ca23d24a5 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, 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 @@ -185,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", @@ -345,14 +347,18 @@ 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 states the core data terms up front: requests/responses may be logged and used to -// improve Altimate's products (including the model), so users should not send secrets. The -// persistent per-install-id linkage detail is disclosed in docs/docs/configure/providers.md -// ("Data handling"), not repeated in this gate; keep the core terms in sync with that note. -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." +// altimate_change start — surfaced in the DialogAltimateBaseConfirm consent gate below before any +// Base credential is minted. This is the text a user actually consents against before any +// registration request, so it states the core data terms up front: requests/responses may be +// logged and used to improve Altimate's products (including the model), so users should not send +// secrets. The persistent per-install-id linkage detail is disclosed in +// docs/docs/configure/providers.md ("Data handling"), not repeated in this gate; keep the core +// terms in sync with that note. +// +// Defined once in core (imported at the top of this file) and re-exported here for existing +// consumers, so this dialog and the HTTP disclosure route (packages/opencode, for hosts that +// render their own dialog) cannot drift apart — a copy change like #1268 now lands on both. +export { ALTIMATE_BASE_DISCLOSURE } // altimate_change end type RegisterOutcome = 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], 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 () => {