diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index 38ee4c2dd5..ab8dda7375 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -43,6 +43,8 @@ Managed LLM access with dynamic routing across Sonnet 4.6, Opus 4.6, GPT-5.4, GP For pricing, security, and data handling details, see the [Altimate LLM Gateway guide](https://help.altimate.ai/datamates/user-guide/components/llm-gateway/). +To sign out, run `altimate providers logout altimate-backend` (or `/logout` in the TUI). This deletes `~/.altimate/altimate.json` (`%USERPROFILE%\.altimate\altimate.json` on Windows), removes the auth-store entry, and removes datamate MCP servers in the global and current project config that carry the saved key. New sessions stop using the key; restart any session that is already running, or run `/logout` inside it. Datamate MCP servers configured in other projects are not removed. + !!! tip "Automatic model selection" When Altimate credentials are configured and no model is explicitly chosen, the Altimate LLM Gateway is selected automatically. You can override this by setting `model` in your config or by restricting the `provider` section to specific providers only. diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 492d9dd702..14169eee89 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -3,6 +3,7 @@ import path from "path" import { rm } from "node:fs/promises" import { Global } from "../../global" import { Filesystem } from "../../util/filesystem" +import { findAllConfigPaths, listMcpInConfig, readMcpEntryFromDisk, removeMcpFromConfig } from "../../mcp/config" const DEFAULT_MCP_URL = "https://mcpserver.getaltimate.com/sse" // altimate_change start — default Altimate API URL when user omits it from the TUI credential entry @@ -147,11 +148,33 @@ export namespace AltimateApi { ) } - /** Remove the stored gateway credential file (sign out). No-op if absent. */ + /** Delete ~/.altimate/altimate.json, including any non-credential fields such as `mcpServerUrl`. No-op if absent. */ export async function clearCredentials(): Promise { await rm(credentialsPath(), { force: true }) } + /** Sign out of the gateway: remove MCP entries that carry the saved key, then delete the credential file. + * Standalone `datamate add` writes the key into MCP headers (`buildMcpConfig`); matching the exact key + * leaves user-authored entries alone. Only the global config and `projectDir` are searched. */ + export async function signOut(projectDir: string): Promise> { + const key = await getCredentials().then( + (creds) => creds.altimateApiKey, + () => undefined, + ) + const removed: Array<{ name: string; path: string }> = [] + if (key) { + for (const configPath of await findAllConfigPaths(projectDir, Global.Path.config)) { + for (const name of await listMcpInConfig(configPath)) { + const entry = await readMcpEntryFromDisk(name, configPath) + if (entry?.type !== "remote" || entry.headers?.Authorization !== `Bearer ${key}`) continue + if (await removeMcpFromConfig(name, configPath)) removed.push({ name, path: configPath }) + } + } + } + await clearCredentials() + return removed + } + const VALID_TENANT_REGEX = /^[a-z_][a-z0-9_-]*$/ /** True if `name` is a well-formed instance/tenant name. */ diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index f77b4d0d13..4327023714 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -19,6 +19,7 @@ import type { Hooks } from "@opencode-ai/plugin" import { Process } from "@/util/process" import { errorMessage } from "@/util/error" import { FreeTier } from "@/altimate/free/client" +import { AltimateApi } from "@/altimate/api/client" import { text } from "node:stream/consumers" import { Effect, Option } from "effect" @@ -605,6 +606,14 @@ export const ProvidersLogoutCommand = effectCmd({ label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", value: key, })) + // Credentials saved only to ~/.altimate/altimate.json (TUI paste dialog) have no auth-store entry. + const hasAltimateBackendFile = yield* Effect.promise(() => AltimateApi.isConfigured()) + if (hasAltimateBackendFile && !options.some((option) => option.value === "altimate-backend")) { + options.push({ + label: (database["altimate-backend"]?.name || "altimate-backend") + UI.Style.TEXT_DIM + " (api)", + value: "altimate-backend", + }) + } if (hasAltimateBaseCredential || hasLegacyAltimateBaseCredential || hasAltimateBaseState) { options.push({ label: "Altimate Base" + UI.Style.TEXT_DIM + " (managed)", @@ -638,6 +647,16 @@ export const ProvidersLogoutCommand = effectCmd({ yield* Prompt.outro("Logout successful") return } + if (provider === "altimate-backend") { + // Browser sign-in persists the gateway key to ~/.altimate/altimate.json, which the provider + // loader and every workspace call read first, so sign-out must remove it too. + const removed = yield* cliTry( + `Failed to sign out of Altimate (if ${AltimateApi.credentialsPath()} remains, delete it manually): `, + () => AltimateApi.signOut(process.cwd()), + ) + for (const entry of removed) yield* Prompt.log.info(`Removed MCP server "${entry.name}" from ${entry.path}`) + if (removed.length > 0) yield* Prompt.log.info("Add the datamate again after signing in to restore it.") + } // altimate_change end yield* Effect.orDie(authSvc.remove(provider)) yield* Prompt.outro("Logout successful") diff --git a/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx b/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx index 9dc4741945..1c11e58a62 100644 --- a/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx +++ b/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx @@ -86,9 +86,12 @@ function show(api: TuiPluginApi) { // opencode-side and unreachable from packages/tui. async function logout(api: TuiPluginApi) { try { - await AltimateApi.clearCredentials() + const removed = await AltimateApi.signOut(api.state.path.directory) + // A pasted url::instance::key in the auth store keeps the provider loader's fallback path live. + await api.client.auth.remove({ providerID: PROVIDER_ID }, { throwOnError: true }) await api.client.instance.dispose() - api.ui.toast({ variant: "success", message: "Signed out of Altimate LLM Gateway" }) + const mcpNote = removed.length > 0 ? ` and removed ${removed.map((entry) => entry.name).join(", ")}` : "" + api.ui.toast({ variant: "success", message: `Signed out of Altimate LLM Gateway${mcpNote}` }) } catch (err) { api.ui.toast({ variant: "error", message: err instanceof Error ? err.message : "Sign-out failed" }) } diff --git a/packages/opencode/test/cli/providers-logout.test.ts b/packages/opencode/test/cli/providers-logout.test.ts index ef80e4e3ea..c9b19e6d7b 100644 --- a/packages/opencode/test/cli/providers-logout.test.ts +++ b/packages/opencode/test/cli/providers-logout.test.ts @@ -97,4 +97,118 @@ describe("providers logout", () => { }), 120_000, ) + + cliIt.live( + "removes the saved Altimate gateway key along with the auth-store entry", + ({ home, opencode }) => + Effect.gen(function* () { + const dataDir = path.join(home, ".local", "share", "altimate-code") + const authPath = path.join(dataDir, "auth.json") + const altimatePath = path.join(home, ".altimate", "altimate.json") + const anthropic = { type: "api", key: "anthropic-test-key" } + const writeAltimate = () => + fs.writeFile( + altimatePath, + JSON.stringify({ + altimateUrl: "https://api.test", + altimateInstanceName: "tenant", + altimateApiKey: "gateway-test-key", + }), + { mode: 0o600 }, + ) + const altimateFileExists = () => + fs.stat(altimatePath).then( + () => true, + () => false, + ) + + yield* Effect.promise(() => fs.mkdir(dataDir, { recursive: true })) + yield* Effect.promise(() => fs.mkdir(path.dirname(altimatePath), { recursive: true })) + yield* Effect.promise(() => writeAltimate()) + yield* Effect.promise(() => + fs.writeFile( + authPath, + JSON.stringify({ anthropic, "altimate-backend": { type: "api", key: "gateway-test-key" } }), + { mode: 0o600 }, + ), + ) + + const logout = yield* opencode.spawn(["providers", "logout", "altimate-backend"], { + env: { OPENCODE_AUTH_CONTENT: "" }, + }) + opencode.expectExit(logout, 0, "providers logout altimate-backend") + expect(logout.stdout).toContain("Logout successful") + expect(yield* Effect.promise(() => altimateFileExists())).toBe(false) + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({ anthropic }) + + // Credentials saved only to altimate.json (TUI paste dialog) must still be removable. + yield* Effect.promise(() => writeAltimate()) + const fileOnlyLogout = yield* opencode.spawn(["providers", "logout", "altimate-backend"], { + env: { OPENCODE_AUTH_CONTENT: "" }, + }) + opencode.expectExit(fileOnlyLogout, 0, "providers logout file-only altimate-backend") + expect(fileOnlyLogout.stdout).toContain("Logout successful") + expect(yield* Effect.promise(() => altimateFileExists())).toBe(false) + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(authPath, "utf8")))).toEqual({ anthropic }) + }), + 120_000, + ) + + cliIt.live( + "removes datamate MCP entries that carry the signed-out gateway key", + ({ home, opencode }) => + Effect.gen(function* () { + const altimatePath = path.join(home, ".altimate", "altimate.json") + // The CLI runs with cwd=home, so home is the project directory. + const projectConfigPath = path.join(home, "altimate-code.json") + const globalConfigPath = path.join(home, ".config", "altimate-code", "altimate-code.json") + const datamateEntry = (key: string) => ({ + type: "remote", + url: "https://mcp.test", + oauth: false, + headers: { Authorization: `Bearer ${key}`, "x-datamate-id": "1", "x-tenant": "tenant" }, + enabled: true, + }) + const unrelated = datamateEntry("someone-elses-key") + + yield* Effect.promise(() => fs.mkdir(path.dirname(altimatePath), { recursive: true })) + yield* Effect.promise(() => fs.mkdir(path.dirname(globalConfigPath), { recursive: true })) + yield* Effect.promise(() => + fs.writeFile( + altimatePath, + JSON.stringify({ + altimateUrl: "https://api.test", + altimateInstanceName: "tenant", + altimateApiKey: "gateway-test-key", + }), + { mode: 0o600 }, + ), + ) + yield* Effect.promise(() => + fs.writeFile( + projectConfigPath, + JSON.stringify({ mcp: { "datamate-sales": datamateEntry("gateway-test-key"), unrelated } }), + ), + ) + yield* Effect.promise(() => + fs.writeFile( + globalConfigPath, + JSON.stringify({ mcp: { "datamate-ops": datamateEntry("gateway-test-key") } }), + ), + ) + + const logout = yield* opencode.spawn(["providers", "logout", "altimate-backend"], { + env: { OPENCODE_AUTH_CONTENT: "" }, + }) + opencode.expectExit(logout, 0, "providers logout altimate-backend with datamate MCP entries") + expect(logout.stdout).toContain('Removed MCP server "datamate-sales"') + expect(logout.stdout).toContain('Removed MCP server "datamate-ops"') + expect(logout.stdout).toContain("Logout successful") + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(projectConfigPath, "utf8")))).toEqual({ + mcp: { unrelated }, + }) + expect(JSON.parse(yield* Effect.promise(() => fs.readFile(globalConfigPath, "utf8")))).toEqual({ mcp: {} }) + }), + 120_000, + ) })