diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index 39b7fcd1f5..7dac03ff9a 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 remote MCP servers whose `Authorization` header carries the saved key, such as those added with `datamate add`. It checks the same config files a session started in the current directory loads: the global config, `~/.altimate-code`, and project config from the current directory up to the git root (every parent directory outside git). New sessions stop using the key; restart any session that is already running, or run `/logout` inside it. MCP servers configured in other projects are not removed. If the saved key can't be read, logout fails and keeps the file so you can fix it and run logout again. + !!! 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 3b6875d5d5..178b777036 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -3,6 +3,13 @@ import path from "path" import { rm } from "node:fs/promises" import { Global } from "../../global" import { Filesystem } from "../../util/filesystem" +import { + findGlobalConfigPaths, + findProjectConfigPaths, + listMcpInConfig, + readMcpEntryFromDisk, + removeMcpFromConfig, +} from "../../mcp/config" import { InstallationVersion } from "@opencode-ai/core/installation/version" const DEFAULT_MCP_URL = "https://mcpserver.getaltimate.com/sse" @@ -154,11 +161,53 @@ 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 }) } + /** Directories whose project config a session in `directory` loads, mirroring `ConfigPaths`: `directory` + * up to the git root (every ancestor outside git, where the worktree is "/"), plus `~/.altimate-code`. */ + async function projectConfigDirs(directory: string): Promise { + const dirs = [Global.Path.home] + for (let dir = directory; ; dir = path.dirname(dir)) { + dirs.push(dir) + if (path.dirname(dir) === dir || (await Filesystem.exists(path.join(dir, ".git")))) return [...new Set(dirs)] + } + } + + /** 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. Searches the global config and every project config a session in + * `projectDir` loads. Fails without deleting the file when the key can't be read: the file holds the + * only copy, and without it leftover MCP entries can never be matched. */ + export async function signOut(projectDir: string): Promise> { + if (!(await isConfigured())) return [] + let key: string + try { + key = (await getCredentials()).altimateApiKey + } catch (err) { + throw new Error( + `could not read the saved key from ${credentialsPath()} (${err instanceof Error ? err.message : err}); ` + + "fix the file and sign out again so MCP servers that carry the key are removed too", + ) + } + const configPaths = [ + ...(await Promise.all((await projectConfigDirs(projectDir)).map(findProjectConfigPaths))).flat(), + ...(await findGlobalConfigPaths(Global.Path.config)), + ] + const removed: Array<{ name: string; path: string }> = [] + for (const configPath of configPaths) { + 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..c631091518 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,17 @@ 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. + // On failure the saved key and the auth-store entry are both kept, so fixing the cause and + // re-running logout can still match and remove every copy of the key. + const removed = yield* cliTry("Failed to sign out of Altimate; fix the cause below and run logout again: ", () => + 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 b5609c1f16..dc4d2cf3e7 100644 --- a/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx +++ b/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx @@ -87,11 +87,14 @@ 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 }) // Rows from here on carry no account; the batch already buffered keeps the one it was tracked under. void Telemetry.refreshIdentity().catch(() => {}) 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/altimate/sign-out.test.ts b/packages/opencode/test/altimate/sign-out.test.ts new file mode 100644 index 0000000000..6fb1ad4224 --- /dev/null +++ b/packages/opencode/test/altimate/sign-out.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import path from "path" +import os from "os" +import fsp from "fs/promises" + +import { AltimateApi } from "../../src/altimate/api/client" + +const tmpRoot = path.join(os.tmpdir(), "sign-out-test-" + process.pid + "-" + Math.random().toString(36).slice(2)) +const originalTestHome = process.env.OPENCODE_TEST_HOME + +const datamateEntry = (key: string) => ({ + type: "remote", + url: "https://mcp.test", + oauth: false, + headers: { Authorization: `Bearer ${key}`, "x-datamate-id": "1", "x-tenant": "tenant" }, +}) + +async function writeJson(file: string, value: unknown) { + await fsp.mkdir(path.dirname(file), { recursive: true }) + await fsp.writeFile(file, JSON.stringify(value)) +} + +const readJson = async (file: string) => JSON.parse(await fsp.readFile(file, "utf8")) + +describe("AltimateApi.signOut", () => { + const home = path.join(tmpRoot, "home") + const outside = path.join(tmpRoot, "outside") + const repo = path.join(outside, "repo") + const subdir = path.join(repo, "models", "staging") + const rootConfig = path.join(repo, ".altimate-code", "altimate-code.json") + const outsideConfig = path.join(outside, "altimate-code.json") + + beforeEach(async () => { + process.env.OPENCODE_TEST_HOME = home + await fsp.mkdir(path.join(repo, ".git"), { recursive: true }) + await fsp.mkdir(subdir, { recursive: true }) + }) + + afterEach(async () => { + if (originalTestHome === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = originalTestHome + await fsp.rm(tmpRoot, { recursive: true, force: true }).catch(() => {}) + }) + + test("from a subdirectory, removes entries in configs up to the git root and no further", async () => { + await writeJson(AltimateApi.credentialsPath(), { + altimateUrl: "https://api.test", + altimateInstanceName: "tenant", + altimateApiKey: "gateway-test-key", + }) + await writeJson(rootConfig, { mcp: { "datamate-sales": datamateEntry("gateway-test-key") } }) + await writeJson(outsideConfig, { mcp: { "datamate-other": datamateEntry("gateway-test-key") } }) + + const removed = await AltimateApi.signOut(subdir) + + expect(removed).toEqual([{ name: "datamate-sales", path: rootConfig }]) + expect(await readJson(rootConfig)).toEqual({ mcp: {} }) + // Above the git root, outside what a session in the repo loads. + expect(await readJson(outsideConfig)).toEqual({ mcp: { "datamate-other": datamateEntry("gateway-test-key") } }) + expect(await AltimateApi.isConfigured()).toBe(false) + }) + + test("outside git, removes entries in every ancestor config and in ~/.altimate-code", async () => { + const plain = path.join(tmpRoot, "plain") + const plainConfig = path.join(plain, ".altimate-code", "altimate-code.json") + const homeConfig = path.join(home, ".altimate-code", "altimate-code.json") + await writeJson(AltimateApi.credentialsPath(), { + altimateUrl: "https://api.test", + altimateInstanceName: "tenant", + altimateApiKey: "gateway-test-key", + }) + await writeJson(plainConfig, { mcp: { "datamate-sales": datamateEntry("gateway-test-key") } }) + await writeJson(homeConfig, { mcp: { "datamate-ops": datamateEntry("gateway-test-key") } }) + + const removed = await AltimateApi.signOut(path.join(plain, "a", "b")) + + expect(removed).toContainEqual({ name: "datamate-sales", path: plainConfig }) + expect(removed).toContainEqual({ name: "datamate-ops", path: homeConfig }) + expect(await readJson(plainConfig)).toEqual({ mcp: {} }) + expect(await readJson(homeConfig)).toEqual({ mcp: {} }) + }) + + test("keeps the credential file and MCP entries when the saved key can't be read", async () => { + delete process.env.SIGN_OUT_TEST_MISSING_KEY + await writeJson(AltimateApi.credentialsPath(), { + altimateUrl: "https://api.test", + altimateInstanceName: "tenant", + altimateApiKey: "${env:SIGN_OUT_TEST_MISSING_KEY}", + }) + await writeJson(rootConfig, { mcp: { "datamate-sales": datamateEntry("gateway-test-key") } }) + + await expect(AltimateApi.signOut(subdir)).rejects.toThrow("could not read the saved key") + + expect(await AltimateApi.isConfigured()).toBe(true) + expect(await readJson(rootConfig)).toEqual({ mcp: { "datamate-sales": datamateEntry("gateway-test-key") } }) + }) + + test("is a no-op when there is no credential file", async () => { + await writeJson(rootConfig, { mcp: { "datamate-sales": datamateEntry("gateway-test-key") } }) + + expect(await AltimateApi.signOut(subdir)).toEqual([]) + expect(await readJson(rootConfig)).toEqual({ mcp: { "datamate-sales": datamateEntry("gateway-test-key") } }) + }) +}) 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, + ) })